- questioner: createRole(adapter, questionerPrompt, schema, extract) + queue short-circuit + meta post-processing - answerer: createRole(adapter, answererPrompt, schema, extract) + empty-questions short-circuit - build.ts: use createLlmAdapter(extract.provider) as default LLM adapter for questioner/answerer Refs uncaged/nerve#277
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
|
|
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
|
import { createRole, 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 async function answererPrompt(ctx: ThreadContext): Promise<string> {
|
|
const messages = ctx.steps as unknown as WorkflowMessage[];
|
|
const cwd = resolveWorkdir(ctx.start);
|
|
const qm = lastQuestionerMeta(messages);
|
|
if (!qm || qm.questions.length === 0) {
|
|
throw new Error("answerer: prompt invoked without questioner questions — wrapped role should short-circuit");
|
|
}
|
|
|
|
const blocks: string[] = [];
|
|
for (const q of qm.questions) {
|
|
if ((ctx.start.meta as Record<string, unknown>).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`);
|
|
}
|
|
}
|
|
|
|
return [
|
|
"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");
|
|
}
|
|
|
|
export function createAnswererRole(adapter: AgentFn, { extract }: CreateAnswererRoleDeps): Role<AnswererMeta> {
|
|
const inner = createRole(adapter, answererPrompt, answererMetaSchema, extract);
|
|
|
|
return async (ctx: ThreadContext) => {
|
|
const messages = ctx.steps as unknown as WorkflowMessage[];
|
|
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 },
|
|
};
|
|
}
|
|
return inner(ctx);
|
|
};
|
|
}
|