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; 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 { 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 }; }; }