mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
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:
@@ -166,23 +166,29 @@ export function makeHooks(
|
||||
// 都给一次重试机会;WorkflowAbortedError(kill)不重试——是用户意图。
|
||||
// 重试仍失败:dead 保持 dead;throw 降级为 dead(不让一个 agent 击穿 workflow)。
|
||||
// budget 不重复扣:dead 不 addOutputTokens;重试 ok 才扣一次(最终 ok 时)。
|
||||
// dead.reason 透传到日志(审计 8/12 dead 都是 no-structured-output 时直接可见)。
|
||||
// dead.reason 透传到日志:no-structured-output(agent 最终文本块没产 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,3 +308,80 @@ test('extractStructuredOutput:合法 JSON 提取;非法返回 null', () => {
|
||||
).toBeNull()
|
||||
expect(extractStructuredOutput([])).toBeNull()
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:fenced code block(剥围栏 + 剥语言标签)', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here are the findings:\n```json\n{"findings":[{"title":"x"}]}\n```\nDone.',
|
||||
},
|
||||
]),
|
||||
).toEqual({ findings: [{ title: 'x' }] })
|
||||
// 无语言标签
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '```\n{"a":1}\n```' }]),
|
||||
).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:嵌套对象(括号平衡扫描,原版 indexOf/lastIndexOf 会跨块拼接)', () => {
|
||||
const text = 'Result: {"outer":{"inner":{"deep":true}},"n":3} trailing'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({
|
||||
outer: { inner: { deep: true } },
|
||||
n: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:字符串里的括号不当配对计', () => {
|
||||
// 字符串内的 } 不会让 depth 归零,扫描能跳到真正的配对 }
|
||||
const text = '{"note":"this } char is in a string","ok":true}'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({
|
||||
note: 'this } char is in a string',
|
||||
ok: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:转义引号不破字符串边界', () => {
|
||||
const text = '{"escaped":"he said \\"hi\\"","n":1}'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({
|
||||
escaped: 'he said "hi"',
|
||||
n: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:多个 JSON 块 → 返回第一个 parse 成功的', () => {
|
||||
// 第一个不平衡(无配对 }),跳到第二个
|
||||
const text = 'broken { stuff\n{"real":1}\n{"ignored":2}'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({ real: 1 })
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:array / number / string / null 不算 object', () => {
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '[1,2,3]' }]),
|
||||
).toBeNull()
|
||||
expect(extractStructuredOutput([{ type: 'text', text: '42' }])).toBeNull()
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '"raw string"' }]),
|
||||
).toBeNull()
|
||||
expect(extractStructuredOutput([{ type: 'text', text: 'null' }])).toBeNull()
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:多 text block → 跨块找第一个成功', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{ type: 'text', text: 'no json' },
|
||||
{ type: 'text', text: '```json\n{"k":"v"}\n```' },
|
||||
]),
|
||||
).toEqual({ k: 'v' })
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:损坏 JSON 返回 null(不抛)', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{ type: 'text', text: '{broken: missing quotes}' },
|
||||
]),
|
||||
).toBeNull()
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '{"a":1,}' }]), // 尾逗号——不做语法修复
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
@@ -62,27 +62,92 @@ export function mapWorkflowModel(
|
||||
return model
|
||||
}
|
||||
|
||||
/** 从 agent 最终消息中提取 StructuredOutput 产出的 JSON 对象;失败返回 null。已导出便于单测。 */
|
||||
/**
|
||||
* 从 agent 最终消息中提取 schema 模式产出的 JSON 对象;失败返回 null。已导出便于单测。
|
||||
*
|
||||
* 鲁棒性策略(按优先级,第一个 parse 成功的返回):
|
||||
* 1. fenced code block(```json ... ``` 或 ``` ... ```)—— agent 常自发加围栏
|
||||
* 2. 裸文本里第一个"括号平衡"的 {...} 片段—— 处理前后叙述 / 多段输出
|
||||
*
|
||||
* 用括号栈扫描而非 `indexOf('{')..lastIndexOf('}')`:能正确处理嵌套对象、
|
||||
* 字符串字面量内的 `{}`、转义字符。不会跨多个不相关 JSON 拼接(原版会)。
|
||||
*
|
||||
* 不做语法修复(尾逗号、单引号→双引号、注释删除)—— agent 不会产非标 JSON,
|
||||
* 修了反而可能在字符串内误改(如 `"http://..."` 被 // 注释正则吃掉)。
|
||||
* parse 失败直接 skip 到下一个候选。
|
||||
*
|
||||
* 只返回 plain object(typeof === 'object' && !null && !Array);
|
||||
* schema 模式契约是 object,array/number/string 一律视为 agent 跑题。
|
||||
*/
|
||||
export function extractStructuredOutput(
|
||||
content: Array<{ type: string; text?: string }>,
|
||||
): unknown | null {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
const trimmed = block.text.trim()
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(start, end + 1))
|
||||
} catch {
|
||||
// 继续尝试下一个文本块
|
||||
}
|
||||
}
|
||||
}
|
||||
if (block.type !== 'text' || !block.text) continue
|
||||
const found = findFirstJsonObject(block.text)
|
||||
if (found !== null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 在 text 中找第一个能 parse 成 plain object 的 JSON 片段。 */
|
||||
function findFirstJsonObject(text: string): unknown | null {
|
||||
// 1. fenced code blocks——优先(agent 自然倾向,剥围栏后 parse 整块)
|
||||
for (const m of text.matchAll(
|
||||
/```[\t ]*[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```/g,
|
||||
)) {
|
||||
const parsed = tryParseObject(m[1] ?? '')
|
||||
if (parsed !== null) return parsed
|
||||
}
|
||||
// 2. 裸文本:扫每个 '{',找平衡配对后 try parse
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '{') continue
|
||||
const end = findBalancedObjectEnd(text, i)
|
||||
if (end < 0) continue
|
||||
const parsed = tryParseObject(text.slice(i, end + 1))
|
||||
if (parsed !== null) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 start(必须是 `{`)开始找配对的 `}` 索引;不平衡返 -1。
|
||||
* 跳过字符串字面量内的括号、转义字符。不做注释跳过(JSON 标准不允许注释,
|
||||
* agent 不会产;做了反而风险——见函数 doc)。
|
||||
*/
|
||||
function findBalancedObjectEnd(text: string, start: number): number {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
if (inString) {
|
||||
if (c === '\\')
|
||||
i++ // 跳过转义符和下一个字符
|
||||
else if (c === '"') inString = false
|
||||
continue
|
||||
}
|
||||
if (c === '"') inString = true
|
||||
else if (c === '{') depth++
|
||||
else if (c === '}') {
|
||||
depth--
|
||||
if (depth === 0) return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** try parse candidate;只有 plain object 才返回,其它(array/number/null)返 null。 */
|
||||
function tryParseObject(candidate: string): unknown | null {
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null
|
||||
try {
|
||||
const v = JSON.parse(trimmed)
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowWorktreeInfo = Awaited<ReturnType<typeof createAgentWorktree>>
|
||||
|
||||
/**
|
||||
@@ -198,27 +263,26 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
appState.mcp.tools,
|
||||
)
|
||||
|
||||
// schema → prompt 首尾各放一份 StructuredOutput 强制要求(sonnet 长 tool chain 后
|
||||
// 易忘记收尾,是 8/12 dead 的主因)。原版只在尾部追加,sonnet 跑到第 N 个工具时
|
||||
// 早就把"必须调 StructuredOutput"挤出注意力了。新版:头部放任务上下文 + 收尾契约,
|
||||
// 尾部再强制提醒一次,让 agent 任何时刻调头都能看到收尾要求。
|
||||
// schema → 指示 agent 在最后文本块里直接 emit JSON。
|
||||
// 不要求调 StructuredOutput 工具——它不在 workflow sub-agent 的工具集里(只有
|
||||
// stop_hook 路径显式注入;workflow 走 assembleToolPool 默认池不含)。
|
||||
// 历史上 prompt 要求"call StructuredOutput tool"导致 8/12 agent 拒绝收尾/纠结调用,
|
||||
// 实测 dead 主因是工具不可达而非"忘记"。改契约:raw JSON 文本,extractStructuredOutput
|
||||
// 容错 fenced 围栏 + 前后叙述 + 多段。
|
||||
const promptText = params.schema
|
||||
? [
|
||||
'[STRUCTURED OUTPUT MODE — read before starting]',
|
||||
'Your ENTIRE final response MUST be a single call to the `StructuredOutput` tool with a value matching this JSON Schema:',
|
||||
JSON.stringify(params.schema),
|
||||
'',
|
||||
'Rules:',
|
||||
'- Call `StructuredOutput` exactly once as your LAST action.',
|
||||
'- NEVER end your turn with plain text. If you have not called the tool, your entire response is discarded and the workflow sees no result.',
|
||||
'- If you need to investigate first (read files, run tests), do so via other tools, then finish with `StructuredOutput`.',
|
||||
'',
|
||||
'--- task ---',
|
||||
params.prompt,
|
||||
'',
|
||||
'--- end task ---',
|
||||
'After completing the task, emit your final answer as a single JSON object matching this JSON Schema:',
|
||||
'```json',
|
||||
JSON.stringify(params.schema, null, 2),
|
||||
'```',
|
||||
'',
|
||||
'[FINAL REMINDER] Before stopping: verify you have called `StructuredOutput`. If not, call it now with your conclusion. Plain-text endings are treated as failure.',
|
||||
'CRITICAL RULES:',
|
||||
'- The JSON object must be the LAST text block in your response. Do not write any prose after it.',
|
||||
'- Emit the JSON as plain text (markdown code fences optional).',
|
||||
'- Do NOT call any "StructuredOutput" or "SyntheticOutput" tool — it is not available in this environment.',
|
||||
'- Your turn must end with the JSON object. Anything after it (prose, tool calls) will be ignored or cause your answer to be discarded.',
|
||||
].join('\n')
|
||||
: params.prompt
|
||||
|
||||
@@ -307,15 +371,15 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
if (params.schema) {
|
||||
const structured = extractStructuredOutput(finalized.content)
|
||||
if (structured === null) {
|
||||
// agent 跑完所有工具调用但既没调 StructuredOutput 工具、也没在文本里产 JSON。
|
||||
// agent 跑完所有工具调用但最终文本块里没找到 plain-object JSON。
|
||||
// 典型场景:长 tool chain 后忘记 emit JSON、JSON 嵌套不平衡、parse 失败。
|
||||
// 把最后文本预览进 detail,让 hooks 重试日志和面板能立刻看到 agent 实际说了什么。
|
||||
// 8/12 dead 在最近一次 audit workflow 都落这里——sonnet 长 tool chain 后忘了收尾。
|
||||
const preview = extractTextContent(finalized.content, '\n').slice(
|
||||
0,
|
||||
200,
|
||||
)
|
||||
logForDebugging(
|
||||
`workflow sub-agent produced no StructuredOutput (${agentDef.agentType}); preview: ${preview}`,
|
||||
`workflow sub-agent produced no JSON object (${agentDef.agentType}); preview: ${preview}`,
|
||||
)
|
||||
return {
|
||||
kind: 'dead',
|
||||
|
||||
Reference in New Issue
Block a user