Three-role workflow (questioner → answerer → explorer) that iterates over .knowledge/ cards to discover and fill knowledge gaps via BFS. - questioner: createLlmRole, reads card, asks 3 technical questions - answerer: spawnSafe nerve knowledge query, judges answers - explorer: reads code, writes/patches .knowledge cards, runs sync - moderator: BFS queue from message history, stagnation rule Closes #266
105 lines
3.2 KiB
TypeScript
105 lines
3.2 KiB
TypeScript
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
|
import { llmExtract, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
import { z } from "zod";
|
|
|
|
import { resolveWorkdir } from "../lib/workdir.js";
|
|
|
|
import type { QuestionerMeta } from "./questioner.js";
|
|
|
|
export const answererMetaSchema = z.object({
|
|
results: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
found: z.boolean(),
|
|
source: z.string(),
|
|
note: z.string(),
|
|
}),
|
|
),
|
|
has_unanswered: z.boolean(),
|
|
});
|
|
|
|
export type AnswererMeta = z.infer<typeof answererMetaSchema>;
|
|
|
|
export type CreateAnswererRoleDeps = {
|
|
extract: LlmExtractorConfig;
|
|
};
|
|
|
|
function lastQuestionerMeta(messages: WorkflowMessage[]): QuestionerMeta | undefined {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i].role === "questioner") {
|
|
return messages[i].meta as QuestionerMeta;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererMeta> {
|
|
const { extract } = deps;
|
|
|
|
return async (start: StartStep, messages: WorkflowMessage[]) => {
|
|
const cwd = resolveWorkdir(start);
|
|
const qm = lastQuestionerMeta(messages);
|
|
if (!qm || qm.questions.length === 0) {
|
|
return {
|
|
content: "answerer: no questions from questioner; skipping CLI lookup.",
|
|
meta: { results: [], has_unanswered: false },
|
|
};
|
|
}
|
|
|
|
const blocks: string[] = [];
|
|
for (const q of qm.questions) {
|
|
if (start.meta.dryRun) {
|
|
blocks.push(`### ${q.id}\n[dryRun] skipped nerve knowledge query\n`);
|
|
continue;
|
|
}
|
|
const res = await spawnSafe(
|
|
"nerve",
|
|
["knowledge", "query", q.question],
|
|
{
|
|
cwd,
|
|
env: nerveCommandEnv(),
|
|
timeoutMs: 120_000,
|
|
dryRun: false,
|
|
abortSignal: null,
|
|
},
|
|
);
|
|
if (res.ok) {
|
|
blocks.push(`### ${q.id} (${q.domain})\nQuestion: ${q.question}\n---\n${res.value.stdout}\n`);
|
|
} else {
|
|
const err = res.error;
|
|
const detail =
|
|
err.kind === "non_zero_exit"
|
|
? `exit ${err.exitCode}\n${err.stderr}`
|
|
: err.kind === "timeout"
|
|
? `timeout\n${err.stderr}`
|
|
: err.kind === "spawn_failed"
|
|
? err.message
|
|
: "aborted";
|
|
blocks.push(`### ${q.id}\nnerve knowledge query failed: ${detail}\n`);
|
|
}
|
|
}
|
|
|
|
const bundle = [
|
|
"You are the **answerer**. You MUST NOT read repository source code — only the CLI retrieval excerpts below.",
|
|
"For each question id, decide whether the knowledge base already answers it.",
|
|
"Set found=true only when the excerpt supports a confident answer; otherwise found=false.",
|
|
"Set has_unanswered=true if any question remains unanswered by the knowledge base.",
|
|
"",
|
|
...blocks,
|
|
].join("\n");
|
|
|
|
const metaR = await llmExtract({
|
|
text: bundle,
|
|
schema: answererMetaSchema,
|
|
provider: extract.provider,
|
|
dryRun: start.meta.dryRun,
|
|
});
|
|
if (!metaR.ok) {
|
|
throw new Error(`answerer llmExtract: ${JSON.stringify(metaR.error)}`);
|
|
}
|
|
|
|
return { content: bundle, meta: metaR.value };
|
|
};
|
|
}
|