feat: add canonical typed tool outputs

This commit is contained in:
Tianyi Cui
2026-07-21 03:08:35 +08:00
parent 8500974fd4
commit 66c36e7325
173 changed files with 3298 additions and 954 deletions
+4 -4
View File
@@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
## Behavior
1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted).
2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through).
2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through).
3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched.
4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged.
5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap:
@@ -26,11 +26,11 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result.
**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved.
## Scope
The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md).
The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md).
## Model Experience
@@ -38,7 +38,7 @@ The policy sees only the FINAL formatted tool result — not a tool's internal r
#### What the model sees
Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>)`; storage or ownership failures leave the original result visible.
Results at or below `maxInlineBytes`, nested results, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text surface result becomes a bounded head/tail preview followed by `(Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>)`; storage or ownership failures leave the original result visible.
#### Token effect
+10 -4
View File
@@ -16,15 +16,20 @@
* - Plain-text results only: a result carrying any non-text block is left
* untouched (the policy knows only the final formatted text, not tool
* internals).
* - Nested composite calls are skipped; only their outer surface result may
* become model-facing and spillable.
* - Accepted value replacements pass through for registry revalidation and
* rendering; this presentation policy cannot also replace content in the
* same mutually exclusive decision.
* - `read` is skipped to avoid a `read → spill → read again` loop.
* - Best-effort: no session owner, no `ctx.spillStore` backend, or a save
* failure ⇒ log and return the original result. A spill failure must NEVER
* turn a successful tool call into an `isError` or hide the inline result.
*
* It COMPOSES with other post-execute listeners: it delegates via `next()` and
* bounds the resulting `accept` content, so a hook that replaced the content
* still has its replacement bounded, and a `block` decision passes through
* unchanged.
* bounds the resulting content projection, so a hook that replaced content
* still has its replacement bounded, while value replacements and `block`
* decisions pass through unchanged.
*
* @module @deepseek-ai/dsh-spill-policy
*/
@@ -109,7 +114,8 @@ export function apply(ctx: Context, config: Config): void {
// accepted plain-text results, never corrective feedback.
const decision = await next()
// Skip `read` to avoid a read → spill → read again loop.
if (decision.kind !== 'accept' || exec.name === 'read') return decision
if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value')
|| exec.parent !== undefined || exec.name === 'read') return decision
const content = decision.content ?? result.content
const text = flattenPlainText(content)
@@ -15,8 +15,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
@@ -39,7 +39,7 @@ class StubStore extends SpillStore {
/** A tool returning `text` verbatim (name configurable so we can register `read`). */
function textTool(name: string, text: string) {
return defineTool({
return defineContentToolFixture({
name,
description: name,
parameters: {},
@@ -159,7 +159,7 @@ describe('oversized plain-text replacement', () => {
it('leaves a result with a non-text block unchanged', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 5 })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'mixed',
description: 'mixed',
parameters: {},
@@ -183,6 +183,21 @@ describe('read skip', () => {
})
})
describe('nested-call skip', () => {
it('leaves nested composite results complete and spillable only through their outer call', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
const body = 'x'.repeat(1000)
ctx.tools.register(textTool('nested', body))
const nested = {
...exec('nested'),
parent: Symbol('outer') as ToolExecutionToken,
}
const result = await ctx.tools.execute(nested)
expect(textOf(result.content)).toBe(body)
expect(spill?.saves).toHaveLength(0)
})
})
describe('best-effort fallback', () => {
it('keeps the original result when saveText fails', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
@@ -238,6 +253,21 @@ describe('composition', () => {
expect(textOf(result.content)).toContain('Full formatted result stored at')
expect(result.additionalContexts).toEqual([context])
})
it('passes a downstream value replacement through for registry rendering', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
const replacement = [{ type: 'text' as const, text: 'z'.repeat(500) }]
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: replacement }))
ctx.tools.register(textTool('small', 'tiny'))
const result = await ctx.tools.execute(exec('small'))
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected replacement success')
expect(result.value).toEqual(replacement)
expect(textOf(result.content)).toBe('z'.repeat(500))
expect(spill?.saves).toHaveLength(0)
})
})
describe('cap invariant', () => {