DeepSeek Harness PTC 模式 run_code 结果卡片单源渲染:让持久化内容成为卡片的唯一内容来源
DeepSeek Harness 的 PTC(Program-as-Tool-Calling)模式下,run_code 工具让模型用一段 TypeScript/Python 程序编排多个子工具调用,其 UI 结果卡片必须完整呈现程序日志与返回值。本文基于该仓库的一条已归档 Agent Note(2026-07-20-code-mode-result-card-completeness.md),讲清这个"结果卡片完整性"问题的成因、最终决策(由工具注册表管线独占最终内容、run_code 有意省略 presentResult)、对应的测试与快照证据,以及四条被否决的替代方案。读完你能理解 DeepSeek Harness 工具呈现层"单一事实来源"的设计契约,并掌握在工具插件中正确实现结果展示的方式。
背景:PTC 模式下的 run_code 与结果卡片
在 packages/core/tools 中,工具注册表通过 mode 配置决定模型看到什么:native(完整工具 schema)、ptc(只有 run_code 加生成的 SDK)或 both。PTC 模式下,模型提交一段 run_code 程序,程序内以 await tools.name(args) 的形式发起嵌套子调用;每次子调用会重入完整的工具执行管线,并记录与外层调用的关联日志。
关键契约是:中间绑定值是执行期本地的(execution-local),只有外层 run_code 的结果进入模型历史,也只有一张卡片对应一次外层调用。这个"一张卡片"的边界正是本次问题修复所保护的。
问题:卡片内容的"分裂所有权"
原实现中,run_code 的 output.render 会把捕获的日志与返回值渲染成完整内容并持久化,但它的 UI 呈现器(presenter)忽略了这份完整内容,转而从一份只含日志(logs-only)的 presentationMeta 投影重建卡片正文。由此出现一个隐蔽的故障模式:
- 只有返回值、没有日志时:呈现器产出的正文为空,消费方回退到
tool/result.content(持久化的完整渲染内容),卡片看起来正确; - 程序一旦
print出日志:呈现器拿到非空内容,回退停止,卡片只显示日志,返回值从完成态卡片上消失; - 溢写策略(spill policy)产生的头/尾预览(head/tail preview)同样暴露在这个分裂所有权之下——只要捕获日志让旧投影变非空,预览也会被丢弃。
此外,嵌套的 Code 调用从不拥有卡片,因此为了外层调用专门生成元数据去重建一张残缺卡片,还会模糊"一次外层 run_code 调用恰好一张卡片"的预期边界。
决策:规范管线独占最终内容,run_code 不声明 presentResult
修复的核心是重新划清所有权边界,包含四个相互咬合的决策:
1. 工具注册表管线拥有模型可见的最终外层内容
在 packages/core/tools/src/ptc.ts#L325-L329 中,run_code 的输出渲染器定义了唯一的模型可见文本契约:成功时依次拼接捕获日志、返回值(或显式的无输出标记);两者皆空时输出 (run_code completed with no output):
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)' }]
},
其中 renderValue(ptc.ts#L257-L260)对字符串原样输出,对非字符串 JSON 根使用两空格缩进的迭代式 JSON 呈现器(renderJsonValue,缩进总量封顶 10 字符、深层子树紧凑化,保证格式化输出规模与规范 JSON 线性相关)。
失败路径不走这个渲染器:运行期失败与执行前的策略拒绝由 ToolRegistry 直接归一化为错误内容。例如 ptc.ts#L640-L643 中,运行失败抛出 CodeRunFailedError,最终呈现为 Error: code run failed (<kind>): <message> 并条件性附加 Captured output: 与捕获日志。而 post-execute 阶段的 block 决策在成功渲染之后执行,可把结果替换为错误内容;其他 post-execute 策略决策与溢写决策则可能在持久化之前替换内容——这些替换都发生在渲染器下游,因此天然被纳入"最终内容"。
2. run_code 有意省略 presentResult,走通用结果回退
packages/core/tools/src/presentation.ts#L133-L138 定义了结果视图词汇表(ToolResultView)的通用规则:"省略 presentResult 方法时,保留 pending 态标题并渲染原始结果内容"。run_code 正是主动利用这条通用回退:
// The model-authored description is the call's always-visible UI label
// (the bash `description` precedent); the program itself rides rawInput.
presentCall: args => ({
card: 'generic',
title: args.description,
kind: 'execute',
rawInput: args.code,
}),
// 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.
见 ptc.ts#L652-L663。由此,那张持久化、可重放、策略后(post-policy)的 tool/result.content 成为卡片的唯一结果内容来源:模型看到什么,UI 就回放什么,包括溢写策略替换后的头/尾预览。
这也解释了 Host 侧的配套决策:Host API 代理因此不再序列化的独立结果视图,避免同一份内容同时出现在 event.data.content 和 view.view.content 两处;原来那份冗余的 logs-only presentationMeta 投影则被彻底移除。
3. 嵌套派发边界保持不变
嵌套派发逻辑见 ptc.ts#L467-L603:每个绑定调用分配形如 ${callId}:code:${n} 的子调用 ID,并携带 parent 标记(ptc.ts#L473-L482)。带 exec.parent 标记的调用只会追加 tool/code-dispatch 事件——其中 content 字段是子调用的完整渲染内容(ptc.ts#L506-L524 中经 shapeDispatchLog 瀑布处理后追加)——但不产生 tool/call / tool/result 事件,即不产生卡片。子调用的并发由每运行的调度器池约束(配置项 maxParallelSubCalls,默认 10,见 packages/core/tools/README.md),exclusive 分类的调用会独占屏障直到 commit(含 post-execute)完成。
结果是一次外层 run_code 调用恰好产生一张卡片;中间值有意保持执行期本地,模型与用户看到的就是那一次 Code Mode 操作本身。
验证:单元测试、host-mux 回归与端到端快照
工具单测:驱动六种结局并钉死内容
packages/core/tools/tests/ptc.spec.ts#L1330-L1347 用参数化用例把六种结局逐一驱动过规范注册表,并在每个用例中断言 presentResult 不存在:
it.each([
['logs only', { logs: ['printed'] }, 'printed'],
['result only', { logs: [], value: 'returned' }, 'returned'],
['logs plus result', { logs: ['printed'], value: 'returned' }, 'printed\nreturned'],
['no output', { logs: [] }, '(run_code completed with no output)'],
] as [string, CodeRunResult, string][])('keeps %s in durable content without a result presenter', async (_name, output, text) => {
...
expect(result.content).toEqual([{ type: 'text', text }])
expect('presentResult' in tool).toBe(false)
})
另外两个用例分别钉住策略后内容与失败内容:
- 溢写预览(ptc.spec.ts#L1349-L1363):注册一个
tools/post-execute监听器把结果替换为HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: ...)\n\nTAIL形式的预览,断言该预览进入持久内容且工具仍无presentResult; - 失败结局(ptc.spec.ts#L1365-L1381):运行以
output-limit失败且带捕获日志,断言持久内容为Error: code run failed (output-limit): ...\nCaptured output:\ncaptured before failure。
这些用例的共同目标是证明:旧元数据无法再顶替最终内容,同时 host 也不会因此复制该内容。host-mux 回归则用一个 call-only 呈现器证明:结果帧恰好携带一次原始内容、且没有伴随的 view 载荷。
端到端快照:外层一张卡片,嵌套零卡片
keyless ACP 后端与 TUI Code Mode 的快照执行同一个外层程序:两次嵌套 bash 调用、print 出 captured output、返回 CODE_ONE+CODE_TWO。会话级快照 snapshots/session/ptc-turn/session.jsonl 中持久化了完整结果;从源码结构看,ACP 日志钉住的是完整渲染文本,TUI 表面显示一张完成态外层卡片(含日志行与返回值两行),且没有任何嵌套卡片。
被否决的替代方案及原因
原 Agent Note 明确记录了四条被否决的路径,值得逐条对照:
| 方案 | 否决原因 |
|---|---|
| 把返回值追加进 logs 元数据 | 元数据将复制渲染器逻辑,需要为每种 JSON 根维护第二套稳定格式化契约,且仍会漏掉 post-policy 内容替换与溢写预览这两种下游替换 |
把 presenter 元数据与 result.content 合并 |
渲染内容本身已包含日志,合并会造成日志重复,并引入脆弱的去重逻辑 |
经通用结果 presenter 转发 result.content |
持久化事件本身已携带该内容,且 UI 消费方已有通用原始内容回退。Host mux 会在事件旁序列化一份工具自有的结果视图,转发等于仅为重建回退而在一帧内重复一次渲染内容;相比之下,默认 worker 在渲染前就允许 64 MiB 的变量载荷预算,转发并无收益 |
| 为每个嵌套派发创建一张卡片 | 中间值是有意执行期本地、从不模型可见的;多卡片会暴露实现轨迹,而非模型与用户实际发起的那一次 Code Mode 操作 |
从源码结构看,这些否决理由与最终实现一一对应:output.render 是唯一格式化点(方案一、二会引入第二套契约);presentResult 缺席即方案三的取舍;tool/code-dispatch 只记日志不建卡片即方案四的落实。
影响与兼容性
- 显示一致性:TUI 与 JSON-RPC/Web 显示的内容与模型收到、回放持久化的内容完全一致,包括 post-policy 溢写预览,全部经由通用结果回退送达;
- 载荷不膨胀:Host API 保留 pending 程序标题,但不再把原始结果复制到独立 view 载荷中;
- 无会话格式升级:新
run_code结果不再携带可选的 logs 元数据,但由于呈现层读取的是持久化渲染内容,既有会话记录依然有效,无需会话格式版本升级(session-format bump); - 适用前提:非 native 模式(
ptc/both)要求已组装的ctx.codeRuntime且其语言有注册的 SDK 渲染器;maxParallelSubCalls(默认 10)约束嵌套调用的重叠上限,1恢复严格串行派发。
延伸阅读
- tools 子系统文档 与 工具执行管线:
tools/pre-execute→ guards →tools/execute→tools/post-execute→finalizeContent→tools/result的完整管线类型与可视化; - @deepseek-ai/dsh-tools 包文档:PTC 模式、
defineToolDSL 与 Model Experience 一节对模型可见结果的权威描述; - 呈现意图词汇表:
ToolCallView/ToolResultView各卡片变体(generic、terminal、diff、search、read、web)的定义与回退规则; - run_code 传输实现:语言风味解析、绑定构建、每运行调度器与 quiescence 排空逻辑的完整源码;
- PTC 工具测试:本文引用的内容完整性、失败归一化与事件日志用例;
- 会话快照:外层一张卡片、嵌套零卡片的端到端持久化证据。
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