Merge branch 'codex/code-mode-typed-results' into codex/code-mode-complete-result-card
# Conflicts: # .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
This commit is contained in:
@@ -23,7 +23,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
|
||||
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
|
||||
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions cross by structured clone after lossless-JSON validation and have no byte cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
|
||||
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure diagnostic. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
|
||||
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure diagnostic. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
|
||||
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
|
||||
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
@@ -106,6 +107,25 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Resolve after a worker pipe emits all queued data, or closes/errors during termination. */
|
||||
function waitForPipeDrain(stream: Readable): Promise<void> {
|
||||
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const done = (): void => {
|
||||
stream.off('end', done)
|
||||
stream.off('close', done)
|
||||
stream.off('error', done)
|
||||
resolve()
|
||||
}
|
||||
stream.once('end', done)
|
||||
stream.once('close', done)
|
||||
stream.once('error', done)
|
||||
// Close the event-registration race if termination finished between the
|
||||
// initial state check and the listeners above.
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
|
||||
* can post anything — `null`, primitives, objects with poisoned fields — so
|
||||
@@ -335,13 +355,20 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const logs: string[] = []
|
||||
const strayLogs: string[] = []
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
let terminalOverride: CodeRunResult | undefined
|
||||
|
||||
// No settled guard: `finish` snapshots the arrays when it resolves, so
|
||||
// a chunk flushing after settlement mutates only the discarded buffers,
|
||||
// and the ledger bounds that growth until the pipes close.
|
||||
// Pipe and message-port delivery are independent. Continue bounded pipe
|
||||
// capture after a terminal message while worker termination drains bytes
|
||||
// that were already queued; `finish` materializes the result only after
|
||||
// termination completes.
|
||||
const captureStray = (chunk: Buffer): void => {
|
||||
if (terminalOverride !== undefined) return
|
||||
const text = chunk.toString('utf8')
|
||||
if (!settled && !output.admit(text, strayLogs)) finish(output.limit([...logs, ...strayLogs, text]))
|
||||
if (!output.admit(text, strayLogs)) {
|
||||
const limited = output.limit([...logs, ...strayLogs, text])
|
||||
terminalOverride = limited
|
||||
finish(() => limited)
|
||||
}
|
||||
}
|
||||
worker.stdout.on('data', captureStray)
|
||||
worker.stderr.on('data', captureStray)
|
||||
@@ -350,14 +377,20 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// logs captured before timeout, abort, or failure remain in the result.
|
||||
let finishResolve!: () => void
|
||||
const finished = new Promise<void>((done) => { finishResolve = done })
|
||||
const finish = (result: CodeRunResult): void => {
|
||||
const finish = (finalize: () => CodeRunResult): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearInterval(eluTimer)
|
||||
clearTimeout(wallTimer)
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
this.live.delete(live)
|
||||
void worker.terminate().then(() => {
|
||||
// Let the poll phase deliver pipe bytes already queued independently
|
||||
// of the terminal port message before termination closes the streams.
|
||||
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(worker.stdout)
|
||||
const stderrDrained = waitForPipeDrain(worker.stderr)
|
||||
await Promise.all([worker.terminate(), stdoutDrained, stderrDrained])
|
||||
const result = terminalOverride ?? finalize()
|
||||
finishResolve()
|
||||
resolve(result)
|
||||
})
|
||||
@@ -365,22 +398,24 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
|
||||
const onDone = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'done') return
|
||||
const captured = [...logs, ...strayLogs]
|
||||
if (message.error) {
|
||||
finish(output.failure(captured, message.error))
|
||||
const error = message.error
|
||||
finish(() => output.failure([...logs, ...strayLogs], error))
|
||||
return
|
||||
}
|
||||
if (message.value === undefined) {
|
||||
finish(output.success(captured))
|
||||
finish(() => output.success([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
// The worker-thread boundary has already structured-cloned this
|
||||
// hostile value, so accessors and proxies cannot survive to throw
|
||||
// during the lossless-JSON snapshot.
|
||||
const value = snapshotJsonValue(message.value) as CodeJsonValue | undefined
|
||||
finish(value === undefined
|
||||
? output.failure(captured, { kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
: output.success(captured, value))
|
||||
if (value === undefined) {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
|
||||
} else {
|
||||
finish(() => output.success([...logs, ...strayLogs], value))
|
||||
}
|
||||
}
|
||||
|
||||
const onCall = (message: WorkerToHost): void => {
|
||||
@@ -438,21 +473,25 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const message = parseWorkerMessage(raw)
|
||||
if (!message) return
|
||||
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
|
||||
finish(output.limit([...logs, ...strayLogs, message.text]))
|
||||
const limited = output.limit([...logs, ...strayLogs, message.text])
|
||||
terminalOverride = limited
|
||||
finish(() => limited)
|
||||
return
|
||||
}
|
||||
if (message.type === 'output-limit' && !settled) {
|
||||
finish(output.limit([...logs, ...strayLogs]))
|
||||
const limited = output.limit([...logs, ...strayLogs])
|
||||
terminalOverride = limited
|
||||
finish(() => limited)
|
||||
return
|
||||
}
|
||||
onCall(message)
|
||||
onDone(message)
|
||||
})
|
||||
worker.on('error', (error: Error) => {
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
|
||||
})
|
||||
worker.on('exit', (exitCode: number) => {
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
|
||||
})
|
||||
|
||||
// The compute budget reads the worker's own measured busy time, so a
|
||||
@@ -461,21 +500,21 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const eluTimer = setInterval(() => {
|
||||
const elu = worker.performance.eventLoopUtilization()
|
||||
if (elu.active > this.config.computeMs) {
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
|
||||
}
|
||||
}, ELU_POLL_INTERVAL_MS)
|
||||
const wallTimer = setTimeout(() => {
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
}, this.config.maxWallMs)
|
||||
const onAbort = (): void => {
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
}
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const live: LiveRun = {
|
||||
worker,
|
||||
finished,
|
||||
settle: (failure: CodeRunFailure) => { finish(output.failure([...logs, ...strayLogs], failure)) },
|
||||
settle: (failure: CodeRunFailure) => { finish(() => output.failure([...logs, ...strayLogs], failure)) },
|
||||
}
|
||||
this.live.add(live)
|
||||
})
|
||||
|
||||
@@ -333,6 +333,24 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
|
||||
expect(result.logs[1]?.length).toBeGreaterThan(0)
|
||||
expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true)
|
||||
}, 15_000)
|
||||
|
||||
it('drains pipe output queued before terminal worker teardown completes', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 200_000 })
|
||||
const payload = `late-pipe-${'x'.repeat(100_000)}`
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
|
||||
write('late-pipe-' + 'x'.repeat(100_000));
|
||||
parentPort.postMessage({ type: 'done', value: 'done' });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs.join('') === payload).toBe(true)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
|
||||
@@ -89,7 +89,7 @@ ctx.tools.register(defineTool({
|
||||
|
||||
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default.
|
||||
|
||||
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. Extra parameter keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
|
||||
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
|
||||
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details.
|
||||
|
||||
|
||||
@@ -37,9 +37,9 @@ export interface FileTextLine {
|
||||
export interface WindowResult {
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
/** Exact total line count in the file. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
/** Whether selected output hit the byte cap. */
|
||||
truncatedByBytes: boolean
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ export interface FileReadOutcome {
|
||||
offset: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
/** Exact total line count in the file. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
/** Whether selected output hit the byte cap. */
|
||||
truncatedByBytes?: true
|
||||
}
|
||||
|
||||
@@ -60,11 +60,10 @@ interface WindowAccumulator {
|
||||
totalLines: number
|
||||
outputBytes: number
|
||||
truncatedByBytes: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false }
|
||||
}
|
||||
|
||||
function truncateLine(line: string, maxLineLength: number): string {
|
||||
@@ -77,13 +76,12 @@ function lineByteSize(line: string, currentLineCount: number): number {
|
||||
|
||||
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine, request.maxLineLength)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > request.maxBytes) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
}
|
||||
acc.outputBytes += bytes
|
||||
@@ -102,8 +100,9 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing
|
||||
* `FS_NOT_FOUND` when the requested offset is past EOF.
|
||||
* Build one window from streamed or whole-file chunks, enforcing line and byte caps while still
|
||||
* scanning to an exact total line count, and throwing `FS_NOT_FOUND` when the requested offset is
|
||||
* past EOF.
|
||||
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
|
||||
* @param request - the resolved window; the caller has already applied its defaults and caps.
|
||||
* @param displayPath - the caller-facing path used in the offset-out-of-range error.
|
||||
@@ -137,7 +136,6 @@ export async function buildWindow(
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('buildWindow', () => {
|
||||
it('caps output at a custom maxBytes', async () => {
|
||||
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -105,6 +106,7 @@ describe('buildWindow', () => {
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
|
||||
expect(result.totalLines).toBe(2000)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -572,6 +572,9 @@ describe('read caps are plugin config', () => {
|
||||
const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
|
||||
fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected read success')
|
||||
expect(result.value).toMatchObject({ totalLines: 3 })
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
expect(text(result)).not.toContain('cccc')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user