Refactor AI structured output and tool family loading
This commit is contained in:
1 parent
65e878dbbd
commit
b85d3c2f50
49 files changed
+1037
-560
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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: `
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
File renamed without changes.
@@ -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 })
|
||||
}
|
||||
+3
-3
@@ -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) {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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('; ')
|
||||
)
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in new issue
Block a user