fix(workflow): schema 模式弃用 StructuredOutput 工具契约,改鲁棒 JSON 文本解析

上一轮 70a2f76 把"agent 长 tool chain 后忘调 StructuredOutput"当作死因,
加 prompt 头尾双强制。但实测跑 5 个 review agent 4 个 dead,detail 全是
"StructuredOutput tool is not available as a deferred tool"——根因是
该工具从未注入 workflow sub-agent 的工具集(assembleToolPool 默认池不含,
只有 stop_hook 路径 execAgentHook.ts 显式 createStructuredOutputTool())。
prompt 反复要求调一个不可达的工具,agent 困扰、长篇辩解、最终没产 JSON。

- claudeCodeBackend.ts:
  - extractStructuredOutput 重写:括号栈扫描替代 indexOf/lastIndexOf,
    处理嵌套对象、字符串内的括号、转义符;新增 fenced code block
    优先路径(```json / ```),多 JSON 块取第一个 parse 成功的;
    只返回 plain object(拒 array/number/string/null)。不做语法修复
    (尾逗号/单引号/注释)——避免在字符串内误改(如 "http://" 被 // 注释正则吃)。
  - schema 模式 prompt 简化:删首尾双 STRUCTURED OUTPUT 强制(600+ token),
    改成指示 agent 在最后文本块 emit raw JSON;明确告知"StructuredOutput
    is not available in this environment",消除调用幻觉。
- hooks.ts: detail.slice 用 typeof === 'string' 守卫;catch 块用
  e instanceof Error ? e.message : String(e)(旧 journal / 第三方 adapter
  可能写非 string detail,直接 .slice 会抛 TypeError 击穿日志)。
- claudeCodeBackend.test.ts: +9 测试覆盖 fenced / 嵌套 / 字符串内括号 /
  转义引号 / 多块取首 / 类型守卫 / 损坏 JSON。

precheck: 5663 pass / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 13:25:13 +08:00
parent 70a2f76a25
commit 35fc2567ed
3 changed files with 183 additions and 36 deletions

View File

@@ -166,23 +166,29 @@ export function makeHooks(
// 都给一次重试机会WorkflowAbortedErrorkill不重试——是用户意图。
// 重试仍失败dead 保持 deadthrow 降级为 dead不让一个 agent 击穿 workflow
// budget 不重复扣dead 不 addOutputTokens重试 ok 才扣一次(最终 ok 时)。
// dead.reason 透传到日志(审计 8/12 dead 都是 no-structured-output 时直接可见)。
// dead.reason 透传到日志no-structured-outputagent 最终文本块没产 plain-object JSON
// 是高频死因detail 进日志能立刻看到 agent 最后说了什么。
// detail 用 String() 包裹防御:旧 journal 或第三方 adapter 可能写入非 string损坏数据
// 直接 .slice 会抛 TypeError 击穿日志路径。
let result: AgentRunResult
try {
result = await invokeBackend()
if (result.kind === 'dead') {
const detailStr =
typeof result.detail === 'string' ? result.detail : ''
ctx.ports.logger.warn?.(
`agent "${label ?? `#${agentId}`}" returned dead` +
(result.reason ? ` (${result.reason})` : '') +
(result.detail ? `: ${result.detail.slice(0, 150)}` : '') +
(detailStr ? `: ${detailStr.slice(0, 150)}` : '') +
'; retrying once',
)
result = await invokeBackend()
}
} catch (e) {
if (e instanceof WorkflowAbortedError) throw e
const eMsg = e instanceof Error ? e.message : String(e)
ctx.ports.logger.warn?.(
`agent "${label ?? `#${agentId}`}" threw (${(e as Error).message}); retrying once`,
`agent "${label ?? `#${agentId}`}" threw (${eMsg}); retrying once`,
)
try {
result = await invokeBackend()
@@ -192,7 +198,7 @@ export function makeHooks(
result = {
kind: 'dead',
reason: 'runagent-threw',
detail: (e2 as Error).message,
detail: e2 instanceof Error ? e2.message : String(e2),
}
}
}