← 回到 infra 主页

DSH 上游 issue #16 草案 · 外发终稿(自包含)

输出上限截断 tool call 时 harness 静默丢弃且不续跑:请让 harness 自发处理(保留完整调用 + 记录被丢弃的调用 + 有界自动续跑 + 分清「输出上限」与「上下文窗口」)

一次响应撞上「单次输出上限」时,这次响应里的 tool-call 会被静默丢弃、turn 立即结束、子代理判 failed;界面把手工干预(「发送『继续』」)写成了唯一出路,而那条提示在被丢弃调用这一情形下与事实不符。实测同一父会话 17 个子代理里 4 席如此,4/4 都带一个写不完的 write。

实测于 2026-09-22 19:18:19 +0800 · 事故窗口 2026-09-22 18:24 - 19:07 +0800 · 版本 @deepseek-ai/dsh 0.1.6-alpha.2([app-dir]/app-[version])

结论(一句话)

当一次响应撞上「单次输出上限」时,harness 会把这次响应里的 全部 tool-call 静默丢弃、立即结束该 turn,并把子代理判成 failed;全程没有任何一处告诉模型「你的调用没执行」。 用户/父代理看到的只是「已达到输出 token 上限 / 发送『继续』可让模型接着输出」,而那条提示在 tool call 被丢弃时是错的——被丢弃的调用不在对话里,「继续」也不会重放它。

本次实测:同一个父会话的 17 个子代理里 4 席死于此路径,4/4 都带一个写不完的 write,合计 19.8 分钟墙钟、128,001 输出 token、交付 0 件;父代理只能人工重派,并在中文汇报里把它误记成「耗尽上下文」,于是纠正动作打在了「读文件太多」这个不相干的一侧。

诉求:把这件事变成 harness 的自发行为——①别丢完整调用(判据只按块类型过滤,连已关闭、JSON 合法的调用也一律丢,比注释自称的范围更宽);②被丢弃的调用必须有模型可见的记录;③有界自动续跑,而不是让用户手打「继续」——这个能力在同仓里是现成的(goal-round-driver 的 <goal_round> 会自动 followup),只是它在 max-tokens 上被显式关掉;④文案与归因分清「输出上限」与「上下文窗口」;⑤给大文件一条分片写的路。五条都有可机检判据,见 §4。

1 · 影响面:17 席里 4 席,全带截断调用

四席「最后一次响应」的输出预算构成(token) W2建集群前置审计门 · step 23 32,000(推理 12,093 | 工具参数 19,907) W2建集群脚本与dry-run · step 13 32,001(推理 20 | 工具参数 31,981) W2前置门脚本重做 · step 10 32,000(推理 8,067 | 工具参数 23,933) W2建集群脚本重做 · step 17 32,000(推理 4,399 | 工具参数 27,601) 单次输出上限 32,000 蓝=推理 token,橙=tool-call 参数 token;两条合计即该次响应的全部输出,四席全部顶到上限。
图 1 · 四席「最后一次响应」的输出预算构成。蓝=推理 token,橙=tool-call 参数 token;两者合计就是这次响应的全部输出,四席无一例外顶到 32,000 上限(其中 6c1466c6 记 32,001)。
同一次请求:输入窗口用了多少 vs 单次输出上限用了多少(%) W2建集群前置审计门 输入窗口 10.2%(102,114/1,000,000) 单次输出上限 100%(32,000/32,000) W2建集群脚本与dry-run 输入窗口 8.6%(85,999/1,000,000) 单次输出上限 100%(32,001/32,000) W2前置门脚本重做 输入窗口 6.5%(64,674/1,000,000) 单次输出上限 100%(32,000/32,000) W2建集群脚本重做 输入窗口 6.2%(61,585/1,000,000) 单次输出上限 100%(32,000/32,000) 绿条=该次请求的输入上下文(含 cacheRead);红条=该次输出 token 占上限的比例。「耗尽上下文」的说法与实际不符。
图 2 · 同一次请求的两个「使用了多少」放在一起看:输入上下文最多 10.2%、最少 6.2%,而单次输出上限 100%。所以这不是「上下文被读满」,是「单次回答写不下」。
同一个父会话下的 17 个子代理:4 席死于单次输出上限,且 4/4 带被截断的 write 5 4 4 干净结束 completed 9 死于此缺陷 max-tokens 4 快照时无 turn/end 4 干净席 13 / 17;4 席合计墙钟 19.8 分钟、输出 128,001 token、交付 0 件。 判据行 TRUNCATED_TOOLCALL_CENSUS: sessions=17 max_tokens=4 truncated_toolcall=4 clean=13(采集于 2026-09-22 19:18:19 +0800)。
图 3 · 同一父会话 17 个子代理的结局分布,以及这条缺陷的代价。判据行由普查脚本当次输出给出。

四席明细

会话 / 标签起止(墙钟/步数)死于末次输出 token该请求输入上下文被丢弃的调用截断位置
9b9ae011
W2建集群前置审计门
18:24:12→18:30:00
348s / 23 步
step 2332,000
推理 12,093
102,114 / 1,000,000
10.2%
write 54,534 字符
JSON 未闭合
截断在 cmd_report(渲染段)
6c1466c6
W2建集群脚本与dry-run
18:24:12→18:28:25
254s / 13 步
step 1332,001
推理 20
85,999 / 1,000,000
8.6%
write 90,252 字符
JSON 未闭合
截断在 仍未到文件末尾
9b2367b8
W2前置门脚本重做
18:55:39→18:59:44
246s / 10 步
step 1032,000
推理 8,067
64,674 / 1,000,000
6.5%
write 69,047 字符
JSON 未闭合
截断在 cmd_selftest(自检段)
221c9b97
W2建集群脚本重做
18:55:39→19:01:20
341s / 17 步
step 1732,000
推理 4,399
61,585 / 1,000,000
6.2%
write 83,842 字符
JSON 未闭合
截断在 main 之前的步骤表

四席里 6c1466c6 的推理只用了 20 token,其余约 31,981 token 全部灌进 write 参数——即「模型几乎没在思考,纯粹在吐文件,仍然没吐完」。这说明瓶颈是单次输出的物理预算,不是模型的策略选择:只要交付物大于预算,任何纪律都救不回来。

阳性对照:同一任务的第三席为什么能过

同题第三席 b957e957-b996-4676-bc88-5ed7bc0b2bcc(W2前置门只实现不读文档)在第 2 步先写了较小的一份骨架(一次 write 只用 18,955 输出 token、参数 34,698 字符),JSON 解析通过、32,170 字符真实落盘,随后 59 步继续迭代到 40,436 B 并 completed。同一模型、同一任务、同一上限——差别只在「一次写完」还是「分片写」。

但请注意:这次成功不是 harness 修正了行为,而是父代理把规格内联、并靠运气让首笔写入小于预算。换一个更大的交付物,同样会挂。

2 · 代码级机制:丢弃 → 立即结束 → 判 failed(自动续跑被显式关掉)

① provider 返回 finish_reason = length / max_tokens
        ↓
② dsh-llm/lib/types/assembler.js:123   assembled()
        const kept = this.finish.kind === 'max-tokens'
            ? all.map(block => block.type !== 'tool-call') : undefined;
        // 注释(117-120)自称:max-token truncation drops tool calls that cannot be executed safely.
        // 但判据只看块类型 ⇒ 连已 block-end 关闭、args JSON 合法的调用也一并丢掉(比注释更宽)
        ↓  (被丢弃的调用不写 tool/result、不注入任何提示 ⇒ 模型侧零信号)
        ↓  (更差一档:若该响应只有 tool call,content 为空 ⇒ 该轮整个不进 transcript,dsh-session/lib/types/surface.js:100)
③ dsh-agent-loop/lib/index.js:1113
        if (finish.kind === "max-tokens") return { kind: "max-tokens" };   // 立即结束 turn
        // 紧邻的 1080 行:error/aborted 走 agent/request-error waterfall(可重试);max-tokens 不在其中
        // 956 行:max-tokens 还是**粘性终态**,后续 step 不会覆盖它
        // 唯一能让 turn 继续的是 inbox.nextStep 非空(965-971)—— 即外部 steer/用户发消息
        ↓
④ 同仓里**本来就有**自动续跑:dsh-goal-round-driver 注入 <goal_round> 并 followup(),
        但它对 max-tokens 显式让路:lib/index.js:262
        case "turn/end": if (event.data.reason.kind === "max-tokens") { disarm(state); return; }
        ⇒ 「不续跑」是刻意选择,不是没有这个能力
        ↓
⑤ 子代理:dsh-subagent/lib/index.js:2727  case "max-tokens" ⇒ { status: "failed" }
        父代理被**自动唤醒**(:1298 sendWaking)并收到:continuation-messages.js:65
        "Background subagent <id> ran out of room before it finished."

关键代码指针(含文件指纹)

它决定什么位置原文md5(前 12)
唯一的 keep/drop 判定:max-tokens 时把**全部** tool-call 丢掉 —— 判据只看块类型,**连已 block-end 关闭、JSON 完整的调用也丢**,比注释自称的「只丢不能安全执行的」更宽dsh-llm/lib/types/assembler.js:123const kept = this.finish.kind === 'max-tokens' ? all.map(block => block.type !== 'tool-call') : undefined;055f7340a2c4
同一判定在打包后的 dsh-llm 里只有一行dsh-llm/lib/index.js:1021const kept = this.finish.kind === "max-tokens" ? all.map((block) => block.type !== "tool-call") : void 0;ad0ec5f5b2a7
错误/中止有 agent/request-error waterfall(可插拔重试),**max-tokens 不在其中**dsh-agent-loop/lib/index.js:1080if (finish.kind === "error" || finish.kind === "aborted") { ... retryPolicy ... }1d582b7f897c
append 完 assistant/message 后**立即结束 turn**:不问模型、不重试、不续跑dsh-agent-loop/lib/index.js:1113if (finish.kind === "max-tokens") return { kind: "max-tokens" };1d582b7f897c
max-tokens 还是**粘性终态**:后续 step 不会覆盖它dsh-agent-loop/lib/index.js:956if (turnEnds === null || turnEnds.kind !== "max-tokens") turnEnds = stepEnd;1d582b7f897c
**自动续跑能力在同仓里是现成的**:goal-round-driver 用 <goal_round> 自动 followup 继续同一会话 —— 唯独 max-tokens 被显式关掉(所以「不续跑」是刻意选择,不是没能力)dsh-goal-round-driver/lib/index.js:262case "turn/end": if (event.data.reason.kind === "max-tokens") { disarm(state); return; }363512b91f31
被丢弃调用的**唯一留存处**是 durable 的 stream(args 分片),模型不可见、只有取证时能解出来dsh-llm/lib/types/assistant-stream.d.ts:29type: 'tool-call-chunks' ... args: readonly string[]c3895485abb9
更糟的一档:若该响应只有 tool call(无 text/reasoning),assistant/message 内容为空 ⇒ 该轮**整个不进 transcript**(只剩一个 usage 载体)dsh-session/lib/types/surface.js:100case 'assistant/message': if (event.data.message.content.length === 0) return null;2703c1ec3348
子代理结算:max-tokens ⇒ 判 failed(不是 completed)dsh-subagent/lib/index.js:2727case "error": case "max-tokens": case "refusal": return { status: "failed", ... };f394f2164bf2
父代理会被**自动唤醒**并收到那句文案(idle 时直接起新 turn)—— 信息到了父代理,只是口径不对dsh-subagent/lib/index.js:1298this.sendWaking(parent, message, parent.status === "idle" ? "queue" : "steer")f394f2164bf2
父代理看到的英文文案(被中译成「耗尽上下文」的原文)dsh-subagent/lib/types/continuation-messages.js:65case 'max-tokens': return `${subject} ran out of room before it finished.`;c01329541f6b
子代理工具面文案dsh-tool-subagent/lib/index.js:292case "max-tokens": return "subagent run hit its token limit before finishing";9e4be841626e
write 工具只有 file_path + content,**没有 append/分片**dsh-tool-fs/lib/index.js:591name: "write"(入参 file_path / content;空 content 合法=写空文件)fad5344bae5d
write 后端同样没有大小护栏(diffBasisMaxBytes 只影响 before-diff 基准)dsh-fs-local/lib/index.js:837async writeText(target, content, expected, signal)f26358594890
同仓反例(姿态不一致):compaction 的摘要调用撞上 max-tokens 是 **fail-closed**,抛 code=MAX_TOKENS;主 turn 既不报错也不提示dsh-compaction-basic/lib/index.js:341case "max-tokens": throw new Error("summarization truncated at the token cap (incomplete checkpoint)") code=MAX_TOKENS565f7d4e2efb
截断轮看不到用量:轮到「本轮用量」面板只在有**带文本**的收尾 assistant 时才渲染dsh-client-ui-chat/lib/client.js:3676if (closing === null) return tail === null ? null : <div …>{tail}</div>; // 7223: closing = finalized.findLast(hasText)a1d288cc3029
子代理继承父的 maxTokens ⇒ 子代理同样是 32000,且模型/工具都无法单次突破dsh-subagent/lib/index.js:475...parentMaxTokens !== void 0 ? { maxTokens: parentMaxTokens } : {}f394f2164bf2

文件指纹按本次安装包([app-dir]/app-[version]/node_modules/@deepseek-ai/)现读;上游改动后行号可能漂,指纹用于对齐"当时是哪一版"。

另外两点实测:

3 · 为什么这是 harness 该自发做的事

今天用户/父代理拿到的全部补偿手段,是这段界面文案(dsh-client-ui-chat/lib/client.js:2717-2718):

"message.maxTokens": "已达到输出 token 上限",
"message.maxTokens.hint": "回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。"

它有两个问题:

  1. 在 tool call 被丢弃的场景下,提示与事实不符——「已有输出保留」对文本成立,对被丢弃的调用不成立:那个 write 从未执行,transcript 里没有它的任何痕迹,模型也不知道自己发过它。「继续」不会重放它(模型续写的是「我怎么写这个文件」的思路,不是那笔调用)。
  2. 把手工干预写成了推荐路径——正确性依赖"用户/父代理读得懂这条提示、并知道该重派还是在原会话里追问"。本次父代理就没读懂:它把「ran out of room」中译成「耗尽上下文」,据此推断「这两个任务是上下文密集型的」,于是第二席被加强的是「读文件纪律」(该席确实只读了 161 行设计件、输入上下文 64.7k),真正的瓶颈(一次写完)原样复现。

归因错了,纠正动作就会错。这不是「用户没设好参数」,而是 harness 知道发生了什么(丢了几笔调用、丢的是什么工具),却没有把这件事说给任何一方听;而它本来就有自动续跑的机制,只是在这一条上被关掉了。

还有一条容易被忽略的可见性缺口:截断轮连「本轮用量」都不显示——TurnUsagePanel 只在有带文本的收尾 assistant 时才渲染(client.js:7223 closing = finalized.findLast(hasText)、3676 早退)。本次四席里有两席的末轮只有 reasoning + tool call(6c1466c6 的 reasoning 只有 20 token、221c9b97 同理),用户即使盯着屏幕也看不到「这一轮花了 32,000 输出 token」。

4 · 建议修法(P0/P1/P2)与验收判据

P0-a · 不要 disarm:把「有界自动续跑」接回去

harness 已经有同会话自动续跑:dsh-goal-round-driver 会注入 <goal_round> 并自动 followup()(该插件在本实例默认组合里是开的)。它唯一的例外就是这一条:case "turn/end": if (event.data.reason.kind === "max-tokens") { disarm(state); return; }。建议把「因输出上限而丢弃了 tool call」与「纯文本截断」分开处理,前者交给一次有界续跑:注入系统级提示(哪几个调用被丢弃、参数多大、输入上下文只用了多少、请分片重写:单次 write ≤ N 字符,其余用 edit/追加),续跑上限建议 1–2 次,用尽仍失败才结束 turn。这条也顺手把 agent/request-error 那道只覆盖 error/aborted 的 waterfall 补齐。

P0-b · 丢弃的判据要收窄,且必须留痕(模型可见)

P1 · 文案与归因分开

P2 · 给大文件一条路

验收判据(可机检)

  1. AC1:夹具=一段以 max-tokens 结束、且同时含「完整关闭的 tool call」与「截断的 tool call」的流 ⇒ 完整那个不被丢,截断那个在 transcript 里有显式记录(含 call id 与工具名)。
  2. AC2:同一夹具下,loop 在 turn/end 之前至少发起 1 次自动续跑;续跑次数不超过配置上限。
  3. AC3:子代理在「首次响应被截断、续跑后完成」的情形下,stopReason=completed(今天必然是 max-tokens ⇒ failed)。
  4. AC4:界面与结算文案在「有 tool call 被丢弃」与「纯文本截断」两种情形下输出不同文本,前者含工具名与参数量;且截断轮仍显示本轮用量。
  5. AC5:回归尺子=本仓 census-truncated-toolcalls.py;修复后在同型夹具上 truncated_toolcall 可以非 0(截断仍会发生),但不再伴随零交付(续跑后文件落盘)。

既有机制与同仓反例(写 issue 时别把它们漏成"什么都没有")

5 · 复现与判据行

会话日志格式 session.v3.jsonl(.zstd),逐行 JSON。以下命令只读:

本页用到的机读件:ops/dsh/upstream-issue-output-cap-truncates-toolcall/evidence-20260922.json(页面与三张图的唯一数字来源)。

6 · 独立核实、未验证与边界

独立核实(2026-09-22,只读):本条结论另起一条只读核实腿复核(只读安装包、不看本仓结论),它修正了本文两处口径,已并入本页:

7 · English issue body(可直接粘贴)

Title: A max-tokens cut-off silently drops every tool call and deliberately skips auto-continuation: keep the complete calls, record the dropped one, and auto-continue (bounded) instead of asking the user to type "continue"

Summary
-------
When a single model response hits the per-response output cap (finish reason `max-tokens`),
the harness drops EVERY tool-call block from that response (dsh-llm/lib/types/assembler.js:123,
comment: "max-token truncation drops tool calls that cannot be executed safely"), ends the turn
immediately (dsh-agent-loop/lib/index.js:1113) and settles a subagent as FAILED
(dsh-subagent/lib/index.js:2727). Nothing in the transcript tells the model that its call never ran,
and the only recovery offered to the user is a UI hint telling them to type "continue"
(dsh-client-ui-chat/lib/client.js:2717-2718) - which does NOT replay the dropped call.

Impact measured (2026-09-22, @deepseek-ai/dsh 0.1.6-alpha.2, deepseek channel)
--------------------------------------------------------------
- 4 of 17 subagents of one parent session ended with `max-tokens`;
  all 4 of them had a truncated `write` call (verdict line: TRUNCATED_TOOLCALL_CENSUS: sessions=17 max_tokens=4 truncated_toolcall=4 clean=13).
- Wasted: 19.8 minutes of wall clock, 128001 output tokens, 0 deliverables.
  * 9b9ae011 (W2建集群前置审计门): step 23, output 32000 tokens (reasoning 12093), `write` args 54534 chars, args JSON unparseable, input context only 102114/1000000 tokens (10.2%).
  * 6c1466c6 (W2建集群脚本与dry-run): step 13, output 32001 tokens (reasoning 20), `write` args 90252 chars, args JSON unparseable, input context only 85999/1000000 tokens (8.6%).
  * 9b2367b8 (W2前置门脚本重做): step 10, output 32000 tokens (reasoning 8067), `write` args 69047 chars, args JSON unparseable, input context only 64674/1000000 tokens (6.5%).
  * 221c9b97 (W2建集群脚本重做): step 17, output 32000 tokens (reasoning 4399), `write` args 83842 chars, args JSON unparseable, input context only 61585/1000000 tokens (6.2%).
- Control: the same task succeeded only when the payload fit in one response (b957e957: args 34698 chars, output 18955 tokens, JSON valid, 32170 chars written, then 59 more steps to completion).

Why this matters beyond the wasted run
-------------------------------------
- The settlement text is "Background subagent <id> ran out of room before it finished." - our orchestrator
  translated that as "ran out of context" and changed the WRONG variable (it tightened the reader's
  file-reading discipline, while the failing action was a single oversized write).
- The configuration is not the fix: `maxTokens` is a deployment patch (here 32000). Raising it moves the
  wall; it does not remove the structural failure (deliverable larger than one response) nor the silence.

Requested behaviour (harness-automatic, no user configuration)
-------------------------------------------------------------
P0 a) Do not disarm auto-continuation on this path. The harness ALREADY has bounded same-session
      continuation: dsh-goal-round-driver injects `<goal_round>` and follows up automatically (it ships in
      the default composition), and it makes exactly one exception - `case "turn/end": if
      (event.data.reason.kind === "max-tokens") { disarm(state); return; }` (lib/index.js:262). Split
      "truncated with dropped tool calls" from "plain text truncation" and let the former continue once or
      twice with an injected instruction (which calls were dropped, how big, how much context was actually
      used, and to re-issue in chunks). Also extend the `agent/request-error` waterfall (line 1080), which
      today covers only error/aborted.
P0 b) Narrow the drop rule and record what is dropped. The filter is `block.type !== 'tool-call'`, so a
      fully closed tool call with valid args JSON is discarded too, while the comment (assembler.js:117-120)
      claims only calls "that cannot be executed safely" are dropped. Keep the complete ones; for the
      truncated one write an explicit, model-visible transcript entry (event or synthetic tool/result:
      "not executed: the response hit the output cap while streaming this call"). Today the only trace is
      the durable `assistant/message.stream` (tool-call-chunks) - forensics-friendly, model-invisible;
      and when the response consists of tool calls only, the whole assistant turn is projected away
      (dsh-session/lib/types/surface.js:100).
P1 c) Split the wording: distinguish "output cap" from "context window" in both the UI notice and the
      subagent settlement text, and include the tool name, args size and input-context usage.
      The current hint ("earlier output is preserved ... send continue") is factually wrong when a tool
      call was dropped: the call is not in the conversation and "continue" will not replay it. Also show
      per-turn usage on a truncated turn: the TurnUsagePanel only renders when the turn has a closing
      assistant WITH TEXT (client.js:7223 findLast(hasText), 3676), so 2 of our 4 truncated turns showed
      no usage at all even though they burned the full 32,000-token budget.
P2 d) Give large files a path: `write` with `mode: "append"` (+ optional expected_sha256) and a documented
      payload budget in the tool description; the model cannot plan around a budget it does not know.
      Today `write` takes only file_path/content (dsh-tool-fs:591) and the backend writeText() has no size
      check at all (dsh-fs-local:837). The workaround (write a first slice, then edit/append) exists but is
      nowhere suggested to the model.
      Consistency note: compaction already treats max-tokens as fail-closed (dsh-compaction-basic:341
      throws code MAX_TOKENS "summarization truncated at the token cap"); the main turn neither fails loud
      nor tells anyone. At minimum align the two postures.

Acceptance criteria
-------------------
AC1 fixture = a response ending in max-tokens that carries both a complete tool call and a truncated one:
    the complete one is NOT dropped, and the truncated one has an explicit transcript entry (call id + tool).
AC2 the loop issues >=1 automatic continuation before turn/end; attempts are capped.
AC3 a child that is truncated once and completes after continuation settles as `completed`, not `max-tokens`.
AC4 UI/settlement text differs between "tool call dropped" and "text-only truncation", names the tool,
    and a truncated turn still shows its token usage.
AC5 regression ruler: ops/dsh/upstream-issue-output-cap-truncates-toolcall/census-truncated-toolcalls.py
    (truncated_toolcall may stay non-zero after the fix; a lost deliverable must not).

Evidence / repro (read-only)
----------------------------
  python3 ops/dsh/session-context-forensics/analyze-context.py --steps <session.jsonl>
  python3 ops/dsh/session-context-forensics/check-toolcall-truncation.py <session.jsonl> <step>
  python3 ops/dsh/upstream-issue-output-cap-truncates-toolcall/census-truncated-toolcalls.py <children-dir>
  sed -n '121,127p' $P/dsh-llm/lib/types/assembler.js
  sed -n '1103,1117p' $P/dsh-agent-loop/lib/index.js
  sed -n '259,266p' $P/dsh-goal-round-driver/lib/index.js
  sed -n '96,101p' $P/dsh-session/lib/types/surface.js
  sed -n '1288,1300p' $P/dsh-subagent/lib/index.js
  sed -n '335,345p' $P/dsh-compaction-basic/lib/index.js
  sed -n '589,606p' $P/dsh-tool-fs/lib/index.js
  grep -n 'message.maxTokens' $P/dsh-client-ui-chat/lib/client.js
  install: [app-dir]/app-[version] (package @deepseek-ai/dsh 0.1.6-alpha.2)