/** * 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法, * 再在同一条消息前缀上循环 insert。已通过的不再覆盖。 * 没有未通过项时,同一次请求里的 reply_user 才给用户。 * 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。 */ import { randomUUID } from "node:crypto"; import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js"; import type { AgentDriver } from "../runtime/driver.js"; import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js"; import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js"; import { buildDictateContext, type DictateSegmentChars } from "./context.js"; import { CREATION_PLAN_SCHEMA, CREATION_PLAN_TAG, formatCreationPlanCapabilityCatalog, formatCreationPlanProgress, parseCreationPlan, } from "./creation-plan.js"; import { isDictateOpeningTag } from "./experience-anchor.js"; import { buildDictateReplyPrompt, buildDictateSelectPrompt, buildDictateSystemPrompt, formatDictateRoundTail, type DictatePromptTailOpts, } from "./prompt.js"; import { parseDictateModuleReadState, readLibraryEntries, serializeDictateModuleReadState, DICTATE_MODULE_READ_STATE_TAG, type DictateModuleReadPayload, } from "./read-module.js"; import { resolveModuleForInsertTag } from "./insert-feedback.js"; import type { DictateChatTurn, DictateProduct } from "./types.js"; import { isAllowedProductTag } from "./types.js"; import { VALUE_MAP_TAG } from "../skills/value-map.js"; import { VARIABLE_CATALOG_TAG } from "../skills/variable-catalog.js"; const READ_EXEMPT = new Set([ "narrative", "variable-context", "context-order", "worker-spec", "refine", ]); type Apply = (calls: DriverToolCall[]) => DriverStepOutcome | Promise; export type PlannedRoundParams = { llm: LlmProvider; dialogue: DictateChatTurn[]; handlers: { listProducts: () => DictateProduct[]; readTag: (tag: string) => string | undefined; writeTag: (tag: string, content: string) => void; prepareOpening?: () => void; /** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */ lookupInsertFeedback?: (tag: string) => string | undefined; onThinkingDelta?: (delta: string) => void; onThinkingDone?: (text: string) => void; }; moduleCatalog?: import("../skills/creation-flow.js").ModuleCatalog | null; moduleReadIndex?: ReadonlyMap; moduleGuide?: string; recipeExamples?: string; recipeName?: string; recipeBrief?: string; systemPrompt?: string; }; type PlanInsert = { tag: string; moduleId: string; libraryId?: string; entryIds: string[]; reason?: string; }; type SelectPlan = { clarify: string[]; inserts: PlanInsert[]; deletes: string[]; /** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */ keys: string[]; }; type ItemFailure = { tag: string; error: string }; const INSERT_ATTEMPTS = 4; export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver { return { async run(input) { const reply = await runPlannedDictateRound(params, input.handleStep); return { iterations: reply.modelCalls, stop: { kind: "text", content: reply.text } }; }, }; } async function runPlannedDictateRound( params: PlannedRoundParams, apply: Apply, ): Promise<{ text: string; modelCalls: number }> { const turnId = randomUUID(); const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt(); const tailOpts = tailState(params); const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" }); const selectCtx = buildDictateContext({ systemPrompt: buildDictateSelectPrompt(), products: params.handlers.listProducts(), dialogue: params.dialogue, phase: "select", tail: selectTail, }); const selected = await params.llm.completeWithTools(selectCtx.messages, { caller: "dictate_agent", label: "对话落盘 选题", tools: [DICTATE_SELECT_TOOL], toolChoice: { type: "function", function: { name: "select_nodes" } }, usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars), usageSink: sinkFor(turnId, "select"), }); if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim()); const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0]; const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params); if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) { return { modelCalls: 1, text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"), }; } if (plan.inserts.length === 0 && plan.deletes.length === 0) { const fields = plan.keys.length ? plan.keys.join("、") : "空对象"; return { modelCalls: 1, text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`, }; } const blocked = plan.inserts.filter((item) => item.tag.startsWith("!")); const inserts = plan.inserts.filter((item) => !item.tag.startsWith("!")); const openings = inserts.filter((item) => isDictateOpeningTag(item.tag)); const rest = inserts.filter((item) => !isDictateOpeningTag(item.tag)); const blockedLines = blocked.map( (item) => `${item.tag.slice(1)}(${item.reason || "计划未通过程序核验"})`, ); if (openings.length > 0) params.handlers.prepareOpening?.(); const injected = injectGroup(params, [...rest, ...openings]); const tail = formatDictateRoundTail({ ...tailOpts, phase: "fill", checklist: formatChecklist([...rest, ...openings], plan.deletes, []), }); const ctx = buildDictateContext({ systemPrompt: fillSystem, products: params.handlers.listProducts(), dialogue: params.dialogue, phase: "fill", injected: injected.text, tail, }); const messages: ChatMessage[] = [...ctx.messages]; const passed = new Set(); const pending = new Set([...rest, ...openings].map((item) => item.tag)); const pendingDeletes = new Set(plan.deletes); const errors = new Map(); let replyFromTool = ""; let lastProse = ""; let modelCalls = 1; for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) { const step = attempt === 0 ? "fill" : "fill-retry"; const filled = await params.llm.completeWithTools(messages, { caller: "dictate_agent", label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写", tools: DICTATE_WRITE_LOOP_TOOLS, toolChoice: "required", usageDetail: { ...detail(turnId, step, attempt, ctx.segmentChars), plannedTags: [...pending, ...pendingDeletes], }, usageSink: sinkFor(turnId, step), }); modelCalls += 1; if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim()); const prose = filled.content?.trim() ?? ""; if (prose) lastProse = prose; const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({ id: call.id || `call-${attempt}-${index}`, name: call.name, arguments: call.arguments, })); if (calls.length === 0) break; const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors); messages.push(assistantToolMessage(filled.content, calls)); for (const item of settled.results) { messages.push({ role: "tool", content: item.content, tool_call_id: item.callId }); } if (pending.size === 0 && pendingDeletes.size === 0) { replyFromTool = settled.reply; break; } } let reply = replyFromTool; if ( !replyFromTool && pending.size === 0 && pendingDeletes.size === 0 && passed.size > 0 ) { reply = await runLeanReply(params, turnId, passed, plan.deletes, lastProse); modelCalls += 1; } else if (!reply) { reply = lastProse; } const failed = [...pending, ...pendingDeletes].map( (tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`, ); const pendingLines = [...blockedLines, ...failed]; const text = pendingLines.length ? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n") : reply; return { text, modelCalls }; } async function runLeanReply( params: PlannedRoundParams, turnId: string, passed: Set, plannedDeletes: string[], fallbackProse: string, ): Promise { const writtenTags = [...passed].filter((tag) => !tag.startsWith("del:")); const deletedTags = [...passed] .filter((tag) => tag.startsWith("del:")) .map((tag) => tag.slice(4)) .filter(Boolean); const products = params.handlers .listProducts() .filter((product) => writtenTags.some((tag) => product.tag === tag || product.tag.startsWith(`${tag}#`))); const feedbackBlocks = writtenTags .map((tag) => params.handlers.lookupInsertFeedback?.(tag)?.trim()) .filter((text): text is string => Boolean(text)); if (writtenTags.length > 1 && feedbackBlocks.length > 1) { feedbackBlocks.unshift( "本轮写入了多个产物。用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。", ); } const tailOpts = tailState(params); const deleteNote = deletedTags.length || plannedDeletes.length ? `本轮已删除:${(deletedTags.length ? deletedTags : plannedDeletes).join("、")}` : ""; const replyTail = [ formatDictateRoundTail({ ...tailOpts, phase: "reply", checklist: [ writtenTags.length ? `本轮已写入:${writtenTags.join("、")}` : "", deleteNote, ] .filter(Boolean) .join("\n"), }), feedbackBlocks.length ? `## 本轮关注(按能力回复口径)\n\n${feedbackBlocks.join("\n\n")}` : "", ] .filter(Boolean) .join("\n\n"); const replyCtx = buildDictateContext({ systemPrompt: buildDictateReplyPrompt(), products, dialogue: params.dialogue, phase: "reply", tail: replyTail, }); const filled = await params.llm.completeWithTools(replyCtx.messages, { caller: "dictate_agent", label: "对话落盘 回复", tools: [DICTATE_REPLY_TOOL], toolChoice: { type: "function", function: { name: DICTATE_REPLY_TOOL.function.name } }, usageDetail: { ...detail(turnId, "reply", 0, replyCtx.segmentChars), plannedTags: writtenTags, }, usageSink: sinkFor(turnId, "reply"), }); if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim()); const call = filled.toolCalls.find((item) => item.name === DICTATE_REPLY_TOOL.function.name); if (call) { const text = replyText({ id: call.id || "reply", name: call.name, arguments: call.arguments, }); if (text) return text; } return filled.content?.trim() || fallbackProse; } function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage { return { role: "assistant", content, tool_calls: calls.map((call) => ({ id: call.id, type: "function" as const, function: { name: call.name, arguments: call.arguments }, })), }; } async function settleWriteStep( params: PlannedRoundParams, apply: Apply, calls: DriverToolCall[], passed: Set, pending: Set, pendingDeletes: Set, errors: Map, ): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> { const results: Array<{ callId: string; content: string }> = []; const forward: DriverToolCall[] = []; let reply = ""; for (const call of calls) { if (call.name === DICTATE_REPLY_TOOL.function.name) continue; const tag = callTag(call, params); if (call.name === "insert" && tag && passed.has(tag)) { results.push({ callId: call.id, content: JSON.stringify({ error: "已通过,本步不要再写", tag }), }); continue; } if (call.name === "delete" && tag && passed.has(`del:${tag}`)) { results.push({ callId: call.id, content: JSON.stringify({ error: "已通过,本步不要再删除", tag }), }); continue; } if (call.name === "insert" && tag) { const args = asRecord(safeJson(call.arguments)); forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) }); continue; } forward.push(call); } const outcome = await apply(forward); if (outcome.kind === "continue") { for (const result of outcome.results) { results.push(result); const call = forward.find((item) => item.id === result.callId); const payload = asRecord(safeJson(result.content)); const tag = call ? callTag(call, params) : ""; if (payload.ok === true && tag) { if (call?.name === "delete") { passed.add(`del:${tag}`); pendingDeletes.delete(tag); } else { passed.add(tag); for (const planned of [...pending]) { if (planned === tag || tag.startsWith(`${planned}#`)) pending.delete(planned); } } errors.delete(tag); } else if (typeof payload.error === "string" && tag) { const planned = [...pending, ...pendingDeletes].find( (item) => item === tag || tag.startsWith(`${item}#`), ); errors.set(planned ?? tag, payload.error); } } } for (const call of calls) { if (call.name !== DICTATE_REPLY_TOOL.function.name) continue; const text = replyText(call); if (pending.size > 0 || pendingDeletes.size > 0) { const left = [...pending, ...pendingDeletes].join("、"); results.push({ callId: call.id, content: JSON.stringify({ error: `还有未通过:${left}。这份回复先不给用户。只再写入这些。`, }), }); continue; } reply = text; results.push({ callId: call.id, content: JSON.stringify({ ok: true }) }); } return { results, reply }; } function callTag(call: DriverToolCall, params: PlannedRoundParams): string { const args = asRecord(safeJson(call.arguments)); const raw = String(args.position ?? args.tag ?? "").trim(); if (!raw) return ""; return canonicalProductTag(raw, "", params.moduleCatalog)?.tag ?? raw; } function replyText(call: DriverToolCall): string { const args = asRecord(safeJson(call.arguments)); return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim(); } function sinkFor(_turnId: string, _step: string): UsageSink { return {}; } function detail( turnId: string, step: UsageDetail["step"], retryIndex: number, segmentChars: DictateSegmentChars, ): UsageDetail { return { turnId, step, retryIndex, segmentChars }; } function tailState(params: PlannedRoundParams): DictatePromptTailOpts { const products = params.handlers.listProducts(); const plan = parseCreationPlan( products.find((product) => product.tag === CREATION_PLAN_TAG)?.content, ); if (plan) { return { creationPlanState: formatCreationPlanProgress({ plan, catalog: params.moduleCatalog, products, }), }; } return { capabilityCatalog: formatCreationPlanCapabilityCatalog(params.moduleCatalog) || params.moduleGuide, recipeExamples: params.recipeExamples, recipeName: params.recipeName, recipeBrief: params.recipeBrief, moduleGuide: params.moduleGuide, }; } function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan { const row = asRecord(raw); const clarify = stringList(row.clarify); let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag)); const inserts: PlanInsert[] = []; let items = Array.isArray(row.nodes) ? row.nodes : Array.isArray(row.inserts) ? row.inserts : []; if (items.length === 0 && clarify.length < 2 && deletes.length === 0) { const recovered = recoverSelectWork(row); items = recovered.items; deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag)); } for (const item of items) { const rec = asRecord(item); const moduleIdHint = String(rec.module_id ?? rec.moduleId ?? "").trim(); let tag = String(rec.tag ?? rec.position ?? "").trim(); if (!tag && moduleIdHint) { tag = params.moduleCatalog?.modules.find((module) => module.id === moduleIdHint)?.artifact?.trim() ?? ""; } if (!tag) continue; const canonical = canonicalProductTag(tag, moduleIdHint, params.moduleCatalog); if (canonical) tag = canonical.tag; const resolvedHint = canonical?.moduleId || moduleIdHint; const module = params.moduleReadIndex?.get(resolvedHint) ?? params.moduleReadIndex?.get(tag) ?? undefined; const fromTag = resolveModuleForInsertTag(tag, params.moduleCatalog); const moduleId = module?.module_id || canonical?.moduleId || fromTag?.id || moduleIdHint; const entryIds = stringList(rec.entry_ids); const reason = String(rec.reason ?? "").trim(); if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) { inserts.push({ tag: `!${tag || "空"}`, moduleId, entryIds, reason: "产物 tag 不合法" }); continue; } const payload = moduleId ? params.moduleReadIndex?.get(moduleId) : undefined; if (payload && !READ_EXEMPT.has(payload.module_id) && payload.libraries.length) { const libraryId = String(rec.library_id ?? payload.libraries[0]?.library_id ?? "").trim(); if (entryIds.length > 2 && !reason) { inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: "默认最多两个库条目", }); continue; } if (libraryId && entryIds.length) { const bound = payload.libraries.some((library) => library.library_id === libraryId); if (!bound) { inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: `能力未绑定库 ${libraryId}` }); continue; } } } inserts.push({ tag, moduleId, libraryId: String(rec.library_id ?? "").trim() || undefined, entryIds, reason: reason || undefined, }); } return { clarify, inserts, deletes, keys: Object.keys(row) }; } /** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */ function recoverSelectWork(row: Record): { items: unknown[]; deletes: string[]; } { if (isCreationPlanShape(row)) { return { items: [{ tag: CREATION_PLAN_TAG, module_id: "creation-plan" }], deletes: [] }; } const items: unknown[] = []; const deletes: string[] = []; for (const list of [row.modules, row.selections, row.selected, row.writes, row.products, row.operations]) { for (const item of asItemList(list)) { const rec = flattenItem(item); if ( typeof rec.tier === "string" && typeof rec.why === "string" && rec.tag == null && rec.position == null ) { continue; } const op = operationName(rec); if (op === "read_module" || op === "read_library_entry" || op === "prepare_opening") { continue; } const moduleId = String(rec.module_id ?? rec.moduleId ?? "").trim(); const tag = String(rec.tag ?? rec.position ?? rec.artifact ?? "").trim(); if (op === "delete" || op === "remove") { if (tag) deletes.push(tag); continue; } if ( op === "declare_variable" || op === "undeclare_variable" || op === "declare_map" || op === "remove_map" ) { continue; } if (!tag && !moduleId) continue; items.push({ tag, module_id: moduleId, ...(rec.library_id != null ? { library_id: rec.library_id } : {}), ...(rec.entry_ids != null ? { entry_ids: rec.entry_ids } : {}), ...(rec.reason != null ? { reason: rec.reason } : {}), }); } } return { items, deletes }; } function isCreationPlanShape(row: Record): boolean { if (row.schema === CREATION_PLAN_SCHEMA) return true; if (!Array.isArray(row.items) || row.items.length === 0) return false; return row.items.every((item) => { const rec = asRecord(item); return ( typeof rec.module_id === "string" && rec.module_id.trim().length > 0 && typeof rec.tier === "string" && rec.tag == null && rec.position == null ); }); } function injectGroup( params: PlannedRoundParams, group: PlanInsert[], ): { text: string } { const blocks: string[] = []; const state = parseDictateModuleReadState( params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG), ); let changed = false; for (const item of group) { const payload = item.moduleId ? params.moduleReadIndex?.get(item.moduleId) : undefined; if (!payload) continue; const catalogEntry = resolveModuleForInsertTag(item.tag, params.moduleCatalog); if (!READ_EXEMPT.has(payload.module_id) && !catalogEntry?.auto) { state.modules[payload.module_id] = payload.version; state.active_module_id = payload.module_id; changed = true; } const parts = [ `### ${payload.module_id} @ ${payload.version}`, `产物 tag:${item.tag}`, payload.score_dimensions.length ? `self_score 维度(这次写入必填,name 必须一致):${payload.score_dimensions.join("、")}` : "", `#### task\n${payload.task}`, `#### principles\n${payload.principles}`, `#### output\n${payload.output}`, `#### score\n${payload.score}`, ]; if (item.libraryId && item.entryIds.length) { const loaded = readLibraryEntries({ module: payload, libraryId: item.libraryId, entryIds: item.entryIds, reason: item.reason, }); if (loaded.entries) { for (const entry of loaded.entries) { const row = entry as { id?: string; content?: string; name?: string }; parts.push(`#### 库 ${item.libraryId} / ${row.id ?? ""}\n${row.content ?? ""}`); } } else if (loaded.error) { parts.push(`#### 库读取失败\n${loaded.error}`); } } blocks.push(parts.filter(Boolean).join("\n\n")); } if (changed) { params.handlers.writeTag(DICTATE_MODULE_READ_STATE_TAG, serializeDictateModuleReadState(state)); } return { text: blocks.join("\n\n") }; } function canonicalProductTag( tag: string, moduleIdHint: string, catalog: PlannedRoundParams["moduleCatalog"], ): { tag: string; moduleId: string } | null { const bare = tag.replace(/^(?:用户|设计)\./, "").split("#")[0]?.trim() ?? ""; const entry = catalog?.modules.find((module) => module.id === moduleIdHint) ?? resolveModuleForInsertTag(tag, catalog) ?? catalog?.modules.find( (module) => module.name === bare || module.id === bare || module.artifact === tag || module.artifact.endsWith(`.${bare}`), ); if (!entry?.artifact?.trim()) return null; const hash = tag.indexOf("#"); const suffix = hash > 0 ? tag.slice(hash) : ""; const next = entry.repeatable && suffix.startsWith("#") && suffix.length > 1 ? `${entry.artifact.trim()}${suffix}` : entry.artifact.trim(); return { tag: next, moduleId: entry.id }; } function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string { const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean); const lines = [ nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "", group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "", deletes.length ? `本轮删除:${deletes.join("、")}` : "", errors.length ? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}` : "", ]; return lines.filter(Boolean).join("\n"); } function asItemList(value: unknown): unknown[] { if (Array.isArray(value)) return value; const rec = asRecord(value); const values = Object.values(rec); if (values.length > 0 && values.every((item) => item && typeof item === "object")) { return values; } return []; } function flattenItem(item: unknown): Record { if (typeof item === "string") { const text = item.trim(); if (!text) return {}; if (text.startsWith("设计.") || text.startsWith("用户.")) return { tag: text }; return { module_id: text }; } const rec = asRecord(item); const nested = asRecord(rec.arguments ?? rec.args ?? rec.payload); return { ...rec, ...nested }; } function operationName(rec: Record): string { return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim(); } function asRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return value as Record; } function stringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((item) => String(item ?? "").trim()).filter(Boolean); } function safeJson(raw: string): unknown { try { return JSON.parse(raw); } catch { return {}; } }