Merge branch 'codex/canonical-tool-output' into codex/code-mode-typed-results

This commit is contained in:
Tianyi Cui
2026-07-22 01:13:56 +08:00
12 changed files with 57 additions and 28 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
## Scope
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).
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. The generic policy prepends its waterfall listener, then delegates, so ordinary tool-owned asynchronous projections complete before generic byte bounding regardless of plugin load order. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md).
## Model Experience
+6 -5
View File
@@ -26,10 +26,11 @@
* 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 content projection, so a hook that replaced content
* still has its replacement bounded, while value replacements and `block`
* decisions pass through unchanged.
* It COMPOSES with other post-execute listeners: its prepended listener
* delegates via `next()` and bounds the resulting content projection, so
* tool-owned asynchronous projection runs before generic bounding, a hook that
* replaced content still has its replacement bounded, and value replacements
* and `block` decisions pass through unchanged.
*
* @module @deepseek-ai/dsh-spill-policy
*/
@@ -176,5 +177,5 @@ export function apply(ctx: Context, config: Config): void {
}
const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }]
return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} }
})
}, { prepend: true })
}
@@ -16,7 +16,7 @@ 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, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision, 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'
@@ -61,7 +61,11 @@ function exec(name: string, session = 's1'): ToolExecution {
* Build a context with tools + the policy, and optionally a spill backend.
* Returns the context and the backend handle (undefined when `withSpill` false).
*/
async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited<ReturnType<Context['plugin']>> }> {
async function setup(
config: SpillPolicy.Config,
withSpill = true,
beforePolicy?: (ctx: Context) => void,
): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited<ReturnType<Context['plugin']>> }> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -70,6 +74,7 @@ async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ct
await ctx.plugin(StubStore)
spill = ctx.spillStore as StubStore
}
beforePolicy?.(ctx)
const fiber = await ctx.plugin(SpillPolicy, config)
return { ctx, fiber, ...spill ? { spill } : {} }
}
@@ -272,6 +277,26 @@ describe('best-effort fallback', () => {
})
describe('composition', () => {
it('wraps an earlier tool-owned projection before applying the generic cap', async () => {
let downstreamDecision: PostToolDecision | undefined
const { ctx, spill } = await setup({ maxInlineBytes: 200 }, true, (target) => {
target.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
downstreamDecision = await next()
return {
kind: 'accept',
content: [{ type: 'text', text: `first page\n\nFull canonical result stored at /spill/search-results.txt.\n${'z'.repeat(500)}` }],
}
})
})
ctx.tools.register(textTool('search', 'initial capped page'))
const result = await ctx.tools.execute(exec('search'))
expect(downstreamDecision).toEqual({ kind: 'accept' })
expect(spill?.saves[0]?.content).toContain('Full canonical result stored at /spill/search-results.txt.')
expect(textOf(result.content)).toContain('Full formatted result stored at')
})
it('bounds content a downstream post-execute listener replaced', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
// A later-registered listener replaces the (small) tool result with a big one;