import { Type } from "@earendil-works/pi-ai"; import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { formatStatus } from "../src/harness/format.ts"; import { addQuestion, advanceRunIfReady, answerQuestion, readActiveRun, reviseFact } from "../src/harness/state.ts"; import { formatFactLabelCatalog } from "../src/harness/prompt-policy.ts"; import { formatAskUserModelOutput, formatStructuredAnswerInstruction, validateAskUserQuestions, type NormalizedAskUserPrompt, } from "../src/harness/question-interaction.ts"; import { formatQuestionArtifactLines, formatQuestionCard, formatQuestions } from "../src/harness/extension-format.ts"; import { appendQuestionEventToArtifact } from "../src/harness/question-artifacts.ts"; import { handleGoalCommand } from "../src/harness/goal-loop.ts"; const NO_ACTIVE_TASK = "当前没有正在处理的需求。直接用 /kd <需求> 开始。"; export function registerKingdeeHarnessTools(pi: ExtensionAPI): void { pi.registerTool(kdPlanStatusTool); pi.registerTool(kdAskUserTool); pi.registerTool(kdAnswerUserTool); pi.registerTool(kdGoalTool); } const kdPlanStatusTool = defineTool({ name: "kd_plan_status", label: "KD 状态", description: "查看当前需求、阶段和下一步阻塞点。", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { const run = readActiveRun(ctx.cwd); return { content: [{ type: "text", text: formatStatus(ctx.cwd, run) }], details: { run }, }; }, }); const kdAnswerUserTool = defineTool({ name: "kd_answer_user", label: "KD 回答", description: "记录已登记用户澄清问题的答案、修订结构化事实、列出问题,或列出合法 factLabel。该工具不发起提问;提问必须使用 kd_ask_user。", parameters: Type.Object({ action: Type.Optional(Type.String({ description: "操作类型:answer、revise、list 或 labels,默认 list。" })), id: Type.Optional(Type.String({ description: "回答问题时的问题编号,例如 Q-001。" })), answer: Type.Optional(Type.String({ description: "用户答案,action=answer 时使用。" })), factLabel: Type.Optional(Type.String({ description: "修订事实时的结构化事实标签。" })), reason: Type.Optional(Type.String({ description: "事实修订原因。" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const run = readActiveRun(ctx.cwd); if (!run) { return { content: [{ type: "text", text: NO_ACTIVE_TASK }], details: { error: "no-active-run" }, }; } const action = (params.action ?? "list").toLowerCase(); if (action === "labels") { return { content: [{ type: "text", text: formatFactLabelCatalog() }], details: { action: "labels" } }; } if (action === "list") { const text = formatQuestions(run); return { content: [{ type: "text", text }], details: { questions: run.questions ?? [] } }; } if (action === "answer") { if (!params.id || !params.answer?.trim()) { return { content: [{ type: "text", text: "kd_answer_user action=answer 必须同时提供 id 和 answer。" }], details: { error: "missing-answer-params" }, }; } const existingQuestion = (run.questions ?? []).find((question) => question.id === params.id); if (existingQuestion?.status === "answered") { return { content: [{ type: "text", text: `${params.id} 已回答,禁止重复覆盖。用户明确更正事实时使用 kd_answer_user action=revise factLabel="<事实标签>" answer="<新值>" reason="<更正原因>"。` }], details: { error: "question-already-answered", question: existingQuestion }, }; } const answered = answerQuestion(ctx.cwd, run, params.id, params.answer); if (!answered) { return { content: [{ type: "text", text: existingQuestion ? `未能记录 ${params.id} 的答案;答案不能为空或占位内容。` : `未找到问题:${params.id}` }], details: { error: existingQuestion ? "invalid-answer" : "question-not-found", id: params.id }, }; } appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]); const auto = advanceRunIfReady(ctx.cwd, run); return { content: [{ type: "text", text: `已记录 ${answered.id} 的答案。${auto.advanced ? auto.message : `还不能继续:${auto.message}`}` }], details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate, autoAdvance: auto }, }; } if (action === "revise") { if (!params.factLabel?.trim() || !params.answer?.trim()) { return { content: [{ type: "text", text: "kd_answer_user action=revise 必须同时提供 factLabel 和 answer。" }], details: { error: "missing-revise-params" }, }; } const fact = reviseFact(ctx.cwd, run, { factLabel: params.factLabel, value: params.answer, reason: params.reason, }); if (!fact) { return { content: [{ type: "text", text: "未能修订结构化事实;factLabel 和 answer 不能为空。" }], details: { error: "invalid-revision" }, }; } appendQuestionEventToArtifact(ctx.cwd, run, [`- 已修订事实 ${fact.label}:${fact.value}`]); const auto = advanceRunIfReady(ctx.cwd, run); return { content: [{ type: "text", text: `已更新 ${fact.label}:${fact.value}。${auto.advanced ? auto.message : `还不能继续:${auto.message}`}` }], details: { fact, gate: readActiveRun(ctx.cwd)?.gate, autoAdvance: auto }, }; } return { content: [{ type: "text", text: `未知 kd_answer_user action:${params.action}。有效值:answer、revise、list、labels。提问使用 kd_ask_user。` }], details: { error: "unknown-action", action: params.action, nextTool: "kd_ask_user" }, }; }, }); const kdAskUserTool = defineTool({ name: "kd_ask_user", label: "KD 提问", description: [ "在需要用户补齐需求、业务事实、数据源或实现决策时阻塞式提问。", "输入 questions 数组;每个问题包含 header、question、options[{label,description}]、factLabel、contextSummary、sourceRefs。", "custom 默认启用,工具会提供自定义输入路径;不要在 options 中添加“其他/自行输入”。", "可一次询问同一事实组内的多个独立问题,但已有 open blocking question 或已确认 factLabel 时会拒绝重复提问。", ].join("\n"), parameters: Type.Object({ questions: Type.Array( Type.Object({ header: Type.String({ description: "极短标题,最多 30 字符。" }), question: Type.String({ description: "完整问题。" }), options: Type.Optional( Type.Array( Type.Object({ label: Type.String({ description: "选项标签,1-5 个词。" }), description: Type.Optional(Type.String({ description: "选项影响或取舍说明。" })), }), ), ), multiple: Type.Optional(Type.Boolean({ description: "是否允许多选。当前 UI 会要求用户用逗号输入多项。" })), custom: Type.Optional(Type.Boolean({ description: "是否允许自定义输入,默认 true。" })), factLabel: Type.Optional(Type.String({ description: "集中定义的结构化事实标签。" })), proposedFactValue: Type.Optional(Type.String({ description: "确认题的候选事实值。" })), reason: Type.Optional(Type.String({ description: "该事实缺失造成的阻塞原因。" })), contextSummary: Type.Optional(Type.String({ description: "提问前工作上下文、已读文件逻辑、当前结论和恢复点。" })), sourceRefs: Type.Optional(Type.Array(Type.String(), { description: "上下文来源文件、证据或命令。" })), blocking: Type.Optional(Type.Boolean({ description: "是否阻塞阶段推进,默认 true。" })), }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const run = readActiveRun(ctx.cwd); if (!run) { return { content: [{ type: "text", text: NO_ACTIVE_TASK }], details: { error: "no-active-run" }, }; } const validation = validateAskUserQuestions(run, params); if (validation.error || !validation.questions) { return { content: [{ type: "text", text: validation.error ?? "kd_ask_user 参数无效。" }], details: { error: validation.code ?? "invalid-questions" }, }; } const registered = validation.questions.map((prompt) => { const question = addQuestion(ctx.cwd, run, { question: prompt.question, reason: prompt.reason, contextSummary: prompt.contextSummary, sourceRefs: prompt.sourceRefs, factLabel: prompt.factLabel, proposedFactValue: prompt.proposedFactValue, options: prompt.options, multiple: prompt.multiple, customAnswer: prompt.custom, blocking: prompt.blocking, }); appendQuestionEventToArtifact(ctx.cwd, run, formatQuestionArtifactLines(question)); return question; }); if (!ctx.hasUI) { return { content: [ { type: "text", text: [ "已记录需要用户确认的问题。当前没有交互窗口,等待用户逐项回答。", "", ...registered.map(formatQuestionCard), "", formatStructuredAnswerInstruction(registered), ].join("\n"), }, ], details: { questions: registered, gate: readActiveRun(ctx.cwd)?.gate, answered: false }, }; } const answers: string[] = []; for (const [index, prompt] of validation.questions.entries()) { const question = registered[index]; const answer = await askUserForPrompt(ctx, prompt); if (!answer?.trim()) { return { content: [{ type: "text", text: `用户未回答 ${question.id}:${question.question}。问题保持 open,禁止继续编码。` }], details: { question, answered: false, error: "dismissed" }, }; } const answered = answerQuestion(ctx.cwd, readActiveRun(ctx.cwd) ?? run, question.id, answer); if (!answered) { return { content: [{ type: "text", text: `未能记录 ${question.id} 的答案;答案为空、占位、口头确认语,或确认题答案不合法。问题保持 open。` }], details: { question, answer, answered: false, error: "invalid-answer" }, }; } answers.push(answered.answer ?? answer); appendQuestionEventToArtifact(ctx.cwd, readActiveRun(ctx.cwd) ?? run, [`- 已回答 ${answered.id}:${answered.answer}`]); } const current = readActiveRun(ctx.cwd) ?? run; const auto = advanceRunIfReady(ctx.cwd, current); return { content: [ { type: "text", text: `${formatAskUserModelOutput(validation.questions, answers)}\n${auto.advanced ? auto.message : `还不能继续:${auto.message}`}`, }, ], details: { questions: registered, answers, gate: readActiveRun(ctx.cwd)?.gate, autoAdvance: auto, answered: true }, }; }, }); const kdGoalTool = defineTool({ name: "kd_goal", label: "KD 自动验证目标", description: "设定自动验证目标。系统自动执行验证命令、分析失败、修复代码、重新验证,直到通过或达到最大轮次(默认 3 轮)。中断后再次调用可从失败处继续。", parameters: Type.Object({ goal: Type.String({ description: "验证目标,如 'npm run check 通过' 或 '所有测试通过'" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const message = await handleGoalCommand(ctx.cwd, params.goal); return { content: [{ type: "text", text: message }], details: { goal: params.goal }, }; }, }); async function askUserForPrompt(ctx: ExtensionContext, prompt: NormalizedAskUserPrompt): Promise { const title = `${prompt.header}\n${prompt.question}`; if (prompt.multiple) { const hint = prompt.options.length > 0 ? `可选:${prompt.options.map((option) => option.label).join(",")}。请输入一个或多个答案,用逗号分隔。` : "请输入一个或多个答案,用逗号分隔。"; return normalizeAnswer(await ctx.ui.input(title, hint)); } if (prompt.options.length === 0) { return normalizeAnswer(await ctx.ui.input(title, "请输入答案")); } const displayOptions = prompt.options.map(formatPromptOption); const customLabel = "自行输入"; const selected = await ctx.ui.select(title, prompt.custom ? [...displayOptions, customLabel] : displayOptions); if (!selected) return undefined; if (selected === customLabel) { return normalizeAnswer(await ctx.ui.input(title, "请输入自定义答案")); } return prompt.options.find((option) => selected === formatPromptOption(option))?.label ?? selected; } function formatPromptOption(option: { label: string; description?: string }): string { return option.description ? `${option.label} - ${option.description}` : option.label; } function normalizeAnswer(answer: string | undefined): string | undefined { const trimmed = answer?.trim(); return trimmed || undefined; }