Tool output spill policy
工具输出 spill 策略
Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools. Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to pr
English
Problem
Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools.
Before this change the behavior was uneven. dsh-bash-local already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The tool result retention library owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results.
The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned presentation spill may retain a complete acquired canonical value while replacing only presentation. The canonical tool-output contract owns that split.
Decision
A thin spill storage seam plus a default spill policy plugin, in a new packages/spill/ group:
| Package | Role |
|---|---|
@deepseek-ai/dsh-spill | Interface: ctx.spillStore, vocabulary types, no storage implementation. |
@deepseek-ai/dsh-spill-local | Local backend: private, session-scoped file storage on the host filesystem. |
@deepseek-ai/dsh-spill-policy | Tool-result policy plugin: wraps final text results after dispatch and replaces oversized results with a retained preview plus a spill locator. |
There is no dedicated model-facing Consumer package. The Consumer is the existing ctx.tools execution pipeline: dsh-spill-policy consumes final tool results through the tools/post-execute waterfall, and the model follows the backend-supplied retrieval hint for the returned locator.
Spill seam
The storage seam is minimal: save text and return a locator plus retrieval hint.
interface SpillStore {
saveText(input: SaveTextSpill): Promise<SpillRef>
}
interface SpillSource {
toolName: string
callId: CallId
label: string
}
interface SaveTextSpill {
owner: { sessionId: SessionId }
source: SpillSource
suggestedName: string
content: string
}
type SpillLocator = Branded<'SpillLocator'>
interface SpillRef {
locator: SpillLocator
bytes: number
retrievalHint: string
}
SpillLocator is a branded model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with retrievalHint instead of assuming read is always the right retrieval mechanism. SpillOwner.sessionId is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy.
dsh-spill-local owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning { locator, bytes, retrievalHint }. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at <root>/session-<hash>/<random>-<safeName>, where root is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short sha256(sessionId) prefix, and the leaf is a random hex prefix plus the caller's suggestedName sanitized to one path segment (mirrors the JSONL backend's encodeSegment). The write is open(path, 'wx', 0o600) — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use read or grep on that path.
Spill policy
dsh-spill-policy is a tools/post-execute result transformer with one configuration knob:
interface Config {
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
maxInlineBytes?: number
}
When maxInlineBytes is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
- Let the tool run normally, delegating via
next()so a downstream listener settles the result first. - Flatten the accepted final
ContentBlock[]only when it is entirely plain text; a result with any non-text block is left untouched. - If its UTF-8 byte size is at or below
maxInlineBytes, leave it unchanged. - If it is larger, call
ctx.spillStore.saveText()with the full final text. - Replace the model-facing result with a retained head/tail preview plus the spill reference.
The preview is an implementation default owned by the policy: a head/tail split of maxInlineBytes via the retention library's TextRetainer. Future config can expose preview sizing only after a second deployment needs it.
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
<retained preview>
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
If ctx.spillStore.saveText() fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an isError result or hides the inline result.
The policy skips read to avoid a circular read -> spill file -> read again loop. Additional opt-out configuration is deferred until a real second tool needs it.
Showcase: web_fetch
web_fetch is the first showcase because it returns a naturally large text result and needs no tool-specific spill code. The tool is ordinary:
ctx.tools.register(defineTool({
name: 'web_fetch',
output: {
schema: WEB_FETCH_RESULT_SCHEMA,
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
},
async execute(args, exec) {
const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
return result
},
}))
With dsh-spill-policy configured, a large formatted fetch result is automatically retained and spilled. A deployment demonstrates the behavior by setting the provider resource cap higher than the policy cap:
- id: web-fetch-http
name: '@deepseek-ai/dsh-web-fetch-http'
config:
maxBodyChars: 500000
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
This separation is important. web-fetch-http still owns resource caps (maxResponseBytes, maxBodyChars) to protect network, memory, and decoding work. spill-policy owns only the model-facing context cap after the result already exists. If the provider already returned truncated: true, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
Relationship to retention and early spill
Retention is separate from spill storage:
@deepseek-ai/dsh-output-retentionowns preview mechanics (TextRetainer,ItemRetainer, and omitted metadata).@deepseek-ai/dsh-spillowns saving final text and returning a locator plus retrieval hint.@deepseek-ai/dsh-spill-policyapplies the default final-result policy in the tool pipeline, composing the two.
The final-result policy cannot replace tool-owned early spill. Some useful content is not present in final ToolExecutionResult.content:
bashfinal output is already a tail plus a temp spill path; the complete stdout/stderr streams live in executor files.subagentfinal output is the child final answer, not the child rollout.- Future tools may produce runtime artifacts that are never represented by their final
ToolExecutionResult.content.
Those cases can consume ctx.spillStore directly in later work. They are not part of the first showcase.
Non-goals
- No new model-facing
artifact_readorartifact_searchtool in v1. - No per-tool retention configuration in v1.
- No model-facing timeout/truncation arguments.
- No migration of
readoutput into spill files. - No replacement for provider/resource caps such as
web-fetch-http.maxBodyChars. - No bash temp-file normalization or subagent rollout capture in the first cut.
Deferred
saveFile()/linkOrCopyfor existing executor spill files, needed for bash normalization.- Tool-owned spill for subagent rollouts (
await run.result, read in-process child session beforerun.dispose(), save JSONL). - Per-tool opt-out or per-tool policy declarations if the built-in
readskip is insufficient. - Remote or database storage backends for ACP or remote environments where a local path is not meaningful.
- Cleanup and retention policy for old spill files, likely tied to session cleanup.
Testing
dsh-spillunit tests pin the seam contract: registration asctx.spillStore, one-implementation-per-context, and disposal release.dsh-spill-localunit tests coversaveText,encodeSegmentsanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection.dsh-spill-policyunit tests drive real tools throughctx.tools.execute: disabled-mode no-op, oversized-text replacement, small/non-text passthrough,readskip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preservingadditionalContexts).dsh-tool-webintegration drivesweb_fetchthroughctx.tools.executewith the realspill-localbackend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result.- The
tui-agentexample loadsspill-local+spill-policy, so its keyless Loader/PTY smoke exercises the real load path (the namespace-plugin export shape +inject).
Consequences
The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work.
Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators.
The local-backend value proposition depends on the existing read/grep tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader.
Snapshot gap. No ACP snapshot scenario covers the transcript-visible web_fetch spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a web_fetch spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load tool-web at all). The behavior is covered instead by the dsh-tool-web integration test against a loopback server. Closing the gap is follow-up work: wire tool-web + a seeded fetch target into the ACP example, then record a web-fetch-spill scenario.
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
Alternatives considered
Require each tool to opt in with a retention declaration. Rejected for v1: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single maxInlineBytes deployment knob is enough to prove the shape.
Make tool-results a broad tool-result platform. Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
Use ctx.fs.writeText or the model-facing write tool. Rejected: workspace filesystem writes carry project-file semantics, write/edit policy, observation state, and user-facing side effects. Spill files are runtime artifacts, not model-authored workspace edits. The existing read tool may inspect them later, but creation belongs to the runtime spill seam.
Let web-fetch-http fetch without caps and rely on spill-policy. Rejected: spill-policy runs after the final tool result exists and cannot protect network, memory, or decoding resources. Provider resource caps stay mandatory.
Merge retention into spill. Rejected: retention and spill have different responsibilities. TextRetainer/ItemRetainer decide what preview is kept and what was omitted; spill storage only saves the final text the policy asks it to save.
中文
问题
工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。
这项改动之前的行为并不一致。dsh-bash-local 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。工具结果保留库负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。
其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。规范工具输出约定规定了这项区分。
决策
在新的 packages/spill/ 分组下增加一层轻量 spill 存储 seam 和一个默认 spill 策略插件:
| 包 | 角色 |
|---|---|
@deepseek-ai/dsh-spill | 接口:ctx.spillStore、词汇类型,不包含存储实现。 |
@deepseek-ai/dsh-spill-local | 本地后端:在宿主文件系统中提供私有、会话作用域的文件存储。 |
@deepseek-ai/dsh-spill-policy | 工具结果策略插件:包装分发后的最终文本结果,并以保留预览和 spill 定位符替换超大结果。 |
系统不增加专用的面向模型消费方包。消费方是现有 ctx.tools 执行流水线:dsh-spill-policy 通过 tools/post-execute waterfall(瀑布式事件)使用最终工具结果,模型则按照后端随定位符返回的检索提示读取内容。
spill seam
存储 seam 保持最小化:保存文本,并返回定位符与检索提示。
interface SpillStore {
saveText(input: SaveTextSpill): Promise<SpillRef>
}
interface SpillSource {
toolName: string
callId: CallId
label: string
}
interface SaveTextSpill {
owner: { sessionId: SessionId }
source: SpillSource
suggestedName: string
content: string
}
type SpillLocator = Branded<'SpillLocator'>
interface SpillRef {
locator: SpillLocator
bytes: number
retrievalHint: string
}
SpillLocator 是一个品牌化的模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 retrievalHint 渲染,而不是假定 read 始终是正确的检索机制。SpillOwner.sessionId 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的 spill 定位符,无需复制它们或重新取得所有权;fork 后的新 spill 使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;spill seam 不定义逐会话的清理策略。
dsh-spill-local 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 { locator, bytes, retrievalHint }。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 <root>/session-<hash>/<random>-<safeName>:root 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 sha256(sessionId) 的短前缀;叶节点由随机十六进制前缀与调用方的 suggestedName 组成,后者会被清理成单一路径段(与 JSONL 后端的 encodeSegment 一致)。系统使用 open(path, 'wx', 0o600) 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 read 或 grep。
spill 策略
dsh-spill-policy 是一个 tools/post-execute 结果转换器,只提供一个配置项:
interface Config {
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
maxInlineBytes?: number
}
省略 maxInlineBytes 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
- 让工具正常运行,通过
next()委托,使下游监听器先结算结果。 - 仅当已接受的最终
ContentBlock[]全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。 - 如果 UTF-8 字节大小不超过
maxInlineBytes,保持不变。 - 如果超出上限,使用完整的最终文本调用
ctx.spillStore.saveText()。 - 把模型可见结果替换为保留的首尾预览和 spill 引用。
预览属于策略所有的实现默认值:以 maxInlineBytes 为上限,使用保留库的 TextRetainer 进行首尾分割。只有第二个部署证明有此需求后,未来配置才会公开预览大小。
替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
<retained preview>
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
如果 ctx.spillStore.saveText() 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。spill 失败绝不会把成功的工具调用变为 isError 结果,也不会隐藏内联结果。
策略跳过 read,以避免形成 read -> spill file -> read again 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
示例:web_fetch
web_fetch 是首个示例,因为它天然会返回较大的文本结果,而且无需工具专用的 spill 代码。该工具本身无需特殊处理:
ctx.tools.register(defineTool({
name: 'web_fetch',
output: {
schema: WEB_FETCH_RESULT_SCHEMA,
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
},
async execute(args, exec) {
const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
return result
},
}))
配置 dsh-spill-policy 后,格式化后的大型 fetch 结果会自动保留并 spill。部署通过把提供方资源上限设得高于策略上限来展示此行为:
- id: web-fetch-http
name: '@deepseek-ai/dsh-web-fetch-http'
config:
maxBodyChars: 500000
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
这项分离很重要。web-fetch-http 仍负责资源上限(maxResponseBytes、maxBodyChars),用来保护网络、内存和解码工作。spill-policy 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 truncated: true,spill 文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
与保留和提前 spill 的关系
保留与 spill 存储相互独立:
@deepseek-ai/dsh-output-retention负责预览机制(TextRetainer、ItemRetainer和省略元数据)。@deepseek-ai/dsh-spill负责保存最终文本,并返回定位符与检索提示。@deepseek-ai/dsh-spill-policy在工具流水线中应用默认的最终结果策略,将前两者组合起来。
最终结果策略不能取代由工具负责的提前 spill。部分有用内容并不存在于最终 ToolExecutionResult.content 中:
bash的最终输出已经是尾部内容加临时 spill 路径;完整的 stdout/stderr 流位于执行器文件中。subagent的最终输出是 subagent 的最终回答,而不是 subagent 的执行轨迹。- 未来的工具可能生成从未出现在最终
ToolExecutionResult.content中的运行时产物。
这些场景可以在后续工作中直接使用 ctx.spillStore,不属于首个示例的范围。
非目标
- v1 不增加面向模型的
artifact_read或artifact_search工具。 - v1 不增加逐工具的保留配置。
- 不增加面向模型的超时/截断参数。
- 不把
read输出迁移到 spill 文件。 - 不取代
web-fetch-http.maxBodyChars等提供方/资源上限。 - 第一版不统一 bash 临时文件,也不采集 subagent 执行轨迹。
延后事项
- 用于现有执行器 spill 文件的
saveFile()/linkOrCopy,这是统一 bash 行为所必需的。 - 由工具负责的 subagent 执行轨迹 spill(
await run.result,在run.dispose()前读取进程内子会话,保存 JSONL)。 - 如果内置的
read跳过规则不足,再增加逐工具选择退出或逐工具策略声明。 - 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。
- 旧 spill 文件的清理和保留策略,很可能与会话清理绑定。
测试
dsh-spill单元测试锁定 seam 约定:注册为ctx.spillStore、每个上下文只允许一种实现,并在 dispose(资源释放)时释放。dsh-spill-local单元测试覆盖saveText、encodeSegment清理(分隔符/波浪号/完整路径段的点/空值)、会话哈希目录、仅所有者权限、每次保存生成不同路径、配置根目录/私有根目录,以及存储失败时的拒绝。dsh-spill-policy单元测试通过ctx.tools.execute驱动真实工具:禁用模式下无操作、替换超大文本、小结果/非文本结果保持不变、跳过read、尽力回退(保存失败/无后端/无所有者),以及下游组合(限制已替换结果、保留additionalContexts)。dsh-tool-web集成测试驱动web_fetch,其实际执行路径经过ctx.tools.execute,并使用真实的spill-local后端与策略;测试证明只有刻意加入的 spill 提示会改变模型可见文本,而 spill 文件保存完整的格式化结果。tui-agent示例加载spill-local与spill-policy,因此其无密钥 Loader/PTY 冒烟测试会执行真实加载路径(namespace-plugin 导出形态与inject)。
影响
默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果 spill 而不是提前 spill,因此可以接受这一限制;由工具负责的提前 spill 仍属于后续工作。
本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。
本地后端的价值取决于现有 read/grep 工具能否检查返回的本地路径,即使 spill 目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地 spill 路径,或改用检索提示指向受支持读取器的非文件 spill 后端。
快照缺口。 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 web_fetch spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 web_fetch spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 tool-web)。该行为改由 dsh-tool-web 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 tool-web 和预置 fetch 目标接入 ACP 示例,然后录制 web-fetch-spill 场景。
如果策略开始负责工具专用语义,就会膨胀得过大。它的范围保持狭窄:只处理纯文本最终结果。由工具负责的提前 spill 仍留作未来工作。
考虑过的替代方案
要求每个工具通过保留声明选择加入。 v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 maxInlineBytes 部署配置项即可验证该形态。
把 tool-results 建成宽泛的工具结果平台。 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
使用 ctx.fs.writeText 或面向模型的 write 工具。 不予采纳:工作区文件系统写入带有项目文件语义、写入/编辑策略、观察状态和面向用户的副作用。spill 文件是运行时产物,不是由模型编写的工作区改动。现有 read 工具之后可以检查它们,但创建操作属于运行时 spill seam。
让 web-fetch-http 不受限地抓取,只依靠 spill-policy。 不予采纳:spill-policy 在最终工具结果已经存在之后才运行,无法保护网络、内存或解码资源。提供方资源上限仍然必须存在。
把保留合并进 spill 机制。 不予采纳:保留与 spill 职责不同。TextRetainer/ItemRetainer 决定保留哪部分预览、又省略了什么;spill 存储只负责保存策略要求的最终文本。