/** * The spill-policy PLUGIN: a `tools/post-execute` result transformer that keeps * oversized plain-text tool results out of the model's context. When a final * result's UTF-8 size exceeds `maxInlineBytes`, it saves the FULL text to a * session-scoped spill artifact (`ctx.spillStore`) and replaces the * model-facing result with a bounded head/tail preview plus the backend's * locator and retrieval guidance. * * It registers NO service and owns NO storage or preview mechanics: preview is * `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`. * The policy only decides WHEN to spill and composes the notice. * * ## Deliberately narrow * * - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op). * - 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: 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 */ import type { Context } from 'cordis' import z from 'schemastery' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention' import type { Omitted } from '@deepseek-ai/dsh-retention' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { SessionId } from '@deepseek-ai/dsh-session' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' import type { SpillPolicyExec } from './types.ts' export type { SpillPolicyExec } from './types.ts' /** Plugin config. */ export interface Config { /** * The model-facing context cap for a plain-text tool result, in UTF-8 bytes. * Omitted disables the policy entirely (no-op). When set, a result larger than * this is spilled and replaced with a preview derived from this same budget. */ maxInlineBytes?: number } /** Cordis plugin name used by loader diagnostics. */ export const name = 'spill-policy' /** Require the tool registry (its `tools/post-execute` waterfall is the seam we transform). */ export const inject = ['tools'] export const Config: z = z.object({ maxInlineBytes: z.number(), }) /** All-text content flattened to one UTF-8 string, or `undefined` if any block is non-text. */ function flattenPlainText(content: ContentBlock[]): string | undefined { let text = '' for (const block of content) { if (block.type !== 'text') return undefined text += block.text } return text } /** The owning session id, or `undefined` for a call with no agent (a direct/test call). */ function ownerSessionId(exec: ToolExecution): SessionId | undefined { return (exec as SpillPolicyExec).agent?.session.header.id } /** Build the bounded head/tail preview for `text`, splitting `budget` bytes across the two ends. */ function preview(text: string, budget: number): { text: string; omitted: Omitted } { const headBytes = Math.ceil(budget / 2) const tailBytes = Math.floor(budget / 2) const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes }) retainer.push(text) const kept = retainer.finish() return { text: kept.text, omitted: kept.omittedBytes } } /** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */ function spillNotice(omitted: Omitted, ref: SpillRef): string { const omission = describeOmitted(omitted, 'bytes') return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})` } export function apply(ctx: Context, config: Config): void { const maxInlineBytes = config.maxInlineBytes // Omitted ⇒ no automatic spill policy: register nothing at all. if (maxInlineBytes === undefined) return // Validate at LOAD, not per call: a negative/fractional cap would reach // TextRetainer's assertBudget and throw, turning every oversized-result call // into an isError. A bad config must fail the deployment, not the tool. if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) { throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`) } ctx.on('tools/post-execute', async (exec, result, next): Promise => { // Delegate first so a downstream listener (e.g. a hook) settles the result; // we bound whatever it accepted. A block passes through — spill only shapes // 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' || Object.hasOwn(decision, 'value') || exec.parent !== undefined || exec.name === 'read') return decision const content = decision.content ?? result.content const text = flattenPlainText(content) if (text === undefined) return decision const totalBytes = Buffer.byteLength(text, 'utf8') if (totalBytes <= maxInlineBytes) return decision const sessionId = ownerSessionId(exec) if (sessionId === undefined) { ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`) return decision } const spillStore = ctx.get('spillStore') if (!spillStore) { ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result') return decision } const save: SaveTextSpill = { owner: { sessionId }, source: { toolName: exec.name, callId: exec.callId, label: 'result' }, suggestedName: `${exec.name}.txt`, content: text, } let ref: SpillRef try { ref = await spillStore.saveText(save) } catch (error: unknown) { // Best-effort: a storage failure (permissions, ENOSPC, backend down) must // never fail the call or hide the result — keep the original inline. ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`) return decision } // Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement // (preview + blank line + notice) never exceeds the documented cap — a naive // preview that spent the whole budget then appended the notice could be // larger than the cap, and for a marginally-over result even larger than the // original. The reservation uses a notice priced at the worst-case omission // count (the full byte total): its digit count bounds the real count's, so // the reserved size is a safe upper bound and the final notice is never // longer than what we reserved. `\n\n` is the 2-byte join. const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 const previewBudget = Math.max(0, maxInlineBytes - reserve) const { text: previewText, omitted } = preview(text, previewBudget) const notice = spillNotice(omitted, ref) const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice // Invariant: the policy NEVER emits a replacement larger than the cap. When // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root), // there is no within-cap replacement, so keep the inline result — spilling // would break the advertised context cap. (A within-cap replacement is // always smaller than the original, which is > cap by the entry condition, // so this one check subsumes "not smaller than the original" too. The spill // file already written is a harmless orphan; cleanup is deferred.) if (Buffer.byteLength(replacedText, 'utf8') > maxInlineBytes) { ctx.logger.warn(`spill-policy: spill notice for ${exec.name} exceeds maxInlineBytes; keeping the inline result`) return decision } const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }] return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } }, { prepend: true }) }