feat: add canonical typed tool outputs
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user