Refactor AI structured output and tool family loading

This commit is contained in:
hy committed 2026-03-20 18:59:21 +08:00
1 parent 65e878dbbd
commit b85d3c2f50
49 files changed
+1037 -560

No files matched your search

+1 -1
View File
@@ -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`
+4 -5
View File
@@ -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
+2 -2
View File
@@ -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. 目标
@@ -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<JsonValue> = 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<JsonObject> = 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<BrainFact> = 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<BrainEvidence> = 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<ToolCallParam> = 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<BrainMemoryOp> = 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<BrainDecision> = 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<BrainNextAction> = 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<LastBrainDecision> = 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)
@@ -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)
@@ -4,11 +4,10 @@ const INVALID_MEMORY_OP_HINT =
'memoryReducer 检测到非法记忆编辑,已忽略对应操作,请重新判断下一步。'
const hasEvidenceIds = (
evidences: HumanState['brainMemory']['inside']['evidences'],
evidenceIdsInScope: Set<string>,
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 = <T>(list: T[], index: number, value: T): T[] => {
@@ -23,9 +22,10 @@ const removeAtIndex = <T>(list: T[], index: number): T[] => {
const applyFactOp = (
inside: HumanState['brainMemory']['inside'],
op: Extract<BrainMemoryOp, { field: 'facts' }>
op: Extract<BrainMemoryOp, { field: 'facts' }>,
validEvidenceIds: Set<string>
) => {
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<string> => {
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<BrainMemoryOp, { field: 'openQuestions' }>
@@ -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)
@@ -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 [
`<catalog>\n${catalogText}\n</catalog>`,
`<learned>\n${learnedDetailText}\n</learned>`,
`<learned_tool_specs>\n${learnedToolSpecText}\n</learned_tool_specs>`
'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'
})
@@ -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 = {
@@ -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
@@ -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<string>()
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)
})
@@ -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<RuntimeSkillId, SkillDefinition> = {
'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<RuntimeSkillId, SkillDefinition> = {
},
'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<RuntimeSkillId, SkillDefinition> = {
},
'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<RuntimeSkillId, SkillDefinition> = {
},
'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<RuntimeSkillId, SkillDefinition> = {
},
'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<RuntimeSkillId, SkillDefinition> = {
},
'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<RuntimeSkillId, SkillDefinition> = {
}
}
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)
@@ -0,0 +1,8 @@
import { z } from 'zod/v4'
import type { HumanResult } from '../../types'
export const mouthResultSchema: z.ZodType<HumanResult> = z.object({
status: z.enum(['completed', 'blocked']),
summary: z.string().min(1),
deliveryMarkdown: z.string().min(1)
})
@@ -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 | object>): 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 = {
@@ -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({
@@ -1,6 +1,7 @@
import type { HumanRuntimeCache } from '../../types'
export const createHumanRuntimeCache = (): HumanRuntimeCache => ({
toolsets: {},
familyToolsets: {},
skillToolsets: {},
toolCallCount: 0
})
+129
View File
@@ -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。
</output_rules>
<output_principles>
- 当你已经有足够依据交付结果时,优先输出 mouth。
- 当你缺的主要是“如何使用某个 skill”的信息时,输出 learning。
- 当你已经明确知道要执行哪些工具动作时,输出 hand。
- 不要编造工具结果、技能内容、事实或证据。
- 不要输出 markdown。
</output_principles>
<output_examples>
<example_memory_ops>
{
"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"
}
}
</example_memory_ops>
<example_learning>
{
"memoryOps": [],
"decision": {
"summary": "先补充当前所需的技能说明",
"reason": "我已经知道任务要继续推进,但还缺少相关 skill 的使用细节,继续猜测风险较高。",
"expectedOutcome": "获得足够的技能说明后,我可以决定是否调用工具。"
},
"nextAction": {
"action": "learning",
"wantLearnSkillIds": ["docx-read"]
}
}
</example_learning>
<example_hand>
{
"memoryOps": [],
"decision": {
"summary": "调用工具获取完成任务所需的直接证据",
"reason": "当前缺的是可验证的结果,而不是额外的解释。",
"expectedOutcome": "拿到新的工具结果后,我可以更新记忆并判断是否收敛。"
},
"nextAction": {
"action": "hand",
"parallelCallToolParam": [
{
"toolName": "kb_hybrid_search_chunks",
"args": {
"query": "项目风险控制要求"
}
}
]
}
}
</example_hand>
<example_mouth>
{
"memoryOps": [],
"decision": {
"summary": "当前已经可以结束任务",
"reason": "现有证据已经足够支持最终结论,没有必要继续扩展动作。",
"expectedOutcome": "由后续表达节点整理出最终结果。"
},
"nextAction": {
"action": "mouth",
"status": "completed"
}
}
</example_mouth>
</output_examples>
</system>
`.trim(),
user: `
+28 -5
View File
@@ -6,17 +6,20 @@ export const buildHumanMouthPrompt = (input: HumanMouthPromptInput) => {
<system>
<role>
你现在扮演一个负责对外表达最终结果的人。
你的任务是把已经确定下来的判断、事实和证据整理成用户可直接阅读和继续使用的结果。
你的任务是把已经确定下来的判断、事实和证据,整理成用户可以直接阅读和继续使用的结果。
</role>
<working_style>
不夸大、不补编、不引入新的结论,只把已经确认的信息组织成最终交付。
请像一个表达清晰、判断克制的人一样输出结果:
不夸大、不补编、不引入新的结论,
只把已经确认的信息组织成最终交付。
</working_style>
<delivery_principles>
1. completed 时先直接给出结果,再给关键依据。
2. blocked 时先说明当前已完成到哪里,再说明阻塞原因和继续需要什么。
3. 只使用已给出的事实、证据和最终判断。
1. 如果任务已经完成,先直接给出结论,再给关键依据,再给结果或产物说明。
2. 如果任务当前阻塞,先说明已经完成到哪里,再说明阻塞原因,再说明继续需要什么。
3. 只使用已经给你的事实、证据和最终判断,不要偷偷补充新的推理。
4. 语言要清楚、克制、可执行,不要堆内部过程。
</delivery_principles>
<output_rules>
@@ -28,7 +31,27 @@ export const buildHumanMouthPrompt = (input: HumanMouthPromptInput) => {
"summary": "string",
"deliveryMarkdown": "string"
}
字段解释:
- status:字符串枚举。表示任务最终状态,只能是 completed 或 blocked。
- summary:字符串。面向用户的一句简洁结果摘要。
- deliveryMarkdown:字符串。最终正式交付内容,必须是完整 markdown 正文。
</output_rules>
<output_principles>
- 不要引入新的事实、依据、步骤或承诺。
- 不要暴露内部字段名、节点名或系统术语。
- 如果依据不足,就保持保守表达,不要写成确定结论。
- deliveryMarkdown 必须是完整结果,而不是零散句子。
</output_principles>
<output_example>
{
"status": "completed",
"summary": "已完成文档修改,并整理出关键依据。",
"deliveryMarkdown": "# 处理结果\\n\\n已完成本次修改。\\n\\n## 关键依据\\n- 依据一\\n- 依据二\\n\\n## 结果说明\\n- 结果一\\n- 结果二"
}
</output_example>
</system>
`.trim(),
user: `
@@ -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<HumanTaskInput> = 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()),
@@ -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<typeof docxCapability.buildTools>[0]) =>
docxCapability
.buildTools(input)
.filter((tool) => DOCX_DELIVERY_TOOL_NAMES.has(String(tool.name || '')))
@@ -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<typeof docxCapability.buildTools>[0]) =>
docxCapability.buildTools(input).filter((tool) => DOCX_READ_TOOL_NAMES.has(String(tool.name || '')))
@@ -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<typeof docxCapability.buildTools>[0]) =>
docxCapability.buildTools(input).filter((tool) => DOCX_WRITE_TOOL_NAMES.has(String(tool.name || '')))
@@ -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,
@@ -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<CapabilityToolFamily['buildTools']>[0]['ctx']
ctx: Parameters<ToolFamilyDefinition['buildTools']>[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) {
@@ -1,8 +0,0 @@
import { localKbRetrievalCapability } from '../local_kb_retrieval'
export const buildKbDocumentDiscoveryTools = (
input: Parameters<typeof localKbRetrievalCapability.buildTools>[0]
) =>
localKbRetrievalCapability
.buildTools(input)
.filter((tool) => String(tool.name || '') === 'kb_list_documents')
@@ -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<typeof localKbRetrievalCapability.buildTools>[0]
) =>
localKbRetrievalCapability
.buildTools(input)
.filter((tool) => KB_EVIDENCE_TOOL_NAMES.has(String(tool.name || '')))
@@ -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 })
}
@@ -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 })
}
@@ -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) {
+16 -11
View File
@@ -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<string, AiToolCapability>
toolToFamily: Record<string, AiToolFamilyId>
}
@@ -1,4 +0,0 @@
import { terminalExecCapability } from '../terminal_exec'
export const buildWorkspaceCliTools = (input: Parameters<typeof terminalExecCapability.buildTools>[0]) =>
terminalExecCapability.buildTools(input)
+4 -1
View File
@@ -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<Record<RuntimeSkillId, StructuredToolInterface[]>>
familyToolsets: Partial<Record<AiToolFamilyId, StructuredToolInterface[]>>
skillToolsets: Partial<Record<RuntimeSkillId, StructuredToolInterface[]>>
toolCallCount: number
}
+4 -11
View File
@@ -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 {
+6 -19
View File
@@ -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',
+23 -31
View File
@@ -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 = <T extends JsonObject>(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<ChatSessionMemory> = 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<JsonObject>(text)
const memory = normalizeMemory(json)
}),
temperature: 0.2
})
)
await input.chatStore.saveSessionMemory(sessionId, memory)
}
+15 -27
View File
@@ -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 = <T extends JsonObject>(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 <T extends JsonObject>(input): Promise<T> => {
const chatStructured: AiRunContext['chatStructured'] = async <T extends Record<string, unknown>>(
input
): Promise<T> => {
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<T>(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,
+153
View File
@@ -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<T extends Record<string, unknown>> = {
caller: string
cfg: LLMConfig
schema: z.ZodType<T>
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 <T extends Record<string, unknown>>(
input: StructuredOutputInvocationInput<T>
): Promise<T> => {
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('; ')
)
}
+5 -2
View File
@@ -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: <T extends JsonObject = JsonObject>(input: {
chatStructured: <T extends Record<string, unknown>>(input: {
caller: string
system: string
user: string
schema: z.ZodType<T>
schemaName: string
temperature?: number
}) => Promise<T>
sendEvent: (event: AiRunEvent) => void
@@ -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([])
})
})
@@ -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('<memory_snapshot>')
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('<final_decision>')
expect(prompt.user).toContain('结束状态completed')
expect(prompt.user).not.toContain('<recent_decision_history>')
})
})
@@ -23,7 +23,7 @@ defineAiMainCase({
runMode: 'repeatable',
execute: async (runtime) => {
const ctx = runtime.createAiRunContext({
chatJsonOverride: async () => ({
chatStructuredOverride: async () => ({
tasks: [
{
id: 'task-1',
@@ -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'])
})
})
+2 -2
View File
@@ -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'
+14 -31
View File
@@ -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<string, unknown>
}) => TestToolExecutionOverride['result'] | undefined
const buildDocumentsStoreStub = (overrides: TestDocumentsStoreOverrides | undefined) => ({
@@ -172,20 +170,6 @@ const translateTestReasonShortcut = (output: TestHumanReasonShortcut): LastBrain
}
}
const parseJsonObject = <T extends JsonObject>(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 <T extends JsonObject>(payload): Promise<T> => {
if (input.chatJsonOverride) {
return input.chatJsonOverride<T>(payload)
chatStructured: async <T extends Record<string, unknown>>(payload): Promise<T> => {
if (input.chatStructuredOverride) {
return input.chatStructuredOverride<T>(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<T>(text)
},
sendEvent: (event: Parameters<AiRunContext['sendEvent']>[0]) => {
input.eventCollector.add(event)
+5 -2
View File
@@ -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?: <T extends JsonObject>(input: {
chatStructuredOverride?: <T extends Record<string, unknown>>(input: {
caller: string
system: string
user: string
schema: z.ZodType<T>
schemaName: string
temperature?: number
}) => Promise<T>
historyOverride?: {