DSH 上游 issue #16 草案 · 外发终稿(自包含)
一次响应撞上「单次输出上限」时,这次响应里的 tool-call 会被静默丢弃、turn 立即结束、子代理判 failed;界面把手工干预(「发送『继续』」)写成了唯一出路,而那条提示在被丢弃调用这一情形下与事实不符。实测同一父会话 17 个子代理里 4 席如此,4/4 都带一个写不完的 write。
当一次响应撞上「单次输出上限」时,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。
| 会话 / 标签 | 起止(墙钟/步数) | 死于 | 末次输出 token | 该请求输入上下文 | 被丢弃的调用 | 截断位置 |
|---|---|---|---|---|---|---|
9b9ae011W2建集群前置审计门 | 18:24:12→18:30:00 348s / 23 步 | step 23 | 32,000 推理 12,093 | 102,114 / 1,000,000 10.2% | write 54,534 字符JSON 未闭合 | 截断在 cmd_report(渲染段) |
6c1466c6W2建集群脚本与dry-run | 18:24:12→18:28:25 254s / 13 步 | step 13 | 32,001 推理 20 | 85,999 / 1,000,000 8.6% | write 90,252 字符JSON 未闭合 | 截断在 仍未到文件末尾 |
9b2367b8W2前置门脚本重做 | 18:55:39→18:59:44 246s / 10 步 | step 10 | 32,000 推理 8,067 | 64,674 / 1,000,000 6.5% | write 69,047 字符JSON 未闭合 | 截断在 cmd_selftest(自检段) |
221c9b97W2建集群脚本重做 | 18:55:39→19:01:20 341s / 17 步 | step 17 | 32,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 修正了行为,而是父代理把规格内联、并靠运气让首笔写入小于预算。换一个更大的交付物,同样会挂。
① 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:123 | const kept = this.finish.kind === 'max-tokens' ? all.map(block => block.type !== 'tool-call') : undefined; | 055f7340a2c4 |
| 同一判定在打包后的 dsh-llm 里只有一行 | dsh-llm/lib/index.js:1021 | const 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:1080 | if (finish.kind === "error" || finish.kind === "aborted") { ... retryPolicy ... } | 1d582b7f897c |
| append 完 assistant/message 后**立即结束 turn**:不问模型、不重试、不续跑 | dsh-agent-loop/lib/index.js:1113 | if (finish.kind === "max-tokens") return { kind: "max-tokens" }; | 1d582b7f897c |
| max-tokens 还是**粘性终态**:后续 step 不会覆盖它 | dsh-agent-loop/lib/index.js:956 | if (turnEnds === null || turnEnds.kind !== "max-tokens") turnEnds = stepEnd; | 1d582b7f897c |
| **自动续跑能力在同仓里是现成的**:goal-round-driver 用 <goal_round> 自动 followup 继续同一会话 —— 唯独 max-tokens 被显式关掉(所以「不续跑」是刻意选择,不是没能力) | dsh-goal-round-driver/lib/index.js:262 | case "turn/end": if (event.data.reason.kind === "max-tokens") { disarm(state); return; } | 363512b91f31 |
| 被丢弃调用的**唯一留存处**是 durable 的 stream(args 分片),模型不可见、只有取证时能解出来 | dsh-llm/lib/types/assistant-stream.d.ts:29 | type: 'tool-call-chunks' ... args: readonly string[] | c3895485abb9 |
| 更糟的一档:若该响应只有 tool call(无 text/reasoning),assistant/message 内容为空 ⇒ 该轮**整个不进 transcript**(只剩一个 usage 载体) | dsh-session/lib/types/surface.js:100 | case 'assistant/message': if (event.data.message.content.length === 0) return null; | 2703c1ec3348 |
| 子代理结算:max-tokens ⇒ 判 failed(不是 completed) | dsh-subagent/lib/index.js:2727 | case "error": case "max-tokens": case "refusal": return { status: "failed", ... }; | f394f2164bf2 |
| 父代理会被**自动唤醒**并收到那句文案(idle 时直接起新 turn)—— 信息到了父代理,只是口径不对 | dsh-subagent/lib/index.js:1298 | this.sendWaking(parent, message, parent.status === "idle" ? "queue" : "steer") | f394f2164bf2 |
| 父代理看到的英文文案(被中译成「耗尽上下文」的原文) | dsh-subagent/lib/types/continuation-messages.js:65 | case 'max-tokens': return `${subject} ran out of room before it finished.`; | c01329541f6b |
| 子代理工具面文案 | dsh-tool-subagent/lib/index.js:292 | case "max-tokens": return "subagent run hit its token limit before finishing"; | 9e4be841626e |
| write 工具只有 file_path + content,**没有 append/分片** | dsh-tool-fs/lib/index.js:591 | name: "write"(入参 file_path / content;空 content 合法=写空文件) | fad5344bae5d |
| write 后端同样没有大小护栏(diffBasisMaxBytes 只影响 before-diff 基准) | dsh-fs-local/lib/index.js:837 | async writeText(target, content, expected, signal) | f26358594890 |
| 同仓反例(姿态不一致):compaction 的摘要调用撞上 max-tokens 是 **fail-closed**,抛 code=MAX_TOKENS;主 turn 既不报错也不提示 | dsh-compaction-basic/lib/index.js:341 | case "max-tokens": throw new Error("summarization truncated at the token cap (incomplete checkpoint)") code=MAX_TOKENS | 565f7d4e2efb |
| 截断轮看不到用量:轮到「本轮用量」面板只在有**带文本**的收尾 assistant 时才渲染 | dsh-client-ui-chat/lib/client.js:3676 | if (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/)现读;上游改动后行号可能漂,指纹用于对齐"当时是哪一版"。
另外两点实测:
write 工具只有 file_path + content(空 content 合法=写空文件),没有 mode: append/offset;想把一份大文件交给 harness,模型只能寄望「一次装得下」。今天用户/父代理拿到的全部补偿手段,是这段界面文案(dsh-client-ui-chat/lib/client.js:2717-2718):
"message.maxTokens": "已达到输出 token 上限",
"message.maxTokens.hint": "回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。"
它有两个问题:
write 从未执行,transcript 里没有它的任何痕迹,模型也不知道自己发过它。「继续」不会重放它(模型续写的是「我怎么写这个文件」的思路,不是那笔调用)。归因错了,纠正动作就会错。这不是「用户没设好参数」,而是 harness 知道发生了什么(丢了几笔调用、丢的是什么工具),却没有把这件事说给任何一方听;而它本来就有自动续跑的机制,只是在这一条上被关掉了。
还有一条容易被忽略的可见性缺口:截断轮连「本轮用量」都不显示——TurnUsagePanel 只在有带文本的收尾 assistant 时才渲染(client.js:7223 closing = finalized.findLast(hasText)、3676 早退)。本次四席里有两席的末轮只有 reasoning + tool call(6c1466c6 的 reasoning 只有 20 token、221c9b97 同理),用户即使盯着屏幕也看不到「这一轮花了 32,000 输出 token」。
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 补齐。
block.type !== 'tool-call',连已 block-end 关闭、args JSON 合法的调用也一起丢——注释却说只丢 "cannot be executed safely" 的那些。至少应只丢真正截断/未闭合的那一个。tool/result:「未执行:响应在该调用参数流式期间撞上输出上限」)。今天要证明它存在过,只能去解 durable stream 里的 tool-call-chunks——那是对取证者友好、对模型和用户不友好。ran out of room 应带上区分度:hit the per-response output cap (32000) with 1 unfinished tool call (write, 69047 B args); input context was 64,674/1,000,000 tokens (6.5%)。这样父代理不可能再把它读成「上下文耗尽」。closing = finalized.findLast(hasText)),只出 reasoning + tool call 的截断轮(本次 4 席里有 2 席如此)连用量都不显示。write 增加 mode: "append"(可选 expected_sha256 做并发保护),并在工具描述里写明「单次调用建议 ≤ N 字符,超出请分片」。模型不是不愿意分片,是它不知道预算是多少——今天 write 只有 file_path+content,后端 writeText() 也没有任何大小护栏。call id 与工具名)。turn/end 之前至少发起 1 次自动续跑;续跑次数不超过配置上限。stopReason=completed(今天必然是 max-tokens ⇒ failed)。census-truncated-toolcalls.py;修复后在同型夹具上 truncated_toolcall 可以非 0(截断仍会发生),但不再伴随零交付(续跑后文件落盘)。assistant/message.stream 保留 tool-call-chunks(含 args 分片)⇒ 被丢弃的调用事后能还原(本条 issue 就是靠它定的性)。缺的是模型可见的记录。sendWaking(parent, …),idle 时直接起新 turn ⇒ 信息到了父代理,只是口径不对。edit(old_string/new_string)与 bash 追加都能分片写——缺的只是告诉模型预算是多少。MAX_TOKENS「incomplete checkpoint」),而主 turn 既不报错也不提示——两种姿态不一致,至少该对齐。会话日志格式 session.v3.jsonl(.zstd),逐行 JSON。以下命令只读:
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.jssed -n '1103,1117p' $P/dsh-agent-loop/lib/index.jssed -n '259,266p' $P/dsh-goal-round-driver/lib/index.jssed -n '96,101p' $P/dsh-session/lib/types/surface.jssed -n '1288,1300p' $P/dsh-subagent/lib/index.jssed -n '335,345p' $P/dsh-compaction-basic/lib/index.jssed -n '589,606p' $P/dsh-tool-fs/lib/index.jsgrep -n 'message.maxTokens' $P/dsh-client-ui-chat/lib/client.js本页用到的机读件:ops/dsh/upstream-issue-output-cap-truncates-toolcall/evidence-20260922.json(页面与三张图的唯一数字来源)。
独立核实(2026-09-22,只读):本条结论另起一条只读核实腿复核(只读安装包、不看本仓结论),它修正了本文两处口径,已并入本页:
block.type !== 'tool-call',已完整关闭且 JSON 合法的调用也一样丢(原文只写「被截断的调用」)。<goal_round> followup 就是自动续跑,唯一差别是它在 max-tokens 上被显式 disarm ⇒ 正确表述是「能力已有、被刻意关掉」。stream 保留原始分片(可取证),缺的是模型可见的记录;另有更差的一档——纯 tool-call 的截断轮整轮不进 transcript。finish=length 时丢 tool-call——本次只实测本机 deepseek 通道(同族的 dsh-llm-pi-ai 也有 length→max-tokens 映射,但未实跑)。maxTokens 是部署配置(ops/dsh/model.patch.yml,本机 32000)。把它调大只是把墙推远,不改变「交付物大于单次输出预算即必然失败」这个结构,也不解决静默丢弃。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)
点击任意处关闭