From b85d3c2f50973e302c2de64bb2011c3a17332dff Mon Sep 17 00:00:00 2001 From: hy Date: Fri, 20 Mar 2026 18:59:21 +0800 Subject: [PATCH] Refactor AI structured output and tool family loading --- AGENTS.md | 2 +- doc/ai-design/prompt-design.md | 9 +- doc/ai-design/refactor-plan.md | 4 +- .../human/node/brainNode/decisionContract.ts | 288 ++++++------------ .../services/ai/human/node/brainNode/index.ts | 7 +- .../ai/human/node/brainNode/memoryReducer.ts | 43 ++- .../ai/human/node/brainNode/promptContext.ts | 87 ++++-- .../human/node/handNode/toolExecutionMeta.ts | 2 +- .../ai/human/node/handNode/toolExecutor.ts | 2 +- .../ai/human/node/learningNode/skillLoader.ts | 76 ++--- .../human/node/learningNode/skillRegistry.ts | 32 +- .../ai/human/node/mouthNode/contract.ts | 8 + .../services/ai/human/node/mouthNode/index.ts | 57 +--- .../ai/human/node/mouthNode/promptContext.ts | 2 +- .../ai/human/node/utils/runtimeCache.ts | 3 +- src/main/services/ai/human/prompts/brain.ts | 129 ++++++++ src/main/services/ai/human/prompts/mouth.ts | 33 +- src/main/services/ai/human/state/schema.ts | 2 + .../ai/human/tools/docx-delivery/index.ts | 8 - .../ai/human/tools/docx-read/index.ts | 12 - .../ai/human/tools/docx-write/index.ts | 6 - .../services/ai/human/tools/docx/docxTools.ts | 2 +- .../services/ai/human/tools/docx/index.ts | 8 +- .../tools/kb-document-discovery/index.ts | 8 - .../ai/human/tools/kb-evidence-query/index.ts | 14 - src/main/services/ai/human/tools/kb/index.ts | 7 + .../retrievalTools.ts | 0 .../human/tools/local_kb_retrieval/index.ts | 7 - .../{terminal_exec => terminal}/index.ts | 6 +- .../terminalExec.ts | 0 .../terminalPolicy.ts | 0 .../terminalTools.ts | 0 .../{terminal_exec => terminal}/types.ts | 0 src/main/services/ai/human/tools/types.ts | 27 +- .../ai/human/tools/workspace-cli/index.ts | 4 - src/main/services/ai/human/types.ts | 5 +- src/main/services/ai/main/node/intentNode.ts | 15 +- src/main/services/ai/main/node/planNode.ts | 25 +- src/main/services/ai/memory/index.ts | 54 ++-- src/main/services/ai/runtime.ts | 42 +-- src/main/services/ai/structuredOutput.ts | 153 ++++++++++ src/main/services/ai/types.ts | 7 +- .../memory-reducer-order-insensitive.test.ts | 105 +++++++ .../basic/prompt-context-alignment.test.ts | 125 ++++++++ .../main/basic/main-plan-created.test.ts | 2 +- .../basic/structured-output-fallback.test.ts | 113 +++++++ tests/harness/backend/aiToolHarness.ts | 4 +- tests/harness/backend/contextFactory.ts | 45 +-- tests/harness/backend/test-types.ts | 7 +- 49 files changed, 1037 insertions(+), 560 deletions(-) create mode 100644 src/main/services/ai/human/node/mouthNode/contract.ts delete mode 100644 src/main/services/ai/human/tools/docx-delivery/index.ts delete mode 100644 src/main/services/ai/human/tools/docx-read/index.ts delete mode 100644 src/main/services/ai/human/tools/docx-write/index.ts delete mode 100644 src/main/services/ai/human/tools/kb-document-discovery/index.ts delete mode 100644 src/main/services/ai/human/tools/kb-evidence-query/index.ts create mode 100644 src/main/services/ai/human/tools/kb/index.ts rename src/main/services/ai/human/tools/{local_kb_retrieval => kb}/retrievalTools.ts (100%) delete mode 100644 src/main/services/ai/human/tools/local_kb_retrieval/index.ts rename src/main/services/ai/human/tools/{terminal_exec => terminal}/index.ts (93%) rename src/main/services/ai/human/tools/{terminal_exec => terminal}/terminalExec.ts (100%) rename src/main/services/ai/human/tools/{terminal_exec => terminal}/terminalPolicy.ts (100%) rename src/main/services/ai/human/tools/{terminal_exec => terminal}/terminalTools.ts (100%) rename src/main/services/ai/human/tools/{terminal_exec => terminal}/types.ts (100%) delete mode 100644 src/main/services/ai/human/tools/workspace-cli/index.ts create mode 100644 src/main/services/ai/structuredOutput.ts create mode 100644 tests/ai/backend/human/basic/memory-reducer-order-insensitive.test.ts create mode 100644 tests/ai/backend/human/basic/prompt-context-alignment.test.ts create mode 100644 tests/ai/backend/runtime/basic/structured-output-fallback.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7579ed59..d6c21688 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - 当涉及 `main`/`human` 状态字段、数据契约、Main/Human 流转关系或流转图时,请阅读 `doc/ai-design/data-definition-and-flow.md` - 当涉及 AI 新架构重构范围、阶段划分、目录重构方向或后续补充计划时,请阅读 `doc/ai-design/refactor-plan.md` - 当涉及 `human` 的 skill 集合、skill 命名、skill 拆分边界、`skillCatalog` / `skillRegistry` 或 skill 与 tools 实现边界时,请阅读 `doc/ai-design/human-skill-design.md` -- 当涉及 AI 提示词设计准则、prompt 结构、标签组织、结构化原始输入、structured output 或提示词编排原则时,请阅读 `doc/ai-design/prompt-design.md` +- 当涉及 AI 提示词设计准则、prompt 结构、标签组织、哪些字段允许保留结构化载荷、structured output 或提示词编排原则时,请阅读 `doc/ai-design/prompt-design.md` - 当涉及 AI workbench 测试方案、阶段划分、阶段目标、各阶段 `basic / boundary / scenario / live` 用例规划、`watchHint`/并行边界、模型使用或 backend / e2e 测试边界时,请先阅读 `doc/ai-workbench-test/plan.md`; - 当涉及 AI workbench 当前阶段推进、完成度、证据、覆盖缺口、四节点 `human` 测试契约迁移或下一阶段准入时,请阅读 `doc/ai-workbench-test/` 下当前阶段 todo 文档;如果阶段 todo 尚未重新建立,则以 `doc/ai-workbench-test/plan.md` 为准 - 当涉及数据库、LanceDB、表职责、存储分层或检索存储实现时,请阅读 `doc/database.md` diff --git a/doc/ai-design/prompt-design.md b/doc/ai-design/prompt-design.md index 558255eb..685f6561 100644 --- a/doc/ai-design/prompt-design.md +++ b/doc/ai-design/prompt-design.md @@ -89,8 +89,8 @@ - 输入部分优先使用自然语言名称,而不是直接暴露字段名。 - 每块输入都应让模型知道“这是什么”和“它为什么重要”。 - 不同类型的信息要拆开,不要不加筛选地把所有内容混成一大段 JSON。 -- 对强结构化信息,可以按需保留精简后的原始对象、数组或 JSON 片段,只要这样能让模型更稳定地理解输入。 -- 是否使用自然语言摘要还是结构化原始对象,应以“哪种形式更利于当前模型理解和稳定输出”为准,而不是机械二选一。 +- 默认先把输入组织成稳定、可读的文本块,而不是直接塞原始对象或 JSON。 +- 只有专项 prompt 文档明确允许的字段,才可以保留结构化载荷;不要自行扩大这类例外。 推荐命名: @@ -108,7 +108,6 @@ - `state.turn.incoming` 字段名可以在输出规则部分单独解释,但不宜主导输入叙述。 -如果某段输入本身就是结构化数据本体,例如记忆快照、最近一次工具结果、动作参数数组,那么可以保留该结构化数据的精简原貌,再配一个清晰的自然语言标签。 如果需要让模型回看上一轮决策,优先提供一个精简视图,只保留: @@ -118,7 +117,7 @@ - 必要时附带 createdAt 或相对轮次,帮助模型理解顺序 不要把整个原始状态对象或无关字段原样回灌给模型。 -但如果最近决策历史、最近工具结果或记忆快照本身高度依赖结构细节,可以按需提供保留关键字段的原始对象或 JSON 视图。 +如果专项 prompt 示例明确要求某个字段保留结构化载荷,例如最近决策历史里的 `nextAction` 数据,则只保留这个局部例外,不要把同样做法扩散到其他输入块。 ## 7. 输出规则准则 @@ -180,7 +179,7 @@ - 决策型 prompt - 核心是判断下一步做什么。 - 如果需要回看上一轮决策,应优先提供“上一轮判断 + 上一轮动作”的精简视图。 - - 对记忆快照、工具结果、动作参数这类强结构化输入,可按需保留精简原始对象。 + - 是否允许保留结构化载荷,应以专项 prompt 文档中的明确示例为准,不要默认保留原始对象。 - 交付型 prompt - 核心是把已有结论稳定表达出来。 - 路由型 prompt diff --git a/doc/ai-design/refactor-plan.md b/doc/ai-design/refactor-plan.md index 4f5467f7..41c9a10c 100644 --- a/doc/ai-design/refactor-plan.md +++ b/doc/ai-design/refactor-plan.md @@ -5,10 +5,10 @@ 以下内容是当前重构在进入“已完成”前,仍需补充完善和对齐的部分: - `brain` prompt 需要继续补齐,实际实现应继续向本文件第 6 章的专项 prompt 设计靠拢,而不是只停留在简化版骨架。 -- `buildBrainPromptContext` 需要按需组织输入:保持“易理解信息组织”为主,但对记忆快照、最近工具结果、动作参数等强结构化输入,允许保留精简后的原始对象或 JSON 视图,需要严格参照refactor-plan的提示词。 +- `buildBrainPromptContext` 需要严格按本文件第 6.2 节的提示词模板组织输入:记忆快照、最近工具结果、skill 说明等统一转成稳定文本;只有最近决策历史中的 `nextAction数据` 可以按示例保留结构化载荷。 - `mouth` 不需要读取最近决策历史;`brain` 需要读取最近决策历史摘要,这个边界必须在实现和文档中保持一致。 - `tools` 目录需要进一步从“按 skill 建目录”收敛为“按实现域归位”,skill 只保留在 `skillCatalog` / `skillRegistry` / `skillLoader` 的映射与装配层。 -- 运行时结构化输出能力需要直接重构为 schema 驱动的 structured output 封装,不再兼容旧 `chatJson + JSON.parse` 路径;LangChain 侧统一使用 `withStructuredOutput`,并优先采用可稳定工作的 `jsonSchema` ,可以封装一下该方法,使得更好用方案。 +- 运行时结构化输出能力需要直接重构为 schema 驱动的 structured output 封装,不再兼容旧 `chatJson + JSON.parse` 路径;LangChain 侧统一使用 `withStructuredOutput`,默认优先 `jsonSchema`,并按 `jsonSchema -> functionCalling -> jsonMode` 的固定顺序自动回退。 - `memoryReducer` 需要修复 `memoryOps` 的顺序敏感问题:同一轮 `memoryOps` 中,`facts[].evidenceIds` 对“本轮新增 evidence”的引用不应因为数组顺序不同而被误判为非法。 ## 1. 目标 diff --git a/src/main/services/ai/human/node/brainNode/decisionContract.ts b/src/main/services/ai/human/node/brainNode/decisionContract.ts index 97f27259..34b28ae3 100644 --- a/src/main/services/ai/human/node/brainNode/decisionContract.ts +++ b/src/main/services/ai/human/node/brainNode/decisionContract.ts @@ -1,207 +1,115 @@ -import { isJsonObject } from '../../../../../utils/aiCapabilityUtils' +import { z } from 'zod/v4' import type { JsonObject, JsonValue } from '../../../../../../shared/types' -import { hasSkillDefinition } from '../learningNode/skillRegistry' +import { RUNTIME_SKILL_IDS } from '../../constants' import type { BrainDecision, + BrainEvidence, + BrainFact, BrainMemoryOp, + BrainNextAction, LastBrainDecision, - RuntimeSkillId, ToolCallParam } from '../../types' -const toString = (value: JsonValue | undefined, fallback: string): string => { - return typeof value === 'string' && value.trim() ? value.trim() : fallback -} +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema) + ]) +) -const toJsonObject = (value: JsonValue | undefined): JsonObject => { - return isJsonObject(value) ? value : {} -} +const jsonObjectSchema: z.ZodType = z.record(z.string(), jsonValueSchema) -const parseToolCallParam = (value: JsonValue | undefined): ToolCallParam | undefined => { - if (!isJsonObject(value)) return undefined - const toolName = toString(value.toolName, '') - if (!toolName) return undefined - return { - toolName, - args: toJsonObject(value.args) - } -} +const brainFactSchema: z.ZodType = z.object({ + id: z.string().min(1), + summary: z.string().min(1), + evidenceIds: z.array(z.string().min(1)) +}) -const parseMemoryOp = (value: JsonValue | undefined): BrainMemoryOp | undefined => { - if (!isJsonObject(value)) return undefined - const op = toString(value.op, '') - const field = toString(value.field, '') - const index = typeof value.index === 'number' ? value.index : undefined +const brainEvidenceSchema: z.ZodType = z.object({ + id: z.string().min(1), + toolName: z.string().min(1), + summary: z.string().min(1), + sourceType: z.literal('tool_result'), + rawRef: z.string().min(1).optional() +}) - if (op === 'add' && field === 'facts' && isJsonObject(value.value)) { - return { - op: 'add', - field: 'facts', - value: { - id: toString(value.value.id, 'fact'), - summary: toString(value.value.summary, ''), - evidenceIds: Array.isArray(value.value.evidenceIds) - ? value.value.evidenceIds.map((item) => String(item)).filter(Boolean) - : [] - } - } - } +const toolCallParamSchema: z.ZodType = z.object({ + toolName: z.string().min(1), + args: jsonObjectSchema +}) - if (op === 'edit' && field === 'facts' && index !== undefined && isJsonObject(value.value)) { - return { - op: 'edit', - field: 'facts', - index, - value: { - id: toString(value.value.id, 'fact'), - summary: toString(value.value.summary, ''), - evidenceIds: Array.isArray(value.value.evidenceIds) - ? value.value.evidenceIds.map((item) => String(item)).filter(Boolean) - : [] - } - } - } +const brainMemoryOpSchema: z.ZodType = z.union([ + z.object({ + op: z.literal('add'), + field: z.literal('facts'), + value: brainFactSchema + }), + z.object({ + op: z.literal('edit'), + field: z.literal('facts'), + index: z.number().int().min(0), + value: brainFactSchema + }), + z.object({ + op: z.literal('add'), + field: z.literal('evidences'), + value: brainEvidenceSchema + }), + z.object({ + op: z.literal('edit'), + field: z.literal('evidences'), + index: z.number().int().min(0), + value: brainEvidenceSchema + }), + z.object({ + op: z.literal('add'), + field: z.literal('openQuestions'), + value: z.string().min(1) + }), + z.object({ + op: z.literal('edit'), + field: z.literal('openQuestions'), + index: z.number().int().min(0), + value: z.string().min(1) + }), + z.object({ + op: z.literal('remove'), + field: z.enum(['facts', 'evidences', 'openQuestions']), + index: z.number().int().min(0) + }) +]) - if (op === 'add' && field === 'evidences' && isJsonObject(value.value)) { - return { - op: 'add', - field: 'evidences', - value: { - id: toString(value.value.id, 'evidence'), - toolName: toString(value.value.toolName, 'tool'), - summary: toString(value.value.summary, ''), - sourceType: 'tool_result', - rawRef: typeof value.value.rawRef === 'string' ? value.value.rawRef : undefined - } - } - } +const brainDecisionSchema: z.ZodType = z.object({ + summary: z.string().min(1), + reason: z.string().min(1), + expectedOutcome: z.string().min(1) +}) - if (op === 'edit' && field === 'evidences' && index !== undefined && isJsonObject(value.value)) { - return { - op: 'edit', - field: 'evidences', - index, - value: { - id: toString(value.value.id, 'evidence'), - toolName: toString(value.value.toolName, 'tool'), - summary: toString(value.value.summary, ''), - sourceType: 'tool_result', - rawRef: typeof value.value.rawRef === 'string' ? value.value.rawRef : undefined - } - } - } +const brainNextActionSchema: z.ZodType = z.union([ + z.object({ + action: z.literal('learning'), + wantLearnSkillIds: z.array(z.enum(RUNTIME_SKILL_IDS)).min(1) + }), + z.object({ + action: z.literal('hand'), + parallelCallToolParam: z.array(toolCallParamSchema).min(1) + }), + z.object({ + action: z.literal('mouth'), + status: z.enum(['completed', 'blocked']) + }) +]) - if (op === 'add' && field === 'openQuestions') { - return { - op: 'add', - field: 'openQuestions', - value: toString(value.value, '') - } - } +export const brainDecisionOutputSchema: z.ZodType = z.object({ + memoryOps: z.array(brainMemoryOpSchema), + decision: brainDecisionSchema, + nextAction: brainNextActionSchema +}) - if (op === 'edit' && field === 'openQuestions' && index !== undefined) { - return { - op: 'edit', - field: 'openQuestions', - index, - value: toString(value.value, '') - } - } - - if ( - op === 'remove' && - index !== undefined && - (field === 'facts' || field === 'evidences' || field === 'openQuestions') - ) { - return { - op: 'remove', - field, - index - } - } - - return undefined -} - -const parseDecision = (value: JsonValue | undefined): BrainDecision => { - if (!isJsonObject(value)) { - return { - summary: '当前信息不足,先结束。', - reason: '模型未返回合法 decision,默认阻塞收敛。', - expectedOutcome: '输出保守的阻塞结果。' - } - } - - return { - summary: toString(value.summary, '继续推进任务'), - reason: toString(value.reason, '基于当前上下文做出保守决策'), - expectedOutcome: toString(value.expectedOutcome, '让任务继续收敛') - } -} - -export const parseBrainDecisionOutput = (rawOutput: JsonObject | object): LastBrainDecision => { - const raw = isJsonObject(rawOutput as JsonValue) ? (rawOutput as JsonObject) : {} - const memoryOps = Array.isArray(raw.memoryOps) - ? raw.memoryOps - .map((item) => parseMemoryOp(item)) - .filter((item): item is BrainMemoryOp => Boolean(item)) - : [] - const decision = parseDecision(raw.decision) - const nextAction = isJsonObject(raw.nextAction) ? raw.nextAction : {} - const action = toString(nextAction.action, 'mouth') - - if (action === 'learning') { - const wantLearnSkillIds = Array.isArray(nextAction.wantLearnSkillIds) - ? nextAction.wantLearnSkillIds - .map((item) => String(item)) - .filter(hasSkillDefinition) as RuntimeSkillId[] - : [] - - return { - memoryOps, - decision, - nextAction: - wantLearnSkillIds.length > 0 - ? { - action: 'learning', - wantLearnSkillIds - } - : { - action: 'mouth', - status: 'blocked' - } - } - } - - if (action === 'hand') { - const parallelCallToolParam = Array.isArray(nextAction.parallelCallToolParam) - ? nextAction.parallelCallToolParam - .map((item) => parseToolCallParam(item)) - .filter((item): item is ToolCallParam => Boolean(item)) - : [] - - return { - memoryOps, - decision, - nextAction: - parallelCallToolParam.length > 0 - ? { - action: 'hand', - parallelCallToolParam - } - : { - action: 'mouth', - status: 'blocked' - } - } - } - - return { - memoryOps, - decision, - nextAction: { - action: 'mouth', - status: nextAction.status === 'completed' ? 'completed' : 'blocked' - } - } -} +export const parseBrainDecisionOutput = (rawOutput: LastBrainDecision): LastBrainDecision => + brainDecisionOutputSchema.parse(rawOutput) diff --git a/src/main/services/ai/human/node/brainNode/index.ts b/src/main/services/ai/human/node/brainNode/index.ts index 813cac1d..b87a376c 100644 --- a/src/main/services/ai/human/node/brainNode/index.ts +++ b/src/main/services/ai/human/node/brainNode/index.ts @@ -4,7 +4,7 @@ import type { HumanRuntimeCache, HumanState } from '../../types' import { appendHumanTraceStep, emitHumanStep, emitWatchHint } from '../utils/events' import { getAvailableToolNames } from '../learningNode/skillLoader' import { buildBrainPromptContext } from './promptContext' -import { parseBrainDecisionOutput } from './decisionContract' +import { brainDecisionOutputSchema, parseBrainDecisionOutput } from './decisionContract' import { reduceBrainMemory } from './memoryReducer' import type { AiRunContextWithBrainOverride } from './types' @@ -17,8 +17,11 @@ export const createBrainNode = (input: { ctx: AiRunContext; cache: HumanRuntimeC const overriddenDecision = ctxWithOverride.resolveBrainDecisionOverride?.() const rawOutput = overriddenDecision ? overriddenDecision - : await input.ctx.chatJson({ + : await input.ctx.chatStructured({ + caller: 'human.brain', ...buildBrainPromptContext(state), + schema: brainDecisionOutputSchema, + schemaName: 'human_brain_decision', temperature: 0.1 }) const parsedDecision = parseBrainDecisionOutput(rawOutput) diff --git a/src/main/services/ai/human/node/brainNode/memoryReducer.ts b/src/main/services/ai/human/node/brainNode/memoryReducer.ts index 47032c57..3e03889c 100644 --- a/src/main/services/ai/human/node/brainNode/memoryReducer.ts +++ b/src/main/services/ai/human/node/brainNode/memoryReducer.ts @@ -4,11 +4,10 @@ const INVALID_MEMORY_OP_HINT = 'memoryReducer 检测到非法记忆编辑,已忽略对应操作,请重新判断下一步。' const hasEvidenceIds = ( - evidences: HumanState['brainMemory']['inside']['evidences'], + evidenceIdsInScope: Set, evidenceIds: string[] ): boolean => { - const evidenceSet = new Set(evidences.map((item) => item.id)) - return evidenceIds.every((evidenceId) => evidenceSet.has(evidenceId)) + return evidenceIds.every((evidenceId) => evidenceIdsInScope.has(evidenceId)) } const updateAtIndex = (list: T[], index: number, value: T): T[] => { @@ -23,9 +22,10 @@ const removeAtIndex = (list: T[], index: number): T[] => { const applyFactOp = ( inside: HumanState['brainMemory']['inside'], - op: Extract + op: Extract, + validEvidenceIds: Set ) => { - if (!hasEvidenceIds(inside.evidences, op.value.evidenceIds)) { + if (!hasEvidenceIds(validEvidenceIds, op.value.evidenceIds)) { return { inside, invalid: true } } @@ -79,6 +79,36 @@ const applyEvidenceOp = ( } } +const computeFinalEvidenceIds = (input: { + inside: HumanState['brainMemory']['inside'] + memoryOps: BrainMemoryOp[] +}): Set => { + let evidences = input.inside.evidences + + input.memoryOps.forEach((op) => { + if (op.field !== 'evidences') return + + if (op.op === 'remove') { + if (op.index < 0 || op.index >= evidences.length) return + evidences = removeAtIndex(evidences, op.index) + return + } + + const outcome = applyEvidenceOp( + { + ...input.inside, + evidences + }, + op + ) + if (!outcome.invalid) { + evidences = outcome.inside.evidences + } + }) + + return new Set(evidences.map((item) => item.id)) +} + const applyOpenQuestionOp = ( inside: HumanState['brainMemory']['inside'], op: Extract @@ -115,6 +145,7 @@ export const reduceBrainMemory = (input: { } => { let nextInside = input.inside let hasInvalidOp = false + const finalEvidenceIds = computeFinalEvidenceIds(input) input.memoryOps.forEach((op) => { if (op.op === 'remove') { @@ -153,7 +184,7 @@ export const reduceBrainMemory = (input: { const outcome = op.field === 'facts' - ? applyFactOp(nextInside, op) + ? applyFactOp(nextInside, op, finalEvidenceIds) : op.field === 'evidences' ? applyEvidenceOp(nextInside, op) : applyOpenQuestionOp(nextInside, op) diff --git a/src/main/services/ai/human/node/brainNode/promptContext.ts b/src/main/services/ai/human/node/brainNode/promptContext.ts index ae3e795f..9f51a1b5 100644 --- a/src/main/services/ai/human/node/brainNode/promptContext.ts +++ b/src/main/services/ai/human/node/brainNode/promptContext.ts @@ -3,35 +3,62 @@ import { getToolPromptSpec } from '../../prompts/toolPrompts' import { getSkillDefinition } from '../learningNode/skillRegistry' import type { HumanState } from '../../types' -const toJsonText = (value: object | undefined, fallback = 'none'): string => { - if (!value) return fallback - try { - return JSON.stringify(value, null, 2) - } catch { - return fallback - } +const formatList = (items: string[]): string => (items.length > 0 ? items.join('\n') : 'none') + +const buildMemorySnapshotText = (state: HumanState): string => { + const factsText = + state.brainMemory.inside.facts.length > 0 + ? state.brainMemory.inside.facts + .map( + (fact) => + `- fact(${fact.id}):${fact.summary};evidenceIds=[${fact.evidenceIds.join(', ')}]` + ) + .join('\n') + : 'none' + const evidencesText = + state.brainMemory.inside.evidences.length > 0 + ? state.brainMemory.inside.evidences + .map( + (evidence) => + `- evidence(${evidence.id}):toolName=${evidence.toolName};summary=${evidence.summary}${evidence.rawRef ? `;rawRef=${evidence.rawRef}` : ''}` + ) + .join('\n') + : 'none' + const openQuestionsText = + state.brainMemory.inside.openQuestions.length > 0 + ? state.brainMemory.inside.openQuestions.map((question) => `- ${question}`).join('\n') + : 'none' + + return [ + 'facts:', + factsText, + '', + 'evidences:', + evidencesText, + '', + 'openQuestions:', + openQuestionsText + ].join('\n') } const buildSkillContextText = (state: HumanState): string => { - const catalogText = + const catalogLines = state.brainMemory.outside.skillCatalog.length > 0 ? state.brainMemory.outside.skillCatalog .map( (item) => `- ${item.skillId}: ${item.summary}; tools=[${item.toolNames.join(', ')}]; whenToUse=${item.whenToUse}` ) - .join('\n') - : 'none' - const learnedDetailText = + : ['none'] + const learnedDetailLines = state.brainMemory.outside.learnedSkillIds.length > 0 ? state.brainMemory.outside.learnedSkillIds .map((skillId) => { const definition = getSkillDefinition(skillId) return `- ${definition.skillId}: ${definition.detail}` }) - .join('\n') - : 'none' - const learnedToolSpecText = + : ['none'] + const learnedToolSpecLines = state.brainMemory.outside.learnedSkillIds.length > 0 ? state.brainMemory.outside.learnedSkillIds .flatMap((skillId) => getSkillDefinition(skillId).toolNames) @@ -43,20 +70,34 @@ const buildSkillContextText = (state: HumanState): string => { .map((arg) => `${arg.name}:${arg.type}`) .join(', ') const example = spec.examples?.[0] - ? `example=${JSON.stringify(spec.examples[0].args)}` + ? `exampleGoal=${spec.examples[0].goal}; exampleArgs=${Object.keys(spec.examples[0].args).join(', ') || 'none'}` : 'example=none' return `- ${spec.name}: whenToUse=${spec.whenToUse}; requiredArgs=[${requiredArgs || 'none'}]; ${example}` }) - .join('\n') - : 'none' + : ['none'] return [ - `\n${catalogText}\n`, - `\n${learnedDetailText}\n`, - `\n${learnedToolSpecText}\n` + 'catalog:', + formatList(catalogLines), + '', + 'learned skills:', + formatList(learnedDetailLines), + '', + 'tool specs:', + formatList(learnedToolSpecLines) ].join('\n') } +const buildRecentToolResultsText = (state: HumanState): string => { + if (!state.lastParallelCallToolResults || state.lastParallelCallToolResults.length === 0) return 'none' + return state.lastParallelCallToolResults + .map( + (result, index) => + `- 第${index + 1}项:toolName=${result.toolName};ok=${result.ok ? 'true' : 'false'};code=${result.code};summary=${result.summary}` + ) + .join('\n') +} + const buildRecentDecisionHistoryText = (state: HumanState): string => { if (state.brainMemory.outside.brainDecisions.length === 0) return 'none' return state.brainMemory.outside.brainDecisions @@ -65,7 +106,7 @@ const buildRecentDecisionHistoryText = (state: HumanState): string => { const action = record.nextAction.action const nextActionPayload = action === 'learning' - ? `wantLearnSkillIds=${record.nextAction.wantLearnSkillIds.join(',')}` + ? `wantLearnSkillIds=[${record.nextAction.wantLearnSkillIds.join(',')}]` : action === 'hand' ? `parallelCallToolParam=${JSON.stringify(record.nextAction.parallelCallToolParam)}` : `status=${record.nextAction.status}` @@ -78,9 +119,9 @@ const buildRecentDecisionHistoryText = (state: HumanState): string => { export const buildBrainPromptContext = (state: HumanState) => buildHumanBrainPrompt({ userInput: state.task.userInput, - memorySnapshotText: toJsonText(state.brainMemory.inside), + memorySnapshotText: buildMemorySnapshotText(state), skillContextText: buildSkillContextText(state), - recentToolResultsText: toJsonText(state.lastParallelCallToolResults, 'none'), + recentToolResultsText: buildRecentToolResultsText(state), recentDecisionHistoryText: buildRecentDecisionHistoryText(state), watchHintText: state.watchHint || 'none' }) diff --git a/src/main/services/ai/human/node/handNode/toolExecutionMeta.ts b/src/main/services/ai/human/node/handNode/toolExecutionMeta.ts index 8849b019..6c6ac44b 100644 --- a/src/main/services/ai/human/node/handNode/toolExecutionMeta.ts +++ b/src/main/services/ai/human/node/handNode/toolExecutionMeta.ts @@ -2,7 +2,7 @@ import { classifyCommandMode, classifyCommandRisk, collectTerminalArtifactCandidates -} from '../../tools/terminal_exec/terminalPolicy' +} from '../../tools/terminal/terminalPolicy' import type { JsonObject } from '../../../../../../shared/types' type ToolExecutionMeta = { diff --git a/src/main/services/ai/human/node/handNode/toolExecutor.ts b/src/main/services/ai/human/node/handNode/toolExecutor.ts index a2cc0b4f..333ffe2e 100644 --- a/src/main/services/ai/human/node/handNode/toolExecutor.ts +++ b/src/main/services/ai/human/node/handNode/toolExecutor.ts @@ -72,7 +72,7 @@ const findTool = (input: { cache: HumanRuntimeCache }): StructuredToolInterface | undefined => { for (const skillId of input.learnedSkillIds) { - const matched = (input.cache.toolsets[skillId] || []).find( + const matched = (input.cache.skillToolsets[skillId] || []).find( (tool) => String(tool.name || '').trim() === input.toolName ) if (matched) return matched diff --git a/src/main/services/ai/human/node/learningNode/skillLoader.ts b/src/main/services/ai/human/node/learningNode/skillLoader.ts index f28a3f48..8de92998 100644 --- a/src/main/services/ai/human/node/learningNode/skillLoader.ts +++ b/src/main/services/ai/human/node/learningNode/skillLoader.ts @@ -1,47 +1,38 @@ import type { StructuredToolInterface } from '@langchain/core/tools' import type { AiRunContext } from '../../../types' +import type { AiToolFamilyId, ToolFamilyBuildInput } from '../../tools/types' +import { docxToolFamily } from '../../tools/docx' +import { kbToolFamily } from '../../tools/kb' +import { terminalToolFamily } from '../../tools/terminal' import type { HumanRuntimeCache, RuntimeSkillId } from '../../types' -import { buildSkillTools } from './skillRegistry' -import { docxCapability } from '../../tools/docx' +import { getSkillDefinition } from './skillRegistry' -const DOCX_READ_TOOL_NAMES = new Set([ - 'docx_open', - 'docx_outline', - 'docx_get_block', - 'docx_search', - 'docx_get_history' -]) -const DOCX_WRITE_TOOL_NAMES = new Set(['docx_apply_ops', 'docx_rollback']) -const DOCX_DELIVERY_TOOL_NAMES = new Set(['docx_validate', 'docx_render_preview', 'docx_save']) +const TOOL_FAMILY_BUILDERS: Record< + AiToolFamilyId, + (input: ToolFamilyBuildInput) => StructuredToolInterface[] +> = { + terminal: terminalToolFamily.buildTools, + kb: kbToolFamily.buildTools, + docx: docxToolFamily.buildTools +} -const ensureDocxToolsets = (input: { +const ensureFamilyTools = (input: { cache: HumanRuntimeCache ctx: AiRunContext runId: string taskId: string -}): void => { - if ( - input.cache.toolsets['docx-read'] && - input.cache.toolsets['docx-write'] && - input.cache.toolsets['docx-delivery'] - ) { - return - } + familyId: AiToolFamilyId +}): StructuredToolInterface[] => { + const cached = input.cache.familyToolsets[input.familyId] + if (cached) return cached - const tools = docxCapability.buildTools({ + const built = TOOL_FAMILY_BUILDERS[input.familyId]({ ctx: input.ctx, runId: input.runId, taskId: input.taskId }) - input.cache.toolsets['docx-read'] = tools.filter((tool) => - DOCX_READ_TOOL_NAMES.has(String(tool.name || '')) - ) - input.cache.toolsets['docx-write'] = tools.filter((tool) => - DOCX_WRITE_TOOL_NAMES.has(String(tool.name || '')) - ) - input.cache.toolsets['docx-delivery'] = tools.filter((tool) => - DOCX_DELIVERY_TOOL_NAMES.has(String(tool.name || '')) - ) + input.cache.familyToolsets[input.familyId] = built + return built } export const ensureSkillTools = (input: { @@ -51,23 +42,20 @@ export const ensureSkillTools = (input: { taskId: string skillId: RuntimeSkillId }): StructuredToolInterface[] => { - if ( - input.skillId === 'docx-read' || - input.skillId === 'docx-write' || - input.skillId === 'docx-delivery' - ) { - ensureDocxToolsets(input) - return input.cache.toolsets[input.skillId] || [] - } - - const cached = input.cache.toolsets[input.skillId] + const cached = input.cache.skillToolsets[input.skillId] if (cached) return cached - const built = buildSkillTools(input.skillId, { + + const definition = getSkillDefinition(input.skillId) + const familyTools = ensureFamilyTools({ + cache: input.cache, ctx: input.ctx, runId: input.runId, - taskId: input.taskId + taskId: input.taskId, + familyId: definition.familyId }) - input.cache.toolsets[input.skillId] = built + const allowedToolNames = new Set(definition.toolNames) + const built = familyTools.filter((tool) => allowedToolNames.has(String(tool.name || '').trim())) + input.cache.skillToolsets[input.skillId] = built return built } @@ -77,7 +65,7 @@ export const getAvailableToolNames = (input: { }): string[] => { const toolNames = new Set() input.learnedSkillIds.forEach((skillId) => { - ;(input.cache.toolsets[skillId] || []).forEach((tool) => { + ;(input.cache.skillToolsets[skillId] || []).forEach((tool) => { const toolName = String(tool.name || '').trim() if (toolName) toolNames.add(toolName) }) diff --git a/src/main/services/ai/human/node/learningNode/skillRegistry.ts b/src/main/services/ai/human/node/learningNode/skillRegistry.ts index 6e788d9d..6d958e8a 100644 --- a/src/main/services/ai/human/node/learningNode/skillRegistry.ts +++ b/src/main/services/ai/human/node/learningNode/skillRegistry.ts @@ -1,16 +1,9 @@ -import type { StructuredToolInterface } from '@langchain/core/tools' -import type { CapabilityToolBuildInput } from '../../tools/types' import type { RuntimeSkillId, SkillCatalogItem, SkillDefinition } from '../../types' -import { buildDocxDeliveryTools } from '../../tools/docx-delivery' -import { buildDocxReadTools } from '../../tools/docx-read' -import { buildDocxWriteTools } from '../../tools/docx-write' -import { buildKbDocumentDiscoveryTools } from '../../tools/kb-document-discovery' -import { buildKbEvidenceQueryTools } from '../../tools/kb-evidence-query' -import { buildWorkspaceCliTools } from '../../tools/workspace-cli' const SKILL_DEFINITIONS: Record = { 'workspace-cli': { skillId: 'workspace-cli', + familyId: 'terminal', title: 'workspace-cli', summary: '读取 workspace 文件、检查环境、执行单条 CLI 命令。', toolNames: ['terminal_run_command'], @@ -20,6 +13,7 @@ const SKILL_DEFINITIONS: Record = { }, 'docx-read': { skillId: 'docx-read', + familyId: 'docx', title: 'docx-read', summary: '打开 docx、定位结构、查找内容、查看历史。', toolNames: ['docx_open', 'docx_outline', 'docx_get_block', 'docx_search', 'docx_get_history'], @@ -29,6 +23,7 @@ const SKILL_DEFINITIONS: Record = { }, 'docx-write': { skillId: 'docx-write', + familyId: 'docx', title: 'docx-write', summary: '修改 docx 内容,或基于历史快照回滚。', toolNames: ['docx_apply_ops', 'docx_rollback'], @@ -37,6 +32,7 @@ const SKILL_DEFINITIONS: Record = { }, 'docx-delivery': { skillId: 'docx-delivery', + familyId: 'docx', title: 'docx-delivery', summary: '做文档校验、生成预览、按需落盘。', toolNames: ['docx_validate', 'docx_render_preview', 'docx_save'], @@ -45,6 +41,7 @@ const SKILL_DEFINITIONS: Record = { }, 'kb-document-discovery': { skillId: 'kb-document-discovery', + familyId: 'kb', title: 'kb-document-discovery', summary: '先确定应该查哪份知识文档。', toolNames: ['kb_list_documents'], @@ -53,6 +50,7 @@ const SKILL_DEFINITIONS: Record = { }, 'kb-evidence-query': { skillId: 'kb-evidence-query', + familyId: 'kb', title: 'kb-evidence-query', summary: '直接查询 chunk 证据并收敛事实依据。', toolNames: ['kb_hybrid_search_chunks', 'kb_vector_search_chunks', 'kb_fts_search_chunks'], @@ -61,21 +59,10 @@ const SKILL_DEFINITIONS: Record = { } } -const SKILL_TOOL_BUILDERS: Record< - RuntimeSkillId, - (input: CapabilityToolBuildInput) => StructuredToolInterface[] -> = { - 'workspace-cli': buildWorkspaceCliTools, - 'docx-read': buildDocxReadTools, - 'docx-write': buildDocxWriteTools, - 'docx-delivery': buildDocxDeliveryTools, - 'kb-document-discovery': buildKbDocumentDiscoveryTools, - 'kb-evidence-query': buildKbEvidenceQueryTools -} - export const buildSkillCatalog = (): SkillCatalogItem[] => Object.values(SKILL_DEFINITIONS).map((definition) => ({ skillId: definition.skillId, + familyId: definition.familyId, title: definition.title, summary: definition.summary, toolNames: [...definition.toolNames], @@ -86,8 +73,3 @@ export const getSkillDefinition = (skillId: RuntimeSkillId): SkillDefinition => export const hasSkillDefinition = (skillId: string): skillId is RuntimeSkillId => skillId in SKILL_DEFINITIONS - -export const buildSkillTools = ( - skillId: RuntimeSkillId, - input: CapabilityToolBuildInput -): StructuredToolInterface[] => SKILL_TOOL_BUILDERS[skillId](input) diff --git a/src/main/services/ai/human/node/mouthNode/contract.ts b/src/main/services/ai/human/node/mouthNode/contract.ts new file mode 100644 index 00000000..e6fe9999 --- /dev/null +++ b/src/main/services/ai/human/node/mouthNode/contract.ts @@ -0,0 +1,8 @@ +import { z } from 'zod/v4' +import type { HumanResult } from '../../types' + +export const mouthResultSchema: z.ZodType = z.object({ + status: z.enum(['completed', 'blocked']), + summary: z.string().min(1), + deliveryMarkdown: z.string().min(1) +}) diff --git a/src/main/services/ai/human/node/mouthNode/index.ts b/src/main/services/ai/human/node/mouthNode/index.ts index a8435a07..4ce1ce8f 100644 --- a/src/main/services/ai/human/node/mouthNode/index.ts +++ b/src/main/services/ai/human/node/mouthNode/index.ts @@ -1,11 +1,9 @@ -import { HumanMessage, SystemMessage } from '@langchain/core/messages' -import { createChatModel } from '../../../../llm/chatModel' -import { parseJsonObject } from '../../../../../utils/aiCapabilityUtils' import type { AiRunContext } from '../../../types' import type { HumanRuntimeCache, HumanState } from '../../types' import { appendHumanTraceStep, emitHumanStep, emitTaskDelivery } from '../utils/events' import { getAvailableToolNames } from '../learningNode/skillLoader' import { buildMouthPromptContext } from './promptContext' +import { mouthResultSchema } from './contract' import type { AiRunContextWithMouthOverride } from './types' const buildFallbackMarkdown = (state: HumanState, status: 'completed' | 'blocked', summary: string) => @@ -20,21 +18,6 @@ const buildFallbackMarkdown = (state: HumanState, status: 'completed' | 'blocked : '无' ].join('\n') -const resolveMessageText = (content: string | object | Array): string => { - if (typeof content === 'string') return content - if (Array.isArray(content)) { - return content - .map((part) => { - if (typeof part === 'string') return part - if ('text' in part && typeof part.text === 'string') return part.text - return '' - }) - .join('') - } - if ('text' in content && typeof content.text === 'string') return content.text - return '' -} - export const createMouthNode = (input: { ctx: AiRunContext cache: HumanRuntimeCache @@ -64,36 +47,14 @@ export const createMouthNode = (input: { state, finalStatus }) - try { - const modelConfig = await input.ctx.getModelConfig() - const model = createChatModel(modelConfig, { temperature: 0.2 }) - const response = await model.invoke([ - new SystemMessage(prompt.system), - new HumanMessage(prompt.user) - ]) - const parsed = parseJsonObject<{ - status: 'completed' | 'blocked' - summary: string - deliveryMarkdown: string - }>(resolveMessageText(response.content)) - result = { - status: parsed.status, - summary: parsed.summary, - deliveryMarkdown: parsed.deliveryMarkdown - } - } catch { - result = { - status: finalStatus, - summary: - state.lastBrainDecision?.decision.summary || - (finalStatus === 'completed' ? '任务已完成。' : '任务当前阻塞。'), - deliveryMarkdown: buildFallbackMarkdown( - state, - finalStatus, - state.lastBrainDecision?.decision.reason || '未获得稳定的对外交付。' - ) - } - } + result = await input.ctx.chatStructured({ + caller: 'human.mouth', + system: prompt.system, + user: prompt.user, + schema: mouthResultSchema, + schemaName: 'human_mouth_result', + temperature: 0.2 + }) } const finalResult = { diff --git a/src/main/services/ai/human/node/mouthNode/promptContext.ts b/src/main/services/ai/human/node/mouthNode/promptContext.ts index a52161a5..a8e68e45 100644 --- a/src/main/services/ai/human/node/mouthNode/promptContext.ts +++ b/src/main/services/ai/human/node/mouthNode/promptContext.ts @@ -20,7 +20,7 @@ export const buildMouthPromptContext = (input: { finalStatus: 'completed' | 'blocked' }) => { const finalDecision = input.state.lastBrainDecision - ? `最终决定:“${input.state.lastBrainDecision.decision.summary}”;原因:${input.state.lastBrainDecision.decision.reason};预期结果:${input.state.lastBrainDecision.decision.expectedOutcome}` + ? `最终决定:“${input.state.lastBrainDecision.decision.summary}”;原因:${input.state.lastBrainDecision.decision.reason};预期结果:${input.state.lastBrainDecision.decision.expectedOutcome};结束状态${input.finalStatus}` : 'none' return buildHumanMouthPrompt({ diff --git a/src/main/services/ai/human/node/utils/runtimeCache.ts b/src/main/services/ai/human/node/utils/runtimeCache.ts index fb443a5f..1e8dbd0a 100644 --- a/src/main/services/ai/human/node/utils/runtimeCache.ts +++ b/src/main/services/ai/human/node/utils/runtimeCache.ts @@ -1,6 +1,7 @@ import type { HumanRuntimeCache } from '../../types' export const createHumanRuntimeCache = (): HumanRuntimeCache => ({ - toolsets: {}, + familyToolsets: {}, + skillToolsets: {}, toolCallCount: 0 }) diff --git a/src/main/services/ai/human/prompts/brain.ts b/src/main/services/ai/human/prompts/brain.ts index 00c6da71..4005845c 100644 --- a/src/main/services/ai/human/prompts/brain.ts +++ b/src/main/services/ai/human/prompts/brain.ts @@ -40,7 +40,136 @@ export const buildHumanBrainPrompt = (input: HumanBrainPromptInput) => { | { "action": "hand", "parallelCallToolParam": [{ "toolName": "tool_name", "args": {} }] } | { "action": "mouth", "status": "completed" | "blocked" } } + + 字段解释: + - memoryOps:数组。表示本轮对内部记忆的增量操作;没有要修改的内容时输出空数组。 + - memoryOps[].op:字符串枚举。只能是 add、edit、remove。 + - memoryOps[].field:字符串枚举。只能是 facts、evidences、openQuestions。 + - memoryOps[].index:数字。仅在 edit、remove 时出现,表示要修改或删除的目标索引。 + - memoryOps[].value:仅在 add、edit 时出现。 + - memoryOps[].value(facts):对象,结构为 { id, summary, evidenceIds }。 + - memoryOps[].value.id:字符串。该 fact 或 evidence 的稳定标识。 + - memoryOps[].value.summary:字符串。该 fact 或 evidence 的简洁摘要。 + - memoryOps[].value.evidenceIds:字符串数组。仅 facts 使用,表示支撑该 fact 的 evidence id 列表。 + - memoryOps[].value.toolName:字符串。仅 evidences 使用,表示 evidence 对应的工具名。 + - memoryOps[].value.sourceType:字符串常量。仅 evidences 使用,当前固定为 tool_result。 + - memoryOps[].value.rawRef:可选字符串。仅 evidences 使用,表示原始结果引用位置。 + - memoryOps[].value(openQuestions):字符串。表示仍未解决的问题描述。 + - decision:对象。表示本轮正式判断。 + - decision.summary:字符串。简洁说明这轮决定做什么。 + - decision.reason:字符串。说明为什么做这个决定。 + - decision.expectedOutcome:字符串。说明期望这一步带来什么结果。 + - nextAction:对象。表示下一跳动作,且只能输出 learning、hand、mouth 三种结构之一。 + - nextAction.action:字符串枚举。只能是 learning、hand、mouth。 + - nextAction.wantLearnSkillIds:字符串数组。仅在 action=learning 时出现,表示本轮要学习的 skill id 列表。 + - nextAction.parallelCallToolParam:数组。仅在 action=hand 时出现,表示本轮要执行的工具调用列表。 + - nextAction.parallelCallToolParam[].toolName:字符串。要调用的工具名。 + - nextAction.parallelCallToolParam[].args:对象。对应工具的调用参数。 + - nextAction.status:字符串枚举。仅在 action=mouth 时出现,只能是 completed 或 blocked。 + + + - 当你已经有足够依据交付结果时,优先输出 mouth。 + - 当你缺的主要是“如何使用某个 skill”的信息时,输出 learning。 + - 当你已经明确知道要执行哪些工具动作时,输出 hand。 + - 不要编造工具结果、技能内容、事实或证据。 + - 不要输出 markdown。 + + + + + { + "memoryOps": [ + { + "op": "add", + "field": "evidences", + "value": { + "id": "evidence-kb-risk-1", + "toolName": "kb_hybrid_search_chunks", + "summary": "知识库结果显示项目风险控制要求包含上线前复核。", + "sourceType": "tool_result", + "rawRef": "tool_result#1" + } + }, + { + "op": "add", + "field": "facts", + "value": { + "id": "fact-risk-review-1", + "summary": "项目上线前需要执行复核。", + "evidenceIds": ["evidence-kb-risk-1"] + } + }, + { + "op": "remove", + "field": "openQuestions", + "index": 0 + } + ], + "decision": { + "summary": "已经得到关键依据,准备结束任务", + "reason": "当前事实已经有足够证据支撑,不需要继续检索。", + "expectedOutcome": "进入最终整理并输出结果。" + }, + "nextAction": { + "action": "mouth", + "status": "completed" + } + } + + + + { + "memoryOps": [], + "decision": { + "summary": "先补充当前所需的技能说明", + "reason": "我已经知道任务要继续推进,但还缺少相关 skill 的使用细节,继续猜测风险较高。", + "expectedOutcome": "获得足够的技能说明后,我可以决定是否调用工具。" + }, + "nextAction": { + "action": "learning", + "wantLearnSkillIds": ["docx-read"] + } + } + + + + { + "memoryOps": [], + "decision": { + "summary": "调用工具获取完成任务所需的直接证据", + "reason": "当前缺的是可验证的结果,而不是额外的解释。", + "expectedOutcome": "拿到新的工具结果后,我可以更新记忆并判断是否收敛。" + }, + "nextAction": { + "action": "hand", + "parallelCallToolParam": [ + { + "toolName": "kb_hybrid_search_chunks", + "args": { + "query": "项目风险控制要求" + } + } + ] + } + } + + + + { + "memoryOps": [], + "decision": { + "summary": "当前已经可以结束任务", + "reason": "现有证据已经足够支持最终结论,没有必要继续扩展动作。", + "expectedOutcome": "由后续表达节点整理出最终结果。" + }, + "nextAction": { + "action": "mouth", + "status": "completed" + } + } + + `.trim(), user: ` diff --git a/src/main/services/ai/human/prompts/mouth.ts b/src/main/services/ai/human/prompts/mouth.ts index 0c6b520f..97aca002 100644 --- a/src/main/services/ai/human/prompts/mouth.ts +++ b/src/main/services/ai/human/prompts/mouth.ts @@ -6,17 +6,20 @@ export const buildHumanMouthPrompt = (input: HumanMouthPromptInput) => { 你现在扮演一个负责对外表达最终结果的人。 - 你的任务是把已经确定下来的判断、事实和证据整理成用户可直接阅读和继续使用的结果。 + 你的任务是把已经确定下来的判断、事实和证据,整理成用户可以直接阅读和继续使用的结果。 - 不夸大、不补编、不引入新的结论,只把已经确认的信息组织成最终交付。 + 请像一个表达清晰、判断克制的人一样输出结果: + 不夸大、不补编、不引入新的结论, + 只把已经确认的信息组织成最终交付。 - 1. completed 时先直接给出结果,再给关键依据。 - 2. blocked 时先说明当前已完成到哪里,再说明阻塞原因和继续需要什么。 - 3. 只使用已给出的事实、证据和最终判断。 + 1. 如果任务已经完成,先直接给出结论,再给关键依据,再给结果或产物说明。 + 2. 如果任务当前阻塞,先说明已经完成到哪里,再说明阻塞原因,再说明继续需要什么。 + 3. 只使用已经给你的事实、证据和最终判断,不要偷偷补充新的推理。 + 4. 语言要清楚、克制、可执行,不要堆内部过程。 @@ -28,7 +31,27 @@ export const buildHumanMouthPrompt = (input: HumanMouthPromptInput) => { "summary": "string", "deliveryMarkdown": "string" } + + 字段解释: + - status:字符串枚举。表示任务最终状态,只能是 completed 或 blocked。 + - summary:字符串。面向用户的一句简洁结果摘要。 + - deliveryMarkdown:字符串。最终正式交付内容,必须是完整 markdown 正文。 + + + - 不要引入新的事实、依据、步骤或承诺。 + - 不要暴露内部字段名、节点名或系统术语。 + - 如果依据不足,就保持保守表达,不要写成确定结论。 + - deliveryMarkdown 必须是完整结果,而不是零散句子。 + + + + { + "status": "completed", + "summary": "已完成文档修改,并整理出关键依据。", + "deliveryMarkdown": "# 处理结果\\n\\n已完成本次修改。\\n\\n## 关键依据\\n- 依据一\\n- 依据二\\n\\n## 结果说明\\n- 结果一\\n- 结果二" + } + `.trim(), user: ` diff --git a/src/main/services/ai/human/state/schema.ts b/src/main/services/ai/human/state/schema.ts index e2c109de..2fc4b704 100644 --- a/src/main/services/ai/human/state/schema.ts +++ b/src/main/services/ai/human/state/schema.ts @@ -16,6 +16,7 @@ export const createHumanRunStateSchema = () => { ]) ) const runtimeSkillIdSchema = z.enum(RUNTIME_SKILL_IDS) + const toolFamilyIdSchema = z.enum(['terminal', 'kb', 'docx']) const toolCallResultCodeSchema = z.enum(TOOL_CALL_RESULT_CODES) const humanTaskInputSchema: z.ZodType = z.object({ taskId: z.string(), @@ -75,6 +76,7 @@ export const createHumanRunStateSchema = () => { .array( z.object({ skillId: runtimeSkillIdSchema, + familyId: toolFamilyIdSchema, title: z.string(), summary: z.string(), toolNames: z.array(z.string()), diff --git a/src/main/services/ai/human/tools/docx-delivery/index.ts b/src/main/services/ai/human/tools/docx-delivery/index.ts deleted file mode 100644 index 33ac46ed..00000000 --- a/src/main/services/ai/human/tools/docx-delivery/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { docxCapability } from '../docx' - -const DOCX_DELIVERY_TOOL_NAMES = new Set(['docx_validate', 'docx_render_preview', 'docx_save']) - -export const buildDocxDeliveryTools = (input: Parameters[0]) => - docxCapability - .buildTools(input) - .filter((tool) => DOCX_DELIVERY_TOOL_NAMES.has(String(tool.name || ''))) diff --git a/src/main/services/ai/human/tools/docx-read/index.ts b/src/main/services/ai/human/tools/docx-read/index.ts deleted file mode 100644 index f77ea49a..00000000 --- a/src/main/services/ai/human/tools/docx-read/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { docxCapability } from '../docx' - -const DOCX_READ_TOOL_NAMES = new Set([ - 'docx_open', - 'docx_outline', - 'docx_get_block', - 'docx_search', - 'docx_get_history' -]) - -export const buildDocxReadTools = (input: Parameters[0]) => - docxCapability.buildTools(input).filter((tool) => DOCX_READ_TOOL_NAMES.has(String(tool.name || ''))) diff --git a/src/main/services/ai/human/tools/docx-write/index.ts b/src/main/services/ai/human/tools/docx-write/index.ts deleted file mode 100644 index 32a11aeb..00000000 --- a/src/main/services/ai/human/tools/docx-write/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { docxCapability } from '../docx' - -const DOCX_WRITE_TOOL_NAMES = new Set(['docx_apply_ops', 'docx_rollback']) - -export const buildDocxWriteTools = (input: Parameters[0]) => - docxCapability.buildTools(input).filter((tool) => DOCX_WRITE_TOOL_NAMES.has(String(tool.name || ''))) diff --git a/src/main/services/ai/human/tools/docx/docxTools.ts b/src/main/services/ai/human/tools/docx/docxTools.ts index 10e94451..23265001 100644 --- a/src/main/services/ai/human/tools/docx/docxTools.ts +++ b/src/main/services/ai/human/tools/docx/docxTools.ts @@ -9,7 +9,7 @@ import type { IpcJsonValue } from '../../../../../../shared/types' import { emitFileArtifactEvent } from '../../../events/fileArtifacts' -import { resolveWorkspacePath } from '../terminal_exec/terminalPolicy' +import { resolveWorkspacePath } from '../terminal/terminalPolicy' import type { DocxCapabilityToolsInput, DocxCapabilityToolsRuntimeState, diff --git a/src/main/services/ai/human/tools/docx/index.ts b/src/main/services/ai/human/tools/docx/index.ts index f0748708..55615a01 100644 --- a/src/main/services/ai/human/tools/docx/index.ts +++ b/src/main/services/ai/human/tools/docx/index.ts @@ -1,7 +1,7 @@ import { tool } from '@langchain/core/tools' import { z } from 'zod/v4' import { emitActivity } from '../../../../../utils/aiCapabilityUtils' -import type { CapabilityToolFamily } from '../types' +import type { ToolFamilyDefinition } from '../types' import { buildDocxCapabilityTools } from './docxTools' const jsonValueSchema = z.lazy(() => @@ -29,7 +29,7 @@ const createWorkspaceGuardTool = (input: { | 'docx_validate' runId: string taskId: string - ctx: Parameters[0]['ctx'] + ctx: Parameters[0]['ctx'] }) => { return tool( async () => { @@ -56,8 +56,8 @@ const createWorkspaceGuardTool = (input: { ) } -export const docxCapability: CapabilityToolFamily = { - capability: 'docx', +export const docxToolFamily: ToolFamilyDefinition = { + familyId: 'docx', buildTools: ({ ctx, runId, taskId }) => { const workspaceRootPath = String(ctx.workspace?.rootPath || '').trim() if (!workspaceRootPath) { diff --git a/src/main/services/ai/human/tools/kb-document-discovery/index.ts b/src/main/services/ai/human/tools/kb-document-discovery/index.ts deleted file mode 100644 index b9e4be3a..00000000 --- a/src/main/services/ai/human/tools/kb-document-discovery/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { localKbRetrievalCapability } from '../local_kb_retrieval' - -export const buildKbDocumentDiscoveryTools = ( - input: Parameters[0] -) => - localKbRetrievalCapability - .buildTools(input) - .filter((tool) => String(tool.name || '') === 'kb_list_documents') diff --git a/src/main/services/ai/human/tools/kb-evidence-query/index.ts b/src/main/services/ai/human/tools/kb-evidence-query/index.ts deleted file mode 100644 index a051c731..00000000 --- a/src/main/services/ai/human/tools/kb-evidence-query/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { localKbRetrievalCapability } from '../local_kb_retrieval' - -const KB_EVIDENCE_TOOL_NAMES = new Set([ - 'kb_hybrid_search_chunks', - 'kb_vector_search_chunks', - 'kb_fts_search_chunks' -]) - -export const buildKbEvidenceQueryTools = ( - input: Parameters[0] -) => - localKbRetrievalCapability - .buildTools(input) - .filter((tool) => KB_EVIDENCE_TOOL_NAMES.has(String(tool.name || ''))) diff --git a/src/main/services/ai/human/tools/kb/index.ts b/src/main/services/ai/human/tools/kb/index.ts new file mode 100644 index 00000000..becf0ddb --- /dev/null +++ b/src/main/services/ai/human/tools/kb/index.ts @@ -0,0 +1,7 @@ +import type { ToolFamilyDefinition } from '../types' +import { buildRetrievalTools } from './retrievalTools' + +export const kbToolFamily: ToolFamilyDefinition = { + familyId: 'kb', + buildTools: ({ ctx, runId, taskId }) => buildRetrievalTools({ ctx, runId, taskId }) +} diff --git a/src/main/services/ai/human/tools/local_kb_retrieval/retrievalTools.ts b/src/main/services/ai/human/tools/kb/retrievalTools.ts similarity index 100% rename from src/main/services/ai/human/tools/local_kb_retrieval/retrievalTools.ts rename to src/main/services/ai/human/tools/kb/retrievalTools.ts diff --git a/src/main/services/ai/human/tools/local_kb_retrieval/index.ts b/src/main/services/ai/human/tools/local_kb_retrieval/index.ts deleted file mode 100644 index e96b436c..00000000 --- a/src/main/services/ai/human/tools/local_kb_retrieval/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { CapabilityToolFamily } from '../types' -import { buildRetrievalTools } from './retrievalTools' - -export const localKbRetrievalCapability: CapabilityToolFamily = { - capability: 'local_kb_retrieval', - buildTools: ({ ctx, runId, taskId }) => buildRetrievalTools({ ctx, runId, taskId }) -} diff --git a/src/main/services/ai/human/tools/terminal_exec/index.ts b/src/main/services/ai/human/tools/terminal/index.ts similarity index 93% rename from src/main/services/ai/human/tools/terminal_exec/index.ts rename to src/main/services/ai/human/tools/terminal/index.ts index 2d0c9331..89f3666d 100644 --- a/src/main/services/ai/human/tools/terminal_exec/index.ts +++ b/src/main/services/ai/human/tools/terminal/index.ts @@ -1,7 +1,7 @@ import { tool } from '@langchain/core/tools' import { z } from 'zod/v4' import { emitActivity } from '../../../../../utils/aiCapabilityUtils' -import type { CapabilityToolFamily } from '../types' +import type { ToolFamilyDefinition } from '../types' import { buildTerminalCapabilityTools } from './terminalTools' const jsonValueSchema = z.lazy(() => @@ -21,8 +21,8 @@ const workspaceRequiredResult = { error: 'workspace is required for terminal_exec' } -export const terminalExecCapability: CapabilityToolFamily = { - capability: 'terminal_exec', +export const terminalToolFamily: ToolFamilyDefinition = { + familyId: 'terminal', buildTools: ({ ctx, runId, taskId }) => { const workspaceRootPath = String(ctx.workspace?.rootPath || '').trim() if (!workspaceRootPath) { diff --git a/src/main/services/ai/human/tools/terminal_exec/terminalExec.ts b/src/main/services/ai/human/tools/terminal/terminalExec.ts similarity index 100% rename from src/main/services/ai/human/tools/terminal_exec/terminalExec.ts rename to src/main/services/ai/human/tools/terminal/terminalExec.ts diff --git a/src/main/services/ai/human/tools/terminal_exec/terminalPolicy.ts b/src/main/services/ai/human/tools/terminal/terminalPolicy.ts similarity index 100% rename from src/main/services/ai/human/tools/terminal_exec/terminalPolicy.ts rename to src/main/services/ai/human/tools/terminal/terminalPolicy.ts diff --git a/src/main/services/ai/human/tools/terminal_exec/terminalTools.ts b/src/main/services/ai/human/tools/terminal/terminalTools.ts similarity index 100% rename from src/main/services/ai/human/tools/terminal_exec/terminalTools.ts rename to src/main/services/ai/human/tools/terminal/terminalTools.ts diff --git a/src/main/services/ai/human/tools/terminal_exec/types.ts b/src/main/services/ai/human/tools/terminal/types.ts similarity index 100% rename from src/main/services/ai/human/tools/terminal_exec/types.ts rename to src/main/services/ai/human/tools/terminal/types.ts diff --git a/src/main/services/ai/human/tools/types.ts b/src/main/services/ai/human/tools/types.ts index c84d407a..7ff0933d 100644 --- a/src/main/services/ai/human/tools/types.ts +++ b/src/main/services/ai/human/tools/types.ts @@ -1,25 +1,30 @@ import type { StructuredToolInterface } from '@langchain/core/tools' import type { AiRunContext } from '../../types' +import type { RuntimeSkillId } from '../types' -export type AiToolCapability = 'local_kb_retrieval' | 'terminal_exec' | 'docx' +export type AiToolFamilyId = 'terminal' | 'kb' | 'docx' -export type CapabilityToolBuildInput = { +export type ToolFamilyBuildInput = { ctx: AiRunContext runId: string taskId: string } -export type CapabilityToolBuilder = ( - input: CapabilityToolBuildInput -) => StructuredToolInterface[] +export type ToolFamilyBuilder = (input: ToolFamilyBuildInput) => StructuredToolInterface[] -export type CapabilityToolFamily = { - capability: AiToolCapability - buildTools: CapabilityToolBuilder +export type ToolFamilyDefinition = { + familyId: AiToolFamilyId + buildTools: ToolFamilyBuilder } -export type CapabilityToolRegistry = { - families: CapabilityToolFamily[] +export type SkillToolBinding = { + skillId: RuntimeSkillId + familyId: AiToolFamilyId + toolNames: string[] +} + +export type ToolFamilyRegistry = { + families: ToolFamilyDefinition[] allTools: StructuredToolInterface[] - toolToCapability: Record + toolToFamily: Record } diff --git a/src/main/services/ai/human/tools/workspace-cli/index.ts b/src/main/services/ai/human/tools/workspace-cli/index.ts deleted file mode 100644 index 84d5da77..00000000 --- a/src/main/services/ai/human/tools/workspace-cli/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { terminalExecCapability } from '../terminal_exec' - -export const buildWorkspaceCliTools = (input: Parameters[0]) => - terminalExecCapability.buildTools(input) diff --git a/src/main/services/ai/human/types.ts b/src/main/services/ai/human/types.ts index 90e815f2..f49aa0e7 100644 --- a/src/main/services/ai/human/types.ts +++ b/src/main/services/ai/human/types.ts @@ -1,5 +1,6 @@ import type { StructuredToolInterface } from '@langchain/core/tools' import type { JsonObject, JsonValue } from '../../../../shared/types' +import type { AiToolFamilyId } from './tools/types' import { HUMAN_STEP_ACTIONS, HUMAN_STEP_RESULT_STATUSES, @@ -45,6 +46,7 @@ export type ToolPromptSpec = { export type SkillCatalogItem = { skillId: RuntimeSkillId + familyId: AiToolFamilyId title: string summary: string toolNames: string[] @@ -208,6 +210,7 @@ export type HumanState = { } export type HumanRuntimeCache = { - toolsets: Partial> + familyToolsets: Partial> + skillToolsets: Partial> toolCallCount: number } diff --git a/src/main/services/ai/main/node/intentNode.ts b/src/main/services/ai/main/node/intentNode.ts index 30ff3ab2..6876d0e2 100644 --- a/src/main/services/ai/main/node/intentNode.ts +++ b/src/main/services/ai/main/node/intentNode.ts @@ -36,21 +36,14 @@ export const createIntentNode = (input: { ctx: AiRunContext; maxContextRounds?: selectedScopeSummary: ctx.selectedScopeSummary }) - const raw = await ctx.chatJson({ + const decision = await ctx.chatStructured({ + caller: 'main.intent', system: prompt.system, user: prompt.user, + schema: contextDecisionSchema, + schemaName: 'main_intent_decision', temperature: 0.1 }) - - const parsed = contextDecisionSchema.safeParse(raw) - if (!parsed.success) { - return { - contextObjective: undefined, - mainStage: 'main_plan' - } - } - - const decision = parsed.data if (decision.mode === 'need_context') { const contextObjective = String(decision.reason || '').trim() || state.input return { diff --git a/src/main/services/ai/main/node/planNode.ts b/src/main/services/ai/main/node/planNode.ts index 62dcc1f1..6c700926 100644 --- a/src/main/services/ai/main/node/planNode.ts +++ b/src/main/services/ai/main/node/planNode.ts @@ -22,29 +22,16 @@ export const createPlanNode = (input: { ctx: AiRunContext }) => { selectedScopeSummary: ctx.selectedScopeSummary }) - const raw = await ctx.chatJson({ + const decision = await ctx.chatStructured({ + caller: 'main.plan', system: prompt.system, user: prompt.user, + schema: planDecisionSchema, + schemaName: 'main_plan_decision', temperature: 0.1 }) - const parsed = planDecisionSchema.safeParse(raw) - if (!parsed.success) { - const parseReason = parsed.error.issues - .map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : 'root' - return `${path}: ${issue.message}` - }) - .join('; ') - - return { - status: 'failed', - error: `plan_generation_failed: ${parseReason || 'invalid planner output'}`, - mainStage: 'main_complete' - } - } - - const validationError = validatePlannerTasks(parsed.data.tasks) + const validationError = validatePlannerTasks(decision.tasks) if (validationError) { return { status: 'failed', @@ -53,7 +40,7 @@ export const createPlanNode = (input: { ctx: AiRunContext }) => { } } - const plan = toPlanTasks(parsed.data.tasks) + const plan = toPlanTasks(decision.tasks) ctx.sendEvent({ type: 'plan_created', diff --git a/src/main/services/ai/memory/index.ts b/src/main/services/ai/memory/index.ts index 1fc1ee3f..9969ae43 100644 --- a/src/main/services/ai/memory/index.ts +++ b/src/main/services/ai/memory/index.ts @@ -1,31 +1,23 @@ -import { HumanMessage, SystemMessage } from '@langchain/core/messages' -import type { ChatSessionMemory, JsonObject, JsonValue } from '../../../types/store' +import { z } from 'zod/v4' +import type { ChatSessionMemory, JsonValue } from '../../../types/store' import type { ChatStore } from '../../storage/domains/ChatStore' import type { LlmConfigStore } from '../../storage/domains/LlmConfigStore' -import { createChatModel } from '../../llm/chatModel' +import { invokeStructuredChat } from '../structuredOutput' import { buildSessionMemoryPrompt } from './prompts/session' -const parseJsonObject = (text: string): T => { - const trimmed = String(text || '').trim() - try { - return JSON.parse(trimmed) as T - } catch (err) { - void err - } - const start = trimmed.indexOf('{') - const end = trimmed.lastIndexOf('}') - if (start >= 0 && end > start) { - const slice = trimmed.slice(start, end + 1) - return JSON.parse(slice) as T - } - throw new Error('无法解析 JSON 输出') -} +const sessionMemorySchema: z.ZodType = z.object({ + summary: z.string(), + openTasks: z.array(z.string()), + userPrefs: z.array(z.string()), + pinnedFacts: z.array(z.string()), + linkedDocumentIds: z.array(z.string()) +}) const toList = (value: JsonValue | undefined): string[] => (Array.isArray(value) ? value : []).map((item) => String(item || '').trim()).filter(Boolean) const normalizeMemory = (input: JsonValue | undefined): ChatSessionMemory => { - const obj: JsonObject = input && typeof input === 'object' && !Array.isArray(input) ? input : {} + const obj = input && typeof input === 'object' && !Array.isArray(input) ? input : {} return { summary: String(obj.summary || '').trim(), openTasks: toList(obj.openTasks).slice(0, 20), @@ -56,22 +48,22 @@ export const updateSessionMemoryAfterRun = async (input: { const cfg = await input.llmStore.getActive() if (!cfg) return - const model = createChatModel(cfg, { temperature: 0.2 }) - const res = await model.invoke([ - new SystemMessage(buildSessionMemoryPrompt()), - new HumanMessage( - JSON.stringify({ + const memory = normalizeMemory( + await invokeStructuredChat({ + caller: 'session.memory', + cfg, + schema: sessionMemorySchema, + schemaName: 'session_memory', + system: buildSessionMemoryPrompt(), + user: JSON.stringify({ previousMemory: prev, newExchange: { user: String(input.userInput || ''), assistant: String(input.assistantOutput || '') } - }) - ) - ]) - const text = typeof res?.content === 'string' ? res.content : String(res?.content ?? '') - if (!text) return - const json = parseJsonObject(text) - const memory = normalizeMemory(json) + }), + temperature: 0.2 + }) + ) await input.chatStore.saveSessionMemory(sessionId, memory) } diff --git a/src/main/services/ai/runtime.ts b/src/main/services/ai/runtime.ts index 5932c1e0..406070c0 100644 --- a/src/main/services/ai/runtime.ts +++ b/src/main/services/ai/runtime.ts @@ -1,24 +1,7 @@ -import { HumanMessage, SystemMessage } from '@langchain/core/messages' import type { AiRunContext, AiRunDeps, AiRunStartPayload, CancelledError } from './types' import type { SourceScopeSelection } from '../../../shared/types' -import { createChatModel } from '../llm/chatModel' -import type { ChatMessage, JsonObject } from '../../types/store' - -const parseJsonObject = (text: string): T => { - const trimmed = String(text || '').trim() - try { - return JSON.parse(trimmed) as T - } catch (err) { - void err - } - const start = trimmed.indexOf('{') - const end = trimmed.lastIndexOf('}') - if (start >= 0 && end > start) { - const slice = trimmed.slice(start, end + 1) - return JSON.parse(slice) as T - } - throw new Error('无法解析 JSON 输出') -} +import type { ChatMessage } from '../../types/store' +import { invokeStructuredChat } from './structuredOutput' const clipText = (text: string, maxChars: number) => { const t = String(text || '') @@ -110,15 +93,20 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): return cfg } - const chatJson: AiRunContext['chatJson'] = async (input): Promise => { + const chatStructured: AiRunContext['chatStructured'] = async >( + input + ): Promise => { checkCancelled() - const { system, user, temperature } = input const cfg = await getActiveConfig() - const model = createChatModel(cfg, { temperature: temperature ?? 0.2 }) - const res = await model.invoke([new SystemMessage(system), new HumanMessage(user)]) - const text = typeof res?.content === 'string' ? res.content : String(res?.content ?? '') - if (!text) throw new Error('大模型返回为空') - return parseJsonObject(text) + return invokeStructuredChat({ + caller: input.caller, + cfg, + schema: input.schema, + schemaName: input.schemaName, + system: input.system, + user: input.user, + temperature: input.temperature + }) } const loadHistory: AiRunContext['loadHistory'] = async (limits) => { @@ -159,7 +147,7 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): } : undefined, checkCancelled, - chatJson, + chatStructured, sendEvent: deps.sendEvent, requestApproval: deps.requestApproval, loadHistory, diff --git a/src/main/services/ai/structuredOutput.ts b/src/main/services/ai/structuredOutput.ts new file mode 100644 index 00000000..6e8eac21 --- /dev/null +++ b/src/main/services/ai/structuredOutput.ts @@ -0,0 +1,153 @@ +import { HumanMessage, SystemMessage } from '@langchain/core/messages' +import { z } from 'zod/v4' +import { createChatModel } from '../llm/chatModel' +import type { LLMConfig } from '../../../shared/types' + +const STRUCTURED_OUTPUT_METHODS = ['jsonSchema', 'functionCalling', 'jsonMode'] as const + +type StructuredOutputMethod = (typeof STRUCTURED_OUTPUT_METHODS)[number] + +type StructuredOutputInvocationInput> = { + caller: string + cfg: LLMConfig + schema: z.ZodType + schemaName: string + system: string + user: string + temperature?: number +} + +const getErrorText = (error: unknown): string => { + if (error instanceof Error && error.message.trim()) return error.message.trim() + return String(error || 'unknown structured output error') +} + +const getStructuredOutputErrorBody = (error: unknown): string => { + const parts = [getErrorText(error)] + if (error && typeof error === 'object') { + const responseData = + 'response' in error && + error.response && + typeof error.response === 'object' && + 'data' in error.response + ? error.response.data + : undefined + if (responseData !== undefined) { + try { + parts.push(JSON.stringify(responseData)) + } catch { + parts.push(String(responseData)) + } + } + } + return parts.join(' ') +} + +const matchesUnsupportedSignal = (body: string, markers: string[]): boolean => { + const unsupportedSignals = [ + 'not supported', + 'unsupported', + 'does not support', + 'is not supported', + 'unknown parameter', + 'invalid parameter', + 'invalid value', + 'not allowed', + 'not available', + 'unrecognized request argument', + 'unsupported parameter' + ] + return unsupportedSignals.some((signal) => body.includes(signal)) && + markers.some((marker) => body.includes(marker)) +} + +export const isStructuredOutputMethodUnsupportedError = ( + method: StructuredOutputMethod, + error: unknown +): boolean => { + const body = getStructuredOutputErrorBody(error).toLowerCase() + if (!body) return false + + if (method === 'jsonSchema') { + return matchesUnsupportedSignal(body, [ + 'json schema', + 'json_schema', + 'response_format', + 'response format' + ]) + } + + if (method === 'functionCalling') { + return matchesUnsupportedSignal(body, [ + 'function calling', + 'function_calling', + 'tool calling', + 'tools', + 'tool_choice', + 'tool choice' + ]) + } + + return matchesUnsupportedSignal(body, ['json mode', 'json_object', 'response_format', 'response format']) +} + +const buildMethodConfig = (method: StructuredOutputMethod, schemaName: string) => { + if (method === 'jsonMode') { + return { + method, + name: schemaName + } as const + } + + return { + method, + name: schemaName, + strict: true + } as const +} + +export const invokeStructuredChat = async >( + input: StructuredOutputInvocationInput +): Promise => { + const methodErrors: string[] = [] + + for (const method of STRUCTURED_OUTPUT_METHODS) { + try { + const model = createChatModel(input.cfg, { temperature: input.temperature ?? 0.2 }) + const structuredModel = model.withStructuredOutput( + input.schema, + buildMethodConfig(method, input.schemaName) + ) + return (await structuredModel.invoke([ + new SystemMessage(input.system), + new HumanMessage(input.user) + ])) as T + } catch (error) { + if (isStructuredOutputMethodUnsupportedError(method, error)) { + methodErrors.push(`${method}: ${getErrorText(error)}`) + continue + } + throw new Error( + [ + 'structured_output_failed', + `caller=${input.caller}`, + `schema=${input.schemaName}`, + `model=${input.cfg.model_name}`, + `method=${method}`, + `reason=${getErrorText(error)}` + ].join('; ') + ) + } + } + + throw new Error( + [ + 'structured_output_unsupported', + `caller=${input.caller}`, + `schema=${input.schemaName}`, + `model=${input.cfg.model_name}`, + `tried=${STRUCTURED_OUTPUT_METHODS.join(' -> ')}`, + `errors=${methodErrors.join(' | ') || 'unknown'}` + ].join('; ') + ) +} diff --git a/src/main/services/ai/types.ts b/src/main/services/ai/types.ts index 7b6abde8..fe1e8669 100644 --- a/src/main/services/ai/types.ts +++ b/src/main/services/ai/types.ts @@ -4,6 +4,7 @@ import type { LlmConfigStore } from '../storage/domains/LlmConfigStore' import type { EmbeddingService } from '../vector/EmbeddingService' import type { DocxChangedBlock, DocxOp } from './human/tools/docx/types' import type { DraftReviewService } from './human/tools/docx/review/DraftReviewService' +import type { z } from 'zod/v4' import type { AiRuntimeSettings, AiPlanTask, @@ -11,7 +12,6 @@ import type { AiRunEventStage, AiRunStatus, AiTaskStatus, - JsonObject, LLMConfig, SourceScopeSelection } from '../../../shared/types' @@ -126,9 +126,12 @@ export type AiRunContext = { policyProfile?: string } checkCancelled: () => void - chatJson: (input: { + chatStructured: >(input: { + caller: string system: string user: string + schema: z.ZodType + schemaName: string temperature?: number }) => Promise sendEvent: (event: AiRunEvent) => void diff --git a/tests/ai/backend/human/basic/memory-reducer-order-insensitive.test.ts b/tests/ai/backend/human/basic/memory-reducer-order-insensitive.test.ts new file mode 100644 index 00000000..b4023f16 --- /dev/null +++ b/tests/ai/backend/human/basic/memory-reducer-order-insensitive.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { reduceBrainMemory } from '../../../../../src/main/services/ai/human/node/brainNode/memoryReducer' +import type { HumanState } from '../../../../../src/main/services/ai/human/types' + +const baseInside: HumanState['brainMemory']['inside'] = { + facts: [], + evidences: [], + openQuestions: ['当前缺少直接依据。'] +} + +describe('reduceBrainMemory', () => { + it('treats same-round fact to new evidence references as order-insensitive', () => { + const factThenEvidence = reduceBrainMemory({ + inside: baseInside, + memoryOps: [ + { + op: 'add', + field: 'facts', + value: { + id: 'fact-1', + summary: '项目上线前需要复核。', + evidenceIds: ['evidence-1'] + } + }, + { + op: 'add', + field: 'evidences', + value: { + id: 'evidence-1', + toolName: 'kb_hybrid_search_chunks', + summary: '知识库显示上线前需要复核。', + sourceType: 'tool_result' + } + } + ] + }) + + const evidenceThenFact = reduceBrainMemory({ + inside: baseInside, + memoryOps: [ + { + op: 'add', + field: 'evidences', + value: { + id: 'evidence-1', + toolName: 'kb_hybrid_search_chunks', + summary: '知识库显示上线前需要复核。', + sourceType: 'tool_result' + } + }, + { + op: 'add', + field: 'facts', + value: { + id: 'fact-1', + summary: '项目上线前需要复核。', + evidenceIds: ['evidence-1'] + } + } + ] + }) + + expect(factThenEvidence.watchHint).toBeUndefined() + expect(evidenceThenFact.watchHint).toBeUndefined() + expect(factThenEvidence.inside).toEqual(evidenceThenFact.inside) + expect(factThenEvidence.inside.facts).toHaveLength(1) + expect(factThenEvidence.inside.evidences).toHaveLength(1) + }) + + it('still rejects facts that reference evidences missing from the final same-round evidence set', () => { + const result = reduceBrainMemory({ + inside: baseInside, + memoryOps: [ + { + op: 'add', + field: 'evidences', + value: { + id: 'evidence-1', + toolName: 'kb_hybrid_search_chunks', + summary: '知识库显示上线前需要复核。', + sourceType: 'tool_result' + } + }, + { + op: 'remove', + field: 'evidences', + index: 0 + }, + { + op: 'add', + field: 'facts', + value: { + id: 'fact-1', + summary: '项目上线前需要复核。', + evidenceIds: ['evidence-1'] + } + } + ] + }) + + expect(result.watchHint).toContain('memoryReducer') + expect(result.inside.evidences).toEqual([]) + expect(result.inside.facts).toEqual([]) + }) +}) diff --git a/tests/ai/backend/human/basic/prompt-context-alignment.test.ts b/tests/ai/backend/human/basic/prompt-context-alignment.test.ts new file mode 100644 index 00000000..1ec0a4e4 --- /dev/null +++ b/tests/ai/backend/human/basic/prompt-context-alignment.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { buildBrainPromptContext } from '../../../../../src/main/services/ai/human/node/brainNode/promptContext' +import { buildMouthPromptContext } from '../../../../../src/main/services/ai/human/node/mouthNode/promptContext' +import type { HumanState } from '../../../../../src/main/services/ai/human/types' + +const createBaseState = (): HumanState => ({ + task: { + taskId: 'task-1', + userInput: '请整理项目风险控制要求。' + }, + runtime: { + stage: 'brain', + status: 'running' + }, + brainMemory: { + inside: { + facts: [ + { + id: 'fact-1', + summary: '项目上线前需要执行复核。', + evidenceIds: ['evidence-1'] + } + ], + evidences: [ + { + id: 'evidence-1', + toolName: 'kb_hybrid_search_chunks', + summary: '知识库显示上线前需要复核。', + sourceType: 'tool_result', + rawRef: 'tool_result#1' + } + ], + openQuestions: ['是否需要同步更新周报?'] + }, + outside: { + skillCatalog: [ + { + skillId: 'kb-evidence-query', + familyId: 'kb', + title: 'kb-evidence-query', + summary: '直接查询 chunk 证据并收敛事实依据。', + toolNames: ['kb_hybrid_search_chunks', 'kb_vector_search_chunks', 'kb_fts_search_chunks'], + whenToUse: '当已经知道查询目标,需要直接从本地知识库提取证据时。' + } + ], + learnedSkillIds: ['kb-evidence-query'], + brainDecisions: [ + { + createdAt: 1710926460000, + memoryOps: [], + decision: { + summary: '调用 KB 检索获取证据', + reason: '当前缺少可引用的直接依据。', + expectedOutcome: '拿到证据后判断是否收敛。' + }, + nextAction: { + action: 'hand', + parallelCallToolParam: [ + { + toolName: 'kb_hybrid_search_chunks', + args: { + query: '项目风险控制要求' + } + } + ] + } + } + ] + } + }, + lastParallelCallToolResults: [ + { + toolName: 'kb_hybrid_search_chunks', + ok: true, + code: 'ok', + summary: '命中 3 条相关证据。' + } + ], + lastBrainDecision: { + memoryOps: [], + decision: { + summary: '当前已经可以结束任务', + reason: '现有证据已足够支撑最终结论。', + expectedOutcome: '由表达节点整理出最终结果。' + }, + nextAction: { + action: 'mouth', + status: 'completed' + } + }, + watchHint: '请优先考虑结束或纠偏。', + trace: { + steps: [] + } +}) + +describe('prompt context alignment', () => { + it('builds brain prompt context with text blocks and only keeps structured payload in decision history nextAction', () => { + const prompt = buildBrainPromptContext(createBaseState()) + + expect(prompt.user).toContain('') + expect(prompt.user).toContain('facts:') + expect(prompt.user).toContain('evidences:') + expect(prompt.user).toContain('openQuestions:') + expect(prompt.user).not.toContain('"facts"') + expect(prompt.user).toContain('toolName=kb_hybrid_search_chunks;ok=true;code=ok;summary=命中 3 条相关证据。') + expect(prompt.user).not.toContain('"ok":true') + expect(prompt.user).toContain( + 'nextAction数据:parallelCallToolParam=[{"toolName":"kb_hybrid_search_chunks","args":{"query":"项目风险控制要求"}}]' + ) + expect(prompt.user).toContain('exampleArgs=query') + expect(prompt.user).not.toContain('example={"query"') + }) + + it('builds mouth prompt context without recent decision history', () => { + const prompt = buildMouthPromptContext({ + state: createBaseState(), + finalStatus: 'completed' + }) + + expect(prompt.user).toContain('') + expect(prompt.user).toContain('结束状态completed') + expect(prompt.user).not.toContain('') + }) +}) diff --git a/tests/ai/backend/main/basic/main-plan-created.test.ts b/tests/ai/backend/main/basic/main-plan-created.test.ts index 7b633e35..86c66030 100644 --- a/tests/ai/backend/main/basic/main-plan-created.test.ts +++ b/tests/ai/backend/main/basic/main-plan-created.test.ts @@ -23,7 +23,7 @@ defineAiMainCase({ runMode: 'repeatable', execute: async (runtime) => { const ctx = runtime.createAiRunContext({ - chatJsonOverride: async () => ({ + chatStructuredOverride: async () => ({ tasks: [ { id: 'task-1', diff --git a/tests/ai/backend/runtime/basic/structured-output-fallback.test.ts b/tests/ai/backend/runtime/basic/structured-output-fallback.test.ts new file mode 100644 index 00000000..463cfc03 --- /dev/null +++ b/tests/ai/backend/runtime/basic/structured-output-fallback.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod/v4' +import { invokeStructuredChat } from '../../../../../src/main/services/ai/structuredOutput' + +const createChatModelMock = vi.fn() + +vi.mock('../../../../../src/main/services/llm/chatModel', () => ({ + createChatModel: (...args: unknown[]) => createChatModelMock(...args) +})) + +const cfg = { + id: 'cfg-1', + name: 'test-model', + base_url: 'https://example.com/v1', + model_name: 'gpt-4o', + api_key: 'test-key', + is_active: true as const +} + +const schema = z.object({ + mode: z.string() +}) + +describe('invokeStructuredChat', () => { + beforeEach(() => { + createChatModelMock.mockReset() + }) + + it('falls back from jsonSchema to functionCalling when provider does not support jsonSchema', async () => { + const attemptedMethods: string[] = [] + + createChatModelMock.mockImplementation(() => ({ + withStructuredOutput: (_schema: unknown, config: { method: string }) => ({ + invoke: async () => { + attemptedMethods.push(config.method) + if (config.method === 'jsonSchema') { + throw new Error('Invalid parameter: response_format json_schema is not supported') + } + return { mode: 'function-calling-result' } + } + }) + })) + + const result = await invokeStructuredChat({ + caller: 'test.structured_output', + cfg, + schema, + schemaName: 'test_schema', + system: 'system prompt', + user: 'user prompt' + }) + + expect(result).toEqual({ mode: 'function-calling-result' }) + expect(attemptedMethods).toEqual(['jsonSchema', 'functionCalling']) + }) + + it('falls back to jsonMode after jsonSchema and functionCalling are both unsupported', async () => { + const attemptedMethods: string[] = [] + + createChatModelMock.mockImplementation(() => ({ + withStructuredOutput: (_schema: unknown, config: { method: string }) => ({ + invoke: async () => { + attemptedMethods.push(config.method) + if (config.method === 'jsonSchema') { + throw new Error('response_format json_schema not supported by this endpoint') + } + if (config.method === 'functionCalling') { + throw new Error('tool_choice tools are not supported by this provider') + } + return { mode: 'json-mode-result' } + } + }) + })) + + const result = await invokeStructuredChat({ + caller: 'test.structured_output', + cfg, + schema, + schemaName: 'test_schema', + system: 'system prompt', + user: 'user prompt' + }) + + expect(result).toEqual({ mode: 'json-mode-result' }) + expect(attemptedMethods).toEqual(['jsonSchema', 'functionCalling', 'jsonMode']) + }) + + it('does not fall back when the active method fails for a normal output error', async () => { + const attemptedMethods: string[] = [] + + createChatModelMock.mockImplementation(() => ({ + withStructuredOutput: (_schema: unknown, config: { method: string }) => ({ + invoke: async () => { + attemptedMethods.push(config.method) + throw new Error('Invalid output: expected string, received number') + } + }) + })) + + await expect( + invokeStructuredChat({ + caller: 'test.structured_output', + cfg, + schema, + schemaName: 'test_schema', + system: 'system prompt', + user: 'user prompt' + }) + ).rejects.toThrow(/structured_output_failed/) + + expect(attemptedMethods).toEqual(['jsonSchema']) + }) +}) diff --git a/tests/harness/backend/aiToolHarness.ts b/tests/harness/backend/aiToolHarness.ts index 4ab6eecc..adfcf9cb 100644 --- a/tests/harness/backend/aiToolHarness.ts +++ b/tests/harness/backend/aiToolHarness.ts @@ -12,8 +12,8 @@ import { } from 'docx' import { buildDocxCapabilityTools } from '../../../src/main/services/ai/human/tools/docx/docxTools' import { releaseDocxRunSession } from '../../../src/main/services/ai/human/tools/docx/worker/docxWorkerClient' -import { buildRetrievalTools } from '../../../src/main/services/ai/human/tools/local_kb_retrieval/retrievalTools' -import { buildTerminalCapabilityTools } from '../../../src/main/services/ai/human/tools/terminal_exec/terminalTools' +import { buildRetrievalTools } from '../../../src/main/services/ai/human/tools/kb/retrievalTools' +import { buildTerminalCapabilityTools } from '../../../src/main/services/ai/human/tools/terminal/terminalTools' import type { JsonObject, JsonValue } from '../../../src/shared/types' import type { BackendTestRuntime, CreateAiRunContextInput } from './test-types' diff --git a/tests/harness/backend/contextFactory.ts b/tests/harness/backend/contextFactory.ts index 44a3a2ca..157c945f 100644 --- a/tests/harness/backend/contextFactory.ts +++ b/tests/harness/backend/contextFactory.ts @@ -1,11 +1,9 @@ -import { HumanMessage, SystemMessage } from '@langchain/core/messages' import { randomUUID } from 'node:crypto' -import { createChatModel } from '../../../src/main/services/llm/chatModel' +import { invokeStructuredChat } from '../../../src/main/services/ai/structuredOutput' import type { DocxChangedBlock, DocxOp } from '../../../src/main/services/ai/human/tools/docx/types' import type { LastBrainDecision } from '../../../src/main/services/ai/human/types' import type { AiRunContext } from '../../../src/main/services/ai/types' import type { AiApprovalDecision, AiApprovalRequest } from '../../../src/main/services/ai/types' -import type { JsonObject } from '../../../src/shared/types' import type { EventCollector } from '../core/eventCollector' import type { ApprovalPolicy, @@ -32,7 +30,7 @@ const buildApprovalResolver = ( type ToolExecutionOverrideResolver = (input: { toolName: string - args: JsonObject + args: Record }) => TestToolExecutionOverride['result'] | undefined const buildDocumentsStoreStub = (overrides: TestDocumentsStoreOverrides | undefined) => ({ @@ -172,20 +170,6 @@ const translateTestReasonShortcut = (output: TestHumanReasonShortcut): LastBrain } } -const parseJsonObject = (text: string): T => { - const trimmed = String(text || '').trim() - try { - return JSON.parse(trimmed) as T - } catch { - const start = trimmed.indexOf('{') - const end = trimmed.lastIndexOf('}') - if (start >= 0 && end > start) { - return JSON.parse(trimmed.slice(start, end + 1)) as T - } - } - throw new Error('无法解析 JSON 输出') -} - type CreateTestAiRunContextInput = CreateAiRunContextInput & { runId?: string sessionId?: string @@ -260,23 +244,22 @@ export const createTestAiRunContext = (input: CreateTestAiRunContextInput): AiRu } : undefined, checkCancelled: input.checkCancelledOverride || (() => undefined), - chatJson: async (payload): Promise => { - if (input.chatJsonOverride) { - return input.chatJsonOverride(payload) + chatStructured: async >(payload): Promise => { + if (input.chatStructuredOverride) { + return input.chatStructuredOverride(payload) } if (!input.liveModelConfig) { - throw new Error('chatJson is not available in this test harness context') + throw new Error('chatStructured is not available in this test harness context') } - const model = createChatModel(input.liveModelConfig, { - temperature: payload.temperature ?? 0.2 + return invokeStructuredChat({ + caller: payload.caller, + cfg: input.liveModelConfig, + schema: payload.schema, + schemaName: payload.schemaName, + system: payload.system, + user: payload.user, + temperature: payload.temperature }) - const res = await model.invoke([ - new SystemMessage(payload.system), - new HumanMessage(payload.user) - ]) - const text = typeof res?.content === 'string' ? res.content : String(res?.content ?? '') - if (!text) throw new Error('大模型返回为空') - return parseJsonObject(text) }, sendEvent: (event: Parameters[0]) => { input.eventCollector.add(event) diff --git a/tests/harness/backend/test-types.ts b/tests/harness/backend/test-types.ts index 42d64a9e..24ad50f0 100644 --- a/tests/harness/backend/test-types.ts +++ b/tests/harness/backend/test-types.ts @@ -12,10 +12,10 @@ import type { import type { AiRuntimeSettings, AiTaskResultCode, - JsonObject, JsonValue, LLMConfig } from '../../../src/shared/types' +import type { z } from 'zod/v4' import type { EventCollector } from '../core/eventCollector' import type { TestDomain, @@ -109,9 +109,12 @@ export type CreateAiRunContextInput = { state: HumanState }) => string | HumanResult liveModelConfig?: LiveLlmConfig - chatJsonOverride?: (input: { + chatStructuredOverride?: >(input: { + caller: string system: string user: string + schema: z.ZodType + schemaName: string temperature?: number }) => Promise historyOverride?: {