mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
182 lines
8.7 KiB
TypeScript
182 lines
8.7 KiB
TypeScript
/**
|
|
* 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<Config> = 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<PostToolDecision> => {
|
|
// 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 })
|
|
}
|