DeepSeek Harness 中 Code Mode 结果卡片的完整性:run_code 输出与返回值的单一来源渲染
本文基于 DeepSeek Harness 仓库中归档的 Bug 修复记录《保证 Code Mode 结果卡片内容完整》2026-07-20-code-mode-result-card-completeness.zh.md 展开。它解释了 run_code(Code Mode / PTC mode)工具在“程序打印了日志”时,返回值为何会从 UI 完成态卡片中消失,以及项目如何把最终内容的唯一来源收归到规范的工具注册表流水线。读完后,你将掌握 run_code 结果的完整渲染链路、presentResult 缺席的设计意图,以及嵌套 Code 调用“只留事件、不生成卡片”的边界约定。
背景:Code Mode 下的 run_code 与内容分工
DeepSeek Harness 的工具注册表支持三种呈现模式(native / ptc / both),由 agent-tool-presentation 插件通过 ctx.tools.presentAs() 声明:ptc 模式下模型只能看到 run_code 加一段自动生成的 SDK 声明段,程序以 await tools.name(args) 的形式调用宿主注册的全部工具。packages/core/agent-tool-presentation/src/index.ts 与模式矩阵测试 packages/core/tools/tests/ptc.spec.ts 固定了这一贡献规则。
run_code 的传输层实现在 packages/core/tools/src/ptc.ts,其输出契约是:
/** 外层 PTC 传输的规范输出。 */
type RunCodeOutput = { logs: string[]; result?: JsonValue }
即一次运行只向模型暴露两类东西:捕获的日志(print)和程序的返回值。工具的 output.render 决定了模型可见文本的拼装方式——先拼接全部日志,再渲染返回值(字符串原样、非字符串走无递归的 JSON 渲染),两者皆空时输出 (run_code completed with no output) 显式无输出标记。运行时失败(程序异常、预算耗尽、abort、runtime 崩溃)则以 CodeRunFailedError(错误码 CODE_RUN_FAILED)抛出,错误文本中附带已捕获的日志,交由注册表流水线转成 isError 结果供模型自我修正。
在此分工下,UI 展示侧(presenter)与注册表侧存在一条清晰的责任边界:注册表流水线负责最终面向模型的内容,UI 负责把这份持久内容投影成卡片。这条边界正是本次 Bug 的引爆点。
Bug 分析:为什么返回值会从完成态卡片中消失
原问题描述可以拆解为一条完整的因果链:
- 外层
run_code工具持久化了完整的渲染内容(日志 + 返回值的拼接文本),它随tool/result事件落盘,可回放。 - 但 UI 展示器忽略了这份持久内容,转而根据仅含日志的
presentationMeta投影重新构建卡片正文。 - 当程序只有返回值、没有日志时,展示器重建出的正文恰好为空,UI 消费方会回退去读
tool/result.content——因此这类运行“看似正确”。 - 一旦程序打印了任何一条日志,展示器就能提供非空正文,回退机制随即停止工作,返回值便从完成态卡片中消失。
- 同一条职责拆分还波及输出落盘策略:当已捕获的日志使陈旧投影变为非空时,spill 落盘最终生成的头尾预览也会基于不完整内容计算。
另一个被掩盖的问题是嵌套 Code 调用:嵌套分发从不生成自己的卡片,然而为了给外层重建这一张不完整卡片而生成元数据,恰好模糊了“每次外层 run_code 调用只生成一张卡片”的预期边界。
修复决策:让规范流水线独占最终内容
渲染顺序与错误归一化
成功路径上,run_code 输出渲染器的顺序是固定的:先渲染已捕获的日志,再渲染返回值或显式的无输出标记。源码中的 render 实现正是这一契约的落点:
render: (_args, value) => {
const rendered = value.result === undefined ? '' : renderValue(value.result)
const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
},
见 packages/core/tools/src/ptc.ts#L325-L329。失败路径则不经过该渲染器:运行时失败和执行前(pre-execute)策略拒绝由 ToolRegistry 统一归一化为错误内容;Post-execute 阻断发生在成功渲染之后,把结果替换为错误内容;其余 post-execute 策略与输出落盘(spill)决策可以在持久化之前替换内容。也就是说,无论哪条策略介入,卡片最终读取的持久内容都已经是经过完整流水线处理的结果。
移除 presentResult:通用回退成为唯一内容来源
最关键的决策是 run_code 不提供 presentResult。源码中这一意图有明确注释:
// Deliberately no presentResult: the generic card fallback keeps this
// title and reads durable result content without duplicating a large raw
// result into the host view payload.
见 packages/core/tools/src/ptc.ts#L660-L662。其含义是:
- 既有的通用结果回退机制会保留
presentCall声明的待完成程序标题(即模型撰写的description,card: 'generic'、kind: 'execute'、rawInput: args.code),并直接渲染持久化的最终tool/result.content; - 这份持久、可回放、且经过 post-policy 处理的内容,成为卡片中结果内容的唯一来源;
- 宿主 API 代理因此不再提供单独的结果视图,不会在同一帧中于
event.data.content与view.view.content里重复序列化同一份内容; - 冗余的“仅含日志”的
presentationMeta投影保持移除状态,陈旧元数据从此无法替换最终内容。
presentCall / presentResult 这套 provider 中立的展示意图词表定义在 packages/core/tools/src/presentation.ts,generic 卡片默认形态即带标题、rawInput 与内容块的普通工具行。
嵌套分发:有事件、无卡片
嵌套 Code 调用的分发链路保持不变,边界更加明确:
- 程序内每次
tools.name(args)调用都会发出tool/code-dispatch-start与tool/code-dispatch两类会话事件,携带完整渲染内容,但不生成与tool/call/tool/result对应的界面卡片; - 一次外层
run_code调用仍然只生成一张卡片;嵌套值属于“执行期间存在的中间值”,永远不面向模型。
源码证据有两处。其一是事件追加现场:packages/core/tools/src/ptc.ts#L513-L524 在子调用 settle 时以 subCallId(形如 <outerCallId>:code:n)、parentCallId 与参数快照写入 tool/code-dispatch 事件;其二是事件类型注册表 packages/core/session/src/known-event-types.ts#L63-L64 将 tool/code-dispatch 与 tool/code-dispatch-start 列为一级会话事件。客户端侧,ui-tool 包按 root 调用树渲染,Code Dispatch 子调用保留 parentCallId 并以扁平 generic 形态呈现,见 packages/client/ui-tool/README.zh.md 的“内置视图”一节;快照语料 snapshots/session/ptc-turn/session.jsonl 也完整保留了这一类事件的落盘形态。
验证:三层测试固定不变式
修复按“单元—宿主—端到端”三层固化:
- 工具单元测试(packages/core/tools/tests/ptc.spec.ts):通过规范注册表覆盖仅有日志、仅有结果、日志与结果并存、无输出、结果落盘与失败结果等情形,并固定“持久内容完整”与“结果展示器不存在”两个事实。例如“仅返回整理后输出、每次分发记录一个事件”的用例断言
result.content为日志加返回值的拼接文本('saw echo:one\necho:two'),且result.meta为undefined(packages/core/tools/tests/ptc.spec.ts#L823-L841)。 - 宿主 mux 回归测试(packages/api/session-controller/tests/event-script.client.ts):使用仅有调用的展示器,证明结果帧恰好携带一次原始内容且不含视图——即陈旧元数据无法替换最终内容,宿主也不重复该内容。
- 端到端快照:无密钥的 ACP 后端快照与 TUI Code Mode 快照会执行一个外层程序——两次嵌套 bash 调用、记录
captured output、并返回CODE_ONE+CODE_TWO。ACP 持久化日志固定了完整结果;TUI 界面只显示一张完成态外层卡片,内含这两行内容且没有嵌套卡片。会话级语料可参见 snapshots/session/ptc-turn/session.jsonl。
被否决的备选方案
原决策记录完整列出了四条被否决的路线,其否决理由同样值得保留为设计依据:
| 备选方案 | 否决理由 |
|---|---|
| 把返回值追加到日志元数据 | 元数据会与渲染器重复;需要为每种 JSON 根另行维护稳定的格式化契约;post-policy 内容替换或输出落盘预览仍会被遗漏 |
把展示元数据与 result.content 合并 |
渲染内容已包含日志,合并会造成重复,且需依赖脆弱的去重逻辑 |
通过通用结果展示器转发 result.content |
持久事件已携带该内容,UI 消费方已有通用的原始内容回退;转发只是为重建回退机制,却会在一个帧中重复渲染内容;仅默认 worker 在渲染前允许 64 MiB 的可变载荷预算 |
| 为每次嵌套分发创建一张卡片 | 中间值有意只存在于执行期间、永不面向模型;多张卡片会暴露实现轨迹,而非模型与用户调用的单次 Code Mode 操作 |
影响与兼容性
- 一致性:TUI 与 JSON-RPC/Web 三种界面通过通用结果回退机制显示与“模型接收 + 回放持久化”完全相同的完整内容,包括 post-policy 输出落盘后的头尾预览(spill 预览的构建逻辑见 packages/spill/spill-policy/src/index.ts)。
- 宿主 API:保留待完成程序标题,但不再在单独的视图负载中重复原始结果,消除了双份序列化。
- 格式兼容:新的
run_code结果不再携带可选的日志元数据,但无需提升会话格式版本——现有记录仍然有效,因为展示逻辑读取的是其中持久化的渲染内容。换言之,旧会话回放在新展示逻辑下自然获得完整卡片,无需迁移。
小结
这个 Bug 的根因不是渲染算法错误,而是“谁拥有最终内容”的职责漂移:UI 展示器试图基于过时的 presentationMeta 重建卡片正文,绕开了注册表流水线的持久输出。修复的核心动作是删减而非增加——移除 presentResult、移除日志元数据投影,让持久化的 tool/result.content 经通用回退成为卡片内容的唯一来源,同时以嵌套分发的 tool/code-dispatch 事件维持“一张外层卡片”的边界。这套“内容单一来源 + 回退投影”的模式,对任何需要在模型侧、持久化与 UI 三处呈现同一工具结果的系统都有参考价值。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00