青雲的博客
深入浅出 Pi 第六部:工具与扩展怎样进入运行时 第 33 章

Bash 输出太长时,完整结果去了哪里

追踪 BashOperations、OutputAccumulator 与 truncateTail,说明流式输出如何限内存、何时落临时文件,以及超时和 abort 如何保留已产生的尾部。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

运行 npm test 时,最后几十行通常比开头更有价值:失败堆栈、退出码、汇总都在尾部。但把几兆字节 stdout/stderr 原样塞进一次 tool result,会同时挤占进程内存、终端渲染和模型上下文。Pi 的选择不是简单 slice(-50000)。它一边接收字节,一边维护可显示尾部;真正越界后,再把完整原始流转存到临时文件。

默认限制是 2000 行与 50KB,任一先达到就截断。通用 truncateHead() 适合读文件开头,Bash 使用 truncateTail() 保留结尾;后者在“最后一整行本身超过 50KB”时允许返回部分行,并用 lastLinePartial 明示这个例外。

Accumulator 同时记总量和显示窗口

OutputAccumulator 持有 streaming TextDecoder、原始 chunks、已解码尾部、行数和字节数。append(Buffer){ stream: true } 解码,避免一个 UTF-8 字符被 chunk 边界切开时产生替换字符;显示尾部超过滚动上限后会裁掉较早内容,同时调整到合法 UTF-8 起点。总行数与总字节数单独累计,因此即使旧显示内容被丢掉,截断提示仍能报告原始规模。

在未越界前,原始 chunks 暂存在内存。第一次判断 totalRawBytestotalDecodedBytestotalLines 超限时,ensureTempFile() 创建形如 /tmp/pi-output-<random>.log 的文件,先补写此前 chunks,再把后续 Buffer 直接写入 stream。这一点很关键:Pi 保存的是收到的原始字节,不是已被 tail trimming 处理的字符串。

flowchart LR
  accTitle: Bash 输出的双通道保存
  accDescr: stdout 与 stderr chunk 进入同一个 accumulator;有界 tail 用于 partial update 和最终 tool result,越界后完整原始字节写入临时日志。
  PROC["child process stdout + stderr"] --> APPEND["OutputAccumulator.append"]
  APPEND --> TAIL["bounded decoded tail"]
  APPEND --> COUNT["total lines + bytes"]
  COUNT -->|"over limit"| FILE["temporary full-output log"]
  TAIL --> UPDATE["throttled partial update"]
  TAIL --> RESULT["final tool result"]
  FILE --> META["fullOutputPath"]
  META --> RESULT

图里 stdout 与 stderr 汇入同一个 accumulator,是因为本地 operations 对两个流都调用传入的 onData。这保留进程交付给 Node 回调的大体到达顺序,却不是分别可查询的 stdout/stderr 档案。若调用方需要区分通道,必须替换 BashOperations 或设计新的 tool result,不能从临时日志反推每个字节来自哪条 fd。

Partial update 与最终结果不是同一快照

Bash executor 收到 chunk 后 output.append(data),再按节流间隔安排 onUpdate。partial snapshot 不要求持久化临时文件;最终 finishOutput() 才停止接收、flush decoder、发最后一次 update,并调用 snapshot({ persistIfTruncated: true }),随后等待临时 stream 关闭。这样最终结果若给出路径,文件已经完成写入,而不是仍在后台 flush。

最终文本有三种提示:按行截断时写显示区间;按字节截断时额外写 50KB limit;最后一行过长时写该行完整大小。结构化 details 同时携带 truncationfullOutputPath,所以 TUI 可以渲染提示,SDK 调用方也不必解析英文文本。不过模型主要看到的仍是 content 文本,路径因此也被写进提示。

可以用一条不会执行 Pi extension 的静态检查核对阈值和保存点:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" grep -n 'DEFAULT_MAX_LINES\|DEFAULT_MAX_BYTES' v0.83.0 -- \
  packages/coding-agent/src/core/tools/truncate.ts
git -C "$repo" grep -n 'persistIfTruncated: true\|Full output:' v0.83.0 -- \
  packages/coding-agent/src/core/tools/bash.ts

这里不把命令输出写成固定行号快照,是为了让读者在同一个 tag 内直接看到定义与消费点。章节旁的 SourceEvidence 才是本册锁定的行号合同。

失败、超时和 abort 仍要先收尾输出

ops.exec() 抛出 abortedtimeout:<seconds> 时,executor 先 finishOutput(),再把已经产生的尾部附到错误信息。非零退出码也先完成相同收尾,再抛出包含输出和 exit code 的错误。于是“tool call 失败”不等于“输出被扔掉”;失败前的诊断仍能进入 error result,超长时也有完整日志路径。

仍要保留两个运维边界。第一,临时文件没有在这条执行链里自动删除;它位于操作系统 temp dir,可能包含密钥、构建日志或用户数据。展示 fullOutputPath 是恢复完整证据的便利,也把清理与访问控制留给宿主环境。第二,只有越界输出才保证 spill file;短输出只存在于 tool result 和会话后续持久化路径,不能预期每次 Bash 都有独立日志文件。

OutputAccumulator 解决的是“一条已获准执行的命令产生太多输出”。它不决定命令是否可信,也不发现 AGENTS.md、Skills 或扩展。下一章回到运行前资源层,解释这些文本与模板从哪些目录进入 system prompt,以及同名资源冲突时谁先留下。