DeepSeek Harness Bash 工具输出捕获机制剖析:bounded tail、spill 文件与一次被否决的简化提案
DeepSeek Harness Bash 工具输出捕获机制剖析:bounded tail、spill 文件与一次被否决的简化提案
导读
本篇文章以 DeepSeek Harness 仓库中 @deepseek-ai/dsh-bash-local(本地 Bash 执行器)的真实输出捕获链路为主线,深入拆解「有界内存尾部(bounded tail)+ spill 临时文件」这套机制的设计动机、源码实现与安全约束,并完整还原一份围绕它展开的、最终被否决的简化提案(Agent Note:Drop bash full-output spill files)。读完你不仅能理解 CollectedOutput、OutputCollector、spillPath 这些核心概念在仓库中的真实形态,还能掌握此类「大输出截断与恢复」问题在 Agent 工具链中的权衡方法——什么情况下该保留 spill 文件、什么条件下才适合用通用 artifact/blob 服务替换。
一、问题背景:为什么 bash 工具的输出必须「有界」
模型驱动的 bash 工具在 Agent 工作流中高频执行命令,而命令的输出长度在理论上是无上限的:一条 cat 大文件、一段密集日志、一次递归扫描都可能产生数百 MB 的 stdout/stderr。如果把输出无界地缓冲在内存里,宿主进程的内存会被轻易耗尽;如果直接把全部输出塞进模型上下文,token 预算与 KV Cache 也会被瞬间击穿。
因此,dsh-bash-local 采用了一个业界常见但实现细节颇多的策略:在内存中保留有界输出,并把超出的部分溢写(spill)到私有临时文件,然后当输出被截断时,把 spill 文件路径渲染进模型可见的文本里,让模型可以自行读取完整输出。
该策略在 packages/shell/bash-local/README.md 中被概括为:
它对每条命令应用配置好的预算(工作目录、超时、输出上限),并在流溢出时返回带有 spill 文件恢复能力的有界输出。
仓库中的拒绝提案(.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md)精确列举了这套机制「解决问题所需要付出的全部代价」:私有目录、随机 owner-only 文件创建、close 失败处理、字节偏移增量读取、lossy 读取上报、模型可见文本中的路径渲染,以及严格的清理纪律。下面逐层拆解这些代价在源码中的具体体现。
二、数据模型:CollectedOutput 与 spill 路径
2.1 最终输出形状
subprocess 能力缝(seam)在 packages/subprocess/subprocess/src/types.ts 中定义了最终输出形状 CollectedOutput:
export interface CollectedOutput {
/** Collected text — the TAIL of the stream when truncated. */
text: string
/** True when bytes were dropped from `text`. */
truncated: boolean
/** Path to a file holding the COMPLETE stream, when truncated and available. */
spillPath?: string
}
三个字段各司其职:
text:被收集到的文本。当发生截断时,它是流的尾部——设计依据是「错误与最终结果通常聚集在命令输出的结尾」(见下文OutputCollector的 tail-keep 注释);truncated:标记是否有字节被丢弃,即text是否只是完整流的尾巴;spillPath:当截断发生且 spill 文件完整可用时,指向保存完整流的文件路径。
这一形状贯穿 bash 工具的整个链路:LocalBashExecutor.finalOutput() 在 packages/shell/bash-local/src/index.ts 中把 subprocess 层的读取结果投影成 CollectedOutput:
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
const read = reader.readFrom(0)
return {
text: read.text,
truncated: read.lossy,
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
}
}
注意这里 truncated 直接取自 read.lossy(有损读标志),而 spillPath 仅在存在时才会带上。在提交了 drop-spill 提案的假设世界里,CollectedOutput 将不再携带 spillPath 字段,truncated 只作为纯布尔标记存在——这正是提案验收标准的第一条。
2.2 采集配置形状
同一文件(packages/subprocess/subprocess/src/types.ts)定义了采集模式 SubprocessCollect,它是「是否 spill」的开关:
export interface SubprocessCollect {
/** In-memory cap in bytes; overflow keeps the TAIL. */
maxBytes: number
/** Full-stream spill file; absent disables spilling entirely. */
spill?: {
/** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */
maxBytes: number
}
}
类型注释明确指出两种形态:
- 诊断尾部形态(diagnostic-tail shape):省略
spill,只保留内存尾部。例如语言服务器(LSP)的 stderr 就属于这种——没人需要从临时文件里找回它的完整历史; - bash 工具形态:带上
spill,让完整流在不超过上限的前提下可恢复。
这个二选一的类型设计本身就是对「spill 是不是必要的通用能力」的一次架构表态:它被建模为可选能力,而非强制行为。
2.3 增量读形状
对后台进程,spill 路径还会出现在每次增量读的结果里。subprocess 层定义 SubprocessOutputRead(packages/subprocess/subprocess/src/types.ts):
export interface SubprocessOutputRead {
text: string
nextOffset: number
lossy: boolean
spillPath?: string
}
而 shell 层的 ShellProcessRead(packages/shell/shell/src/types.ts)进一步把 stdout/stderr 两条流的 spill 路径合并暴露:
export interface ShellProcessRead {
/** Output produced since the previous read (stderr in a marked section). */
delta: string
/** True when truncation dropped unread bytes the delta cannot include. */
lossy: boolean
/** Full stdout spill file, when stdout truncation occurred and a safe path is available. */
stdoutSpillPath?: string
/** Full stderr spill file, when stderr truncation occurred and a safe path is available. */
stderrSpillPath?: string
}
提案中「它(spill 路径机制)也把后台任务读取复杂化了,因为有损增量读不得不指向一个或两个 spill 文件」指的就是这里:一个 lossy 读可能携带两条路径。
三、核心实现:OutputCollector 的 tail-keep 与 spill 机械
spill 机制的全部细节集中在 packages/subprocess/subprocess-local/src/spawn.ts 的 OutputCollector 类中。它的注释直接交代了设计动机:
Tail-keep rationale (pi/OpenCode): errors and final results cluster at the end of command output; the spill file covers the head.
即:错误和最终结果集中在命令输出尾部,所以内存里保留尾部、把头部交给 spill 文件,是信息损失最小的截断策略。
3.1 push:溢出即 spill
push() 是每次 data 事件的入口,逻辑如下(spawn.ts):
- 累计整流字节数
total; - 判断本次 chunk 是否会让内存超限(
overflows); - 只要启用了 spill(
!spillDisabled)且发生溢出或 spill 文件已打开,就把 chunk 追加进 spill 文件(spillAll); - chunk 进入内存缓冲后,循环从头部丢弃整块或裁掉超量字节,直到内存回到
maxBytes以内,并置dropped = true。
其中「裁掉超量字节」的分支(this.chunks[0] = head.subarray(excess))保证了内存窗口在字节粒度上精确等于上限——对诊断尾部形态尤其重要,无论流如何分块,最后 maxBytes 字节必然保留。
3.2 spillAll:随机文件名与独占创建
spill 文件并非简单写在 /tmp 下,而是有一整套防预测、防符号链接攻击的纪律(spawn.ts):
private spillAll(chunk: Buffer): void {
if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
this.discardSpill()
return
}
if (this.spillFd === undefined) {
this.spillFile = join(
this.spillDir,
`dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
)
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
for (const prior of this.chunks) writeSync(this.spillFd, prior)
}
writeSync(this.spillFd, chunk)
}
要点逐条对应提案列举的「代价清单」:
- 私有目录:
privateSpillDir()(spawn.ts)在 OS tmpdir 下惰性创建mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))目录。注释明确:可预测的世界可读路径会让其他本地用户读到命令输出或预创建符号链接; - 随机 owner-only 文件创建:文件名含
process.pid、递增计数器和 6 字节随机 hex;openSync(..., 'wx', 0o600)是「独占 + 0600 权限 + 遇任何已存在路径(含符号链接)即失败」的三重防护; - close 失败处理:
seal()(spawn.ts)在流结束时关闭文件描述符,一旦 close 抛错(延迟写回故障),就把spillFile置为undefined——不再对外宣传这个可能缺尾的文件,但内存读一切照常; - 清理纪律:
discardSpill()(spawn.ts)在整流超过maxSpillBytes时关闭并unlinkSync删除不再完整的 spill 文件,避免留下无限增长的文件;即便 unlink 失败,最多也只残留一个不超过maxSpillBytes的文件,绝不会是无界文件。
3.3 readFrom:字节偏移与 lossy 判定
增量读 readFrom(fromByte)(spawn.ts)是「字节偏移增量读取 + lossy 读取上报」的实现:
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
const windowStart = this.total - this.bytes
const buffer = Buffer.concat(this.chunks)
const lossy = fromByte < windowStart
const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
return {
text: slice.toString('utf8'),
nextOffset: this.total,
lossy,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
偏移是「整流字节坐标」,由调用方持有:当请求的偏移已经滑出内存尾部窗口时,本次读是 lossy——只能返回整个保留的尾部,缺口(gap)只能靠 spill 文件补齐。这正是提案所说的「有损增量读必须指向一个或两个 spill 文件」的场景。
3.4 finalize:最终形状
finalize()(spawn.ts)先 seal() 再返回最终的 CollectedOutput:尾部文本 + truncated: dropped +(若文件完好)spillPath。spawn 路径在进程 settle 时同时封口两个采集器,保证进程退出后的读取永远不会指向一个仍打开着的文件。
四、配置参数:溢出行为的可调预算
spill 行为完全由 bash 执行器的配置驱动。Config 定义于 packages/shell/bash-local/src/index.ts,默认值见同文件的 static Config(index.ts):
| 字段 | 默认值 | 含义 |
|---|---|---|
cwd |
process.cwd() |
命令默认工作目录 |
timeoutMs |
120,000 |
前台默认超时(毫秒) |
maxTimeoutMs |
600,000 |
单次调用超时覆盖的上限 |
maxOutputBytes |
64,000 |
每流内存输出上限;溢出 spill 到临时文件 |
maxSpillBytes |
67,108,864(64 MB) |
每流完整输出 spill 上限 |
graceMs |
3,000 |
kill 升级与退出后管道排空的宽限期 |
spawnSpec()(index.ts)把这两个输出预算映射到 subprocess 的采集配置:
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
其中 stdout 使用前台调用方可能抬高的 stdoutMaxBytes,stderr 与后台任务一律使用 config.maxOutputBytes(参见 ShellExecSpec 的注释)。更详尽的字段说明可在生成的 配置目录 中查到。
值得注意的边界:当整流超过 maxSpillBytes 时,spill 文件会被丢弃且该流永久禁用 spill(spillDisabled = true),此时 truncated = true 而 spillPath 缺席——这正是「无法从临时文件恢复被省略前缀」的真实形态,也是被否决提案所接受的终极状态。
五、模型可见的渲染:truncation marker 与路径文案
spill 路径进入模型上下文的位置在 packages/shell/tool-bash/src/render.ts。streamText()(render.ts)负责给单条流追加截断通告:
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
renderResult()(render.ts)把它拼进最终模型文本:stdout 正文 → 标记的 <a href="https://link.gitcode.com/i/bca9d8df08887244060da8adcb9eaab9" target="_blank">stderr] 区块 → 退出状态标记([timed out after Nms]、[killed by signal: …]、[exit code: N])。对后台任务,renderProcessRead()([render.ts)在 lossy 时拼接:
[some output was dropped from memory; full output: <stdoutSpillPath>, <stderrSpillPath>]
这两处渲染正是提案核心诉求的落点:「bash 结果应只包含有界尾部 + 清晰的截断标记,不发出任何路径」——即 renderResult() 报告截断但不再附带文件系统路径,模型不再被引导去读取一个进程本地的临时文件。
提案同时指出这类路径的架构性质问题:
一个 spill 路径是暴露给模型输出的进程本地文件系统工件,而不是一个有作用域访问、保留策略和 UI 呈现能力的持久化 harness 工件。
即:模型看到的是一条「脆弱」的绝对路径,它属于某个进程的私有临时目录,生命周期与宿主进程绑定,没有保留、配额或可视化的概念——这是「泄漏式(leaky)」抽象的核心论据。
六、安全边界:defensive-patterns 中的 spill 文件纪律
spill 文件涉及命令输出这种敏感数据,安全文档 docs/defensive-patterns.md 有明确指引:
Spawned commands get a scrubbed env (drop
*KEY*/*SECRET*/*TOKEN*/*PASSWORD*) so harness credentials cannot leak into output,env, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens ('wx',0o600) — predictable world-readable paths invite symlink races and disclosure.
两层防线值得注意:
- 前置清洗:子进程环境经过凭据擦洗(subprocess 服务的
scrubbedParentEnv,见 packages/subprocess/subprocess-local/src/spawn.ts),避免*KEY*/*SECRET*/*TOKEN*/*PASSWORD*泄漏进输出、环境或 spill 文件; - 落地防护:0700 私有目录 + 随机文件名 +
'wx'独占打开 + 0600 权限,抵御共享临时目录中的符号链接竞态与披露。
这份文档正是被否决提案验收标准中「安全指引应停止把私有 spill 文件当作模型可见接口」所指向的修改对象。有趣的是,仓库现状(该提案被拒绝)意味着这一节的描述依然成立——spill 文件的临时性被明确接受为模型可见接口,但必须叠加上述全套落地纪律。
七、测试实证:spill 行为是仓库契约
spill 机制不是纸面设计,packages/shell/bash-local/tests/executor.spec.ts 通过注入 internals.spillDir(executor.spec.ts)在确定性目录中验证行为:
result.stdout.truncated === false、result.stderr.truncated === true之类的截断断言(executor.spec.ts);readOutput flags lossy reads and reports stdout spill paths(executor.spec.ts):窗口滑过偏移 0 →lossy,spill 路径指向完整流;readOutput reports stderr spill paths(executor.spec.ts)。
spill 可注入(spillDir)、可观察(路径出现)、可断言(truncated/lossy),说明它当前是被测试锁定的公开契约。被否决提案若落地,验收标准对应的测试变更将是「只覆盖尾部截断,不再断言完整输出文件内容」——即这些用例会被改写或删除。
八、被否决的提案:drop full-output spill files
8.1 提案内容
仓库中的 Agent Note(.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md)提出:
- 保留尾部截断,移除完整输出 spill 文件:bash 结果只包含有界尾部加清晰截断标记,不发出任何路径;
- 若用户确实需要完整输出恢复,应引入通用 artifact/blob 服务(显式所有权、清理与 UI 渲染),让 bash 把大输出挂到该服务上;
- 该提案可独立于通用长运行工具运行时落地;若后台任务保留,
bash_output仍应报告输出被丢弃,但不再宣传 spill 路径。
8.2 验收标准(原文五项)
CollectedOutput不再携带 spill 路径;OutputCollector只保留有界缓冲,删除临时文件机械;renderResult()报告截断但不附带文件系统路径;- 测试覆盖尾部截断,且不再断言完整输出文件内容;
- docs/defensive-patterns.md 中的安全指引停止把私有 spill 文件当作模型可见接口。
这套标准精确映射了本文前六节剖析的每一个实现点:类型、采集器、渲染、测试、文档——说明提案作者对现状代码的边界有完整把握,改动面是「一处机制、五处落点」。
8.3 被否决的理由与权衡
笔记的状态行是理解整个仓库决策逻辑的关键:
Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output.
理由拆解为两层:
- 完整输出恢复是 bash 的真实行为需求。命令输出的头部信息(如构建日志的开头、命令版本信息、错误发生前的上下文)对模型与用户同样有价值,截断尾部策略下的头部只能靠 spill 文件补齐;
- 时机不对。在通用 artifact/blob 服务存在之前就删除 spill 机制,等于先造成能力损失,再等待一个尚未存在的替代品——净效果是用户丢失可恢复的有用输出。
笔记还记录了「我们放弃什么」:
模型或用户无法再从临时文件恢复巨大命令输出中被省略的前缀。在真正的 artifact 服务出现之前,这是可以接受的。当前 spill 路径对这样一个生命周期和权限都未经设计的功能来说,是太多特制机械。
也就是说:提案作者认可当前实现「窄而漏(narrow and leaky)」的批评,但最终权衡认为——在有替代品之前,保留这个「特制机械」是更保守、更不伤害用户的选择。这是典型的「架构整洁度 vs 用户可用性」权衡,仓库选择优先保住可用性。
九、未来方向:从 spill 文件到 artifact/blob 服务
笔记为这条机制规划了演进路线:将来若有通用 artifact/blob 服务,它应当提供:
- 显式所有权:谁创建、谁负责,而不是进程本地随机文件;
- 清理纪律:可管理的保留与回收策略,而不是依赖进程退出或
unlinkSync的偶然性; - UI 呈现:模型与用户可见的工件视图,而不是一条裸的
/tmp/dsh-subprocess-*路径。
届时 bash 工具只需把大输出挂载到该服务,CollectedOutput 的 spillPath 语义可被替换为 artifact 引用,OutputCollector 的临时文件机械可整体删除。在此之前,仓库的立场是:保留现状——正如提案标题所示,这是一份「被否决的简化(rejected simplification)」,它在架构史上留下的价值是完整记录了为什么 spill 机制存在、它保护什么、以及什么条件下可以被替代。
十、总结
CollectedOutput(text/truncated/spillPath)与SubprocessCollect(maxBytes/spill.maxBytes)是理解 bash 输出捕获的类型入口,定义于 packages/subprocess/subprocess/src/types.ts;OutputCollector的「内存尾部 + 头部 spill」策略、'wx'独占创建、close/unlink 失败降级与字节偏移增量读,全部实现在 packages/subprocess/subprocess-local/src/spawn.ts;- 溢出行为由
dsh-bash-local的maxOutputBytes/maxSpillBytes配置驱动,默认 64 KB / 64 MB,见 packages/shell/bash-local/src/index.ts; - 截断通告与路径渲染发生在 packages/shell/tool-bash/src/render.ts,安全落地纪律记录于 docs/defensive-patterns.md;
- 「移除 spill 文件」的提案因其会先于替代方案造成能力损失而被否决(Agent Note),演进方向是通用 artifact/blob 服务。
对想要修改或替换这套机制的开发者,正确的顺序应该是:先让 artifact 服务具备显式所有权、清理与 UI 呈现,再迁移 bash 输出,最后删除 OutputCollector 的临时文件机械——而不是反向为之。