Archon DAG 工作流修复实录:`output_format` 结构化输出与 `when:` 条件求值断裂问题的根因分析与修复方案

Archon DAG 工作流修复实录:`output_format` 结构化输出与 `when:` 条件求值断裂问题的根因分析与修复方案 Archon DAG 工作流修复实录output_format结构化输出与when:条件求值断裂问题的根因分析与修复方案【免费下载链接】ArchonThe first open-source harness builder for AI coding. Make AI coding deterministic and repeatable.项目地址: https://gitcode.com/GitHub_Trending/archon3/Archon导读本文以 Archon 仓库中针对 issue #497 的完整调查与修复方案.claude/PRPs/issues/issue-497.md为主线深入剖析一个会影响所有 DAG 工作流的严重缺陷当节点声明了output_formatJSON Schema后模型输出的推理散文 JSON混合文本会被无条件拼接到节点输出里导致下游when:条件中的$nodeId.output.field引用在JSON.parse时失败、条件节点被静默跳过。读完本文你将掌握该缺陷的完整证据链、三层修复方案类型承载 → SDK 字段提取 → 执行器输出覆写、对应的测试策略与边界风险并能对照当前仓库源码理解结构化输出最终是如何被规范化、校验并参与条件求值的。问题概述条件节点为何会静默跳过在 Archon 的 DAG 工作流引擎中节点可以通过output_format声明一个 JSON Schema要求模型输出符合该 Schema 的结构化结果下游节点则通过when:条件表达式如$classify.output.run_code_review true来决定是否执行。issue #497 报告的缺陷是当 DAG 节点使用output_formatJSON Schema配合 Claude Code SDK 时节点输出中会出现前置的推理散文与结构化 JSON 拼接在一起executeNodeInternal无条件拼接所有assistant文本块导致节点输出变成Ill analyze the PR...\n{\run_code_review\: \true\}这样的混合内容下游when:条件通过$nodeId.output.field引用节点输出时需要对其执行JSON.parse()而混合内容无法解析最终所有条件节点被静默跳过。原调查文档对该缺陷的评级为指标评级依据严重性SeverityHIGH任何使用output_formatwhen:条件的 DAG 工作流都会受影响条件节点静默跳过复杂度ComplexityMEDIUM仅需改动 3 个文件claude 客户端、dag-executor、condition-evaluator并补充测试数据流清晰置信度ConfidenceHIGH根因明确SDK 的structured_output字段从未被读取散文被拼入节点输出根因分析结构化输出的双通道丢失Claude Code SDK 的两种输出通道Claude Code SDK 通过两个通道提供结构化输出assistant消息文本块——同时包含推理散文与 JSON 输出result消息中的structured_output字段——仅包含经过校验的 JSON。原调查指出当时的ClaudeClient.sendQuery从未读取result消息中的structured_output字段仅提取session_id与usageWorkflowMessageChunk类型中也没有承载结构化输出的字段executeNodeInternal因而只能不加区分地拼接全部assistant文本。证据链自下而上原文档给出了从现象回溯到根因的完整证据链现象when:条件如$classify.output.run_code_review true求值为 false直接原因resolveOutputRef对节点输出调用JSON.parse()失败原文档引用packages/workflows/src/condition-evaluator.ts:38拼接来源节点输出包含Ill analyze the PR...\n{\run_code_review\: \true\}dag-executor.ts:652处nodeOutputText msg.content对所有assistant块无条件累加散文来源Claude SDK 在 JSON 块之前以assistant文本块形式输出推理散文原文档引用packages/core/src/clients/claude.ts:290-292根因SDKresult上的structured_output字段从未被读取原文档引用claude.ts:300-311仅提取session_id与usage且 SDK 类型定义node_modules/anthropic-ai/claude-agent-sdk/sdk.d.ts:1556中structured_output?: unknown存在但未被使用当前仓库中的对应实现在当今仓库中结构化输出的正常化链路已经存在于提供者抽象层MessageChunk的result变体在 packages/providers/src/types.ts 中已包含structuredOutput?: unknown字段与sessionId、tokens、isError、cost、stopReason、resumed等一并承载Claude 提供者在 packages/providers/src/claude/provider.ts 中通过条件展开structured_output in resultMsg resultMsg.structured_output ! undefined将 SDK 结果转发为structuredOutput。这正是原修复方案落地后的形态——调查文档中要求新增的类型字段与提取逻辑如今在提供者契约层已经实现。受影响文件与修复方案受影响文件清单原文档给出需要修改的文件及其作用文件行号动作说明packages/workflows/src/deps.ts28UPDATE为WorkflowMessageChunk的result变体增加structuredOutputpackages/core/src/clients/claude.ts300-311UPDATE从 SDK result 提取structured_output并转发packages/workflows/src/dag-executor.ts637-710UPDATE使用 result 消息中的structuredOutput覆写nodeOutputTextpackages/workflows/src/dag-executor.test.tsNEWUPDATE为executeNodeInternal增加结构化输出提取测试packages/workflows/src/condition-evaluator.test.tsNEWUPDATE增加散文JSON 混合场景测试集成点全景修复牵涉的数据流贯穿以下关键函数ClaudeClient.sendQuery——向所有调用方产出WorkflowMessageChunkexecuteNodeInternal——消费 chunk、构建NodeOutputsubstituteNodeOutputRefs——读取NodeOutput.output用于提示词替换resolveOutputRef——读取NodeOutput.output用于条件求值executor.ts的 sequential/loop 执行器——同样消费WorkflowMessageChunk但不使用output_formatGit 历史中的引入点DAG 引擎引入a315617——feat: DAG workflow engine with parallel execution and conditional branching (#450)output_format 接线4204e1f——Archon orchestrator (#452)推论structured_output从最初实现起就从未被读取该缺陷属于从初始实现即存在的历史遗留问题。分步修复实施计划Step 1为WorkflowMessageChunk增加structuredOutput改动点packages/workflows/src/deps.ts第 28 行。改动前| { type: result; sessionId?: string; tokens?: WorkflowTokenUsage }改动后| { type: result; sessionId?: string; tokens?: WorkflowTokenUsage; structuredOutput?: unknown }目的让WorkflowMessageChunk类型能够承载来自 SDK 的结构化输出并将其一路传递到 DAG 执行器。需要说明的是随着代码演进这一职责如今由提供者契约层统一承担MessageChunk的result变体packages/providers/src/types.ts直接声明了structuredOutput?: unknown工作流引擎通过deps.ts重导出该类型无需再维护镜像副本。Step 2在 Claude 客户端提取structured_output改动点packages/core/src/clients/claude.ts第 300-311 行。改动前} else if (msg.type result) { const resultMsg msg as { session_id?: string; usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }; }; const tokens normalizeClaudeUsage(resultMsg.usage); yield { type: result, sessionId: resultMsg.session_id, ...(tokens ? { tokens } : {}), }; }改动后} else if (msg.type result) { const resultMsg msg as { session_id?: string; usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }; structured_output?: unknown; }; const tokens normalizeClaudeUsage(resultMsg.usage); yield { type: result, sessionId: resultMsg.session_id, ...(tokens ? { tokens } : {}), ...(resultMsg.structured_output ! undefined ? { structuredOutput: resultMsg.structured_output } : {}), }; }目的把 SDK 的structured_output字段经WorkflowMessageChunk流水线转发出去。注意这里沿用了代码库中条件展开 SDK 字段的既有模式——在原文档引用的claude.ts:244-247中outputFormat的转发同样使用了...(requestOptions?.outputFormat ! undefined ? { outputFormat: requestOptions.outputFormat } : {})的写法本次改动与之保持完全一致。Step 3在 DAG 执行器用structuredOutput覆写nodeOutputText改动点packages/workflows/src/dag-executor.ts第 637-710 行executeNodeInternal内部。在消息处理循环中捕获 result 消息中的structuredOutput循环结束后若存在结构化输出且节点声明了output_format则以 JSON 序列化后的结构化输出作为节点输出替代拼接文本。改动前result 处理约 660-666 行} else if (msg.type result) { if (msg.sessionId) newSessionId msg.sessionId; if (msg.tokens) nodeTokens msg.tokens; }改动后} else if (msg.type result) { if (msg.sessionId) newSessionId msg.sessionId; if (msg.tokens) nodeTokens msg.tokens; if (msg.structuredOutput ! undefined) structuredOutput msg.structuredOutput; }同时在nodeOutputText附近增加变量声明let structuredOutput: unknown;循环结束约第 706 行 return 之前增加覆写逻辑// When output_format is set and the SDK returned structured_output, // use it instead of the concatenated assistant text (which includes prose) if (structuredOutput ! undefined nodeOptions?.outputFormat) { nodeOutputText typeof structuredOutput string ? structuredOutput : JSON.stringify(structuredOutput); }目的这是最小化修复——使用 SDK 专用的结构化输出字段仅含校验后的 JSON替换散文JSON的混合拼接。散文仍然流式展示给用户但节点存储的输出是干净 JSON供下游条件使用。当前仓库中的落地形态dag-executor.ts的executeNodeInternal消息处理循环中dag-executor.ts 附近已实现if (msg.structuredOutput ! undefined) structuredOutput msg.structuredOutput并在循环结束后执行结构化输出覆写约 dag-executor.ts仅当nodeOptions?.outputFormat存在时才进入该分支对结构化输出执行validateStructuredOutput模式校验每个提供者都会校验SDK 强校验的场景同样不例外——因为模型拒绝请求或max_tokens截断时SDK 强制校验也可能产出不符合语法约束的解码结果校验通过后用canonicalValueText(structuredOutput)序列化为规范 JSON 文本并覆写nodeOutputText记录dag.structured_output_override日志校验失败且仍有重问配额reaskAttempt maxReasks且未空闲超时/未中止时调用scheduleReask重新询问无配额则抛出明确错误如 schema 无法编译时抛dag.structured_output_schema_uncompilable。这比原文档的最小修复更进一步如今不仅使用干净 JSON 覆写还引入了 schema 校验与重问机制保证写入节点输出的结构化数据一定符合声明契约。Step 4补充测试文件packages/workflows/src/dag-executor.test.tsdescribe(executeNodeInternal with output_format, () { it(uses structuredOutput from result when output_format is set, async () { // Mock AI client that yields prose JSON as assistant chunks, // then a result with structuredOutput const mockClient { *sendQuery() { yield { type: assistant, content: Let me analyze...\n }; yield { type: assistant, content: {type: BUG} }; yield { type: result, sessionId: sid, structuredOutput: { type: BUG } }; }, }; // ... execute node with output_format set ... // Assert: output is {type:BUG} (from structuredOutput), not Let me analyze...\n{type:BUG} }); it(falls back to concatenated text when structuredOutput is absent, async () { // Mock AI client without structuredOutput on result // Assert: output is the full concatenated text (backward compatible) }); });文件packages/workflows/src/condition-evaluator.test.tsit(dot notation works with clean structured output (not proseJSON), () { // Simulates the fixed behavior where output is clean JSON const outputs new Map([[classify, makeOutput(JSON.stringify({ run_code_review: true }))]]); expect(evaluateCondition($classify.output.run_code_review true, outputs).result).toBe(true); });第一个用例验证散文JSON 混合输入下输出来自structuredOutput第二个用例验证无结构化输出时回退到拼接文本保证向后兼容。Step 5确认substituteNodeOutputRefs自动受益文件packages/workflows/src/dag-executor.ts第 325-350 行动作无需修改。substituteNodeOutputRefs读取的是NodeOutput.outputStep 3 之后该字段在声明output_format时已是干净 JSON替换函数本身无需任何改动。条件求值的静默失败与 no-silent-drop 契约要真正理解这个缺陷为何危险需要了解when:条件求值的设计哲学。当前仓库的 condition-evaluator.ts 中明确定义了两种错误模式表达式语法错误坏的语法→ fail-closed结果返回false跳过节点parsed: false引用无法解析→抛出异常并传播使节点失败在 no-silent-drop 契约下被引用但缺失的值必须是可见的失败而不是静默跳过。resolveOutputRefcondition-evaluator.ts对.field引用走resolveNodeOutputField严格路径字段不在产出节点的声明 schema 中、无 schema 节点的输出不是 JSON 或缺少该键、产出节点失败、或字段值为对象/数组都会抛出OutputRefError使消费节点失败。而 issue #497 描述的场景是静默跳过——JSON.parse抛错或求值为 false 不会传播导致工作流在用户无感知的情况下跳过整个条件分支。这正是该缺陷被评级为 HIGH 的原因错误路径不可见故障排查极其困难。值得补充的是when:原子语法由 when-atom.ts 统一定义运行时求值condition-evaluator与加载时校验loader共享同一文法两者永远不会对原子含义产生分歧。文法支持字符串相等$nodeId.output VALUE/!点号字段$nodeId.output.field VALUE及简写$nodeId.field命名输入$INPUTS.mode fast数值运算 两侧必须可解析为有限数值否则 fail-closed无引号右值$nodeId.exit_code 0、$nodeId.passed true复合 AND/OR优先级高于||不支持括号边界情况与风险缓解原文档对修复方案可能引入的风险逐一给出了缓解策略风险 / 边界情况缓解措施SDK 不填充structured_output回退保持nodeOutputText原样向后兼容structured_output本身是字符串同时处理字符串与对象仅对非字符串执行 stringifystructured_output为null专门检查! undefinednull 是合法 JSON非 Claude 提供者如 Codex使用output_formatCodex 本就以警告方式跳过output_format无影响sequential/loop 执行器也收到 result chunk它们不使用output_format或nodeOutputText无影响散文仍流式展示给用户期望行为nodeOutputText累加继续用于流式展示仅最终值被替换此外在提供者能力契约层面当前仓库的 providers/src/types.ts 定义了structuredOutput: enforced | best-effort | false这一能力档位SDK 强校验的提供者与尽力而为的提供者被明确区分dag-executor在 dag-executor.ts 处也会依据getProviderCapabilities(provider).structuredOutput best-effort来决定是否需要对输出做额外兜底处理如从文本摘要中解析 JSON这从架构层面封堵了提供者不返回结构化输出这一最大不确定性。验证方案自动化检查bun run type-check bun run test bun run lint或一步到位bun run validate手工验证使用测试 PR 运行archon-smart-pr-review工作流——验证classify节点输出为干净 JSON下游when:条件求值正确运行一个不带output_format的 DAG 工作流——验证普通文本输出无回归检查日志中不存在condition_json_parse_failed警告。修复范围边界IN SCOPE范围内将 Claude SDK result 的structured_output经WorkflowMessageChunk转发当存在结构化输出且声明output_format时用其覆写nodeOutputText为新行为补充测试。OUT OF SCOPE明确不触碰substituteNodeOutputRefs与resolveOutputRef的 JSON 解析逻辑干净 JSON 下它们工作正常Codex 客户端或其他非 Claude 提供者sequential/loop 执行器的输出处理工作流加载器对output_formatschema 结构的校验任何从混合文本中回退提取 JSON 的逻辑有了正确的 SDK 字段这没有必要。小结从缺陷报告到架构契约issue #497 的修复方案展示了一个教科书式的调试与修复闭环从条件节点静默跳过这一表象出发通过逐层证据链条件求值 → 输出拼接 → SDK 文本块 →structured_output未被读取定位根因再以类型承载 SDK 字段提取 执行器覆写三层最小改动完成修复并用双向测试有结构化输出走新路径、无结构化输出走旧路径保证兼容性。在今天的仓库中这一修复已演化为更完整的契约提供者层统一声明structuredOutput能力档位执行器在覆写前执行 schema 校验并可自动重问确保when:条件永远基于干净、合规的结构化数据求值——这正是 DAG 工作流确定性、可重复make AI coding deterministic and repeatable这一项目核心理念在条件分支上的具体落地。【免费下载链接】ArchonThe first open-source harness builder for AI coding. Make AI coding deterministic and repeatable.项目地址: https://gitcode.com/GitHub_Trending/archon3/Archon创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考