Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,4 +1205,85 @@ describe('runAgent', () => {
expect(result.stopReason).toBe('end_turn');
});
});

describe('tool-output spill', () => {
const floodTool: ToolHandler = {
name: 'Flood',
definition: {
name: 'Flood',
description: 'returns a lot of text',
inputSchema: { type: 'object', properties: {} },
},
execute: () => Promise.resolve({ content: `HEAD${'.'.repeat(200_000)}TAIL` }),
};
const floodCall = (): ToolUseBlock => ({
type: 'tool_use',
id: 'call_flood',
name: 'Flood',
input: {},
});

/** The tool result the loop actually handed back to the provider. */
function resultText(history: StoredMessage[]): string {
for (const msg of history) {
for (const block of msg.content) {
if (typeof block !== 'string' && block.type === 'tool_result') return block.content;
}
}
expect.fail('no tool result in history');
}

it('bounds what a tool can put into the model context', async () => {
const result = await runAgent({
provider: new MockProvider([toolUse('flooding', floodCall()), endTurn('done')]),
tools: new ToolRegistry([floodTool]),
systemPrompt: '',
userMessage: 'go',
model: 'deepseek-chat',
cwd,
spillThresholdChars: 1_000,
});

const text = resultText(result.history);
expect(text.length).toBeLessThan(2_000);
expect(text.startsWith('HEAD')).toBe(true);
expect(text.trimEnd().endsWith('TAIL')).toBe(true);
});

it('saves the omitted output where the model can read it back', async () => {
const manager = new SessionManager({ root: sessionsRoot });
const session = await manager.create(cwd);
const result = await runAgent({
provider: new MockProvider([toolUse('flooding', floodCall()), endTurn('done')]),
tools: new ToolRegistry([floodTool]),
systemPrompt: '',
userMessage: 'go',
model: 'deepseek-chat',
cwd,
session: { manager, id: session.id },
spillThresholdChars: 1_000,
});

const text = resultText(result.history);
const match = /Full output saved to:\n(.+)\n/.exec(text);
expect(match).not.toBeNull();
const saved = await fs.readFile((match as RegExpExecArray)[1], 'utf8');
expect(saved.length).toBe(200_008);
expect(saved.startsWith('HEAD')).toBe(true);
expect(saved.endsWith('TAIL')).toBe(true);
});

it('says so plainly when there is nowhere to save it', async () => {
const result = await runAgent({
provider: new MockProvider([toolUse('flooding', floodCall()), endTurn('done')]),
tools: new ToolRegistry([floodTool]),
systemPrompt: '',
userMessage: 'go',
model: 'deepseek-chat',
cwd,
spillThresholdChars: 1_000,
});
expect(resultText(result.history)).toContain('was not saved');
});
});
});
40 changes: 40 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { HookDispatcher } from './hooks/index.js';
import type { Mode } from './types.js';
import type { Provider } from './providers/types.js';
import { resolveRuntimePolicy } from './runtime/policy.js';
import { applySpillPolicy, type SpillStore } from './spill/index.js';
// NOTE: reminders + sessions are lazy-loaded inside the loop so a browser
// build (Tauri renderer) that doesn't use them avoids pulling node:fs at
// module-load time. See `loadRemindersIfEnabled` and `appendSessionIfSet`.
Expand Down Expand Up @@ -109,6 +110,14 @@ export interface RunAgentOptions {
/** Inject system reminders before the user message (date, todos, etc).
* Pass `false` to disable; pass a partial list to limit which builders run. */
systemReminders?: false | { enabled?: ReminderType[] };
/** Model-visible ceiling per tool result, in code units. See `spill/policy.ts`. */
spillThresholdChars?: number;
/**
* Where oversized tool output is persisted. Hosts with a session directory
* get the local file store by default; supplying one here overrides that,
* and it is the seam tests use.
*/
spillStore?: SpillStore;
/** Host callback for AskUserQuestion tool. Optional — when absent the tool
* errors. */
askUser?: NonNullable<ToolContext['askUser']>;
Expand Down Expand Up @@ -302,6 +311,29 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
modeSignal,
};

// Spill storage is resolved once, lazily: the local backend imports node:fs,
// which a renderer build has no answer for. A host without one still gets the
// bounded preview — it just cannot offer retrieval.
const resolveSpillStore = async (): Promise<SpillStore | undefined> => {
if (opts.spillStore) return opts.spillStore;
const dir = toolCtx.sessionDir;
if (dir === undefined) return undefined;
try {
const mod = /* @vite-ignore */ './spill/local.js';
const { createLocalSpillStore } = (await import(mod)) as typeof import('./spill/local.js');
return createLocalSpillStore(dir);
} catch {
// No filesystem in this build — preview-only is the documented fallback.
return undefined;
}
};
// Memoized on the promise, not its result: tools run concurrently, and a
// second caller arriving mid-import must wait for the same answer rather than
// observe "not resolved yet" as "no store".
let spillStorePending: Promise<SpillStore | undefined> | undefined;
const spillStore = (): Promise<SpillStore | undefined> =>
(spillStorePending ??= resolveSpillStore());

// Wire the Task tool's sub-agent runner — but only below the recursion cap,
// so a sub-agent can't spawn further sub-agents (it also never gets the Task
// tool, see the denylist below; this is belt-and-suspenders).
Expand Down Expand Up @@ -773,6 +805,14 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
tr = { content: `Error: ${(err as Error).message}`, isError: true };
}

// Every result passes the spill policy on its way to the model, so no
// single tool can flood the context regardless of what it returns.
tr = await applySpillPolicy(tr, {
source: { toolName: toolUse.name, callId: toolUse.id, label: 'result' },
store: await spillStore(),
thresholdChars: opts.spillThresholdChars,
});

// PostToolUse hook (M3) — observation only; can inject additionalContext
if (opts.hooks) {
await opts.hooks.dispatch({
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,21 @@ export {
type CompactionResult,
} from './compaction/index.js';

// Tool-output spill — the central bound on model-visible tool output.
export {
applySpillPolicy,
boundText,
BoundedCapture,
DEFAULT_SPILL_THRESHOLD_CHARS,
type BoundedText,
type SaveTextRequest,
type SpillOutcome,
type SpillPolicyOptions,
type SpillRef,
type SpillSource,
type SpillStore,
} from './spill/index.js';

// Agent loop's approval callback type (M3b)
export type { ApprovalCallback, ApprovalDecision } from './agent.js';

Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/spill/bound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { boundText, BoundedCapture } from './bound.js';

describe('boundText', () => {
it('returns the whole string when it fits', () => {
expect(boundText('hello', 10, 10)).toEqual({ head: 'hello', tail: '', omitted: 0 });
});

it('keeps both ends and reports the gap', () => {
const r = boundText('abcdefghij', 3, 2);
expect(r.head).toBe('abc');
expect(r.tail).toBe('ij');
expect(r.omitted).toBe(5);
});

it('accounts for every character', () => {
const text = 'x'.repeat(1000);
const r = boundText(text, 100, 250);
expect(r.head.length + r.tail.length + r.omitted).toBe(text.length);
});

it('never splits a surrogate pair', () => {
// Each emoji is two UTF-16 code units; cutting at an odd index would leave
// a lone surrogate that renders as a replacement character.
const text = '😀'.repeat(20);
const r = boundText(text, 3, 3);
expect(r.head).toBe('😀');
expect(r.tail).toBe('😀');
expect([...r.head].every((c) => c === '😀')).toBe(true);
expect([...r.tail].every((c) => c === '😀')).toBe(true);
});

it('supports a zero-length tail', () => {
const r = boundText('abcdef', 2, 0);
expect(r).toEqual({ head: 'ab', tail: '', omitted: 4 });
});
});

describe('BoundedCapture', () => {
it('reproduces the input exactly when under the limits', () => {
const c = new BoundedCapture(10, 10);
c.push('abc');
c.push('def');
expect(c.text()).toBe('abcdef');
expect(c.omitted).toBe(0);
expect(c.total).toBe(6);
});

it('keeps the head and the tail once it overflows', () => {
const c = new BoundedCapture(3, 3);
for (const ch of 'abcdefghijklmnop') c.push(ch);
expect(c.total).toBe(16);
expect(c.omitted).toBe(10);
const text = c.text();
expect(text.startsWith('abc')).toBe(true);
expect(text.endsWith('nop')).toBe(true);
expect(text).toContain('10 characters not captured');
});

it('bounds memory regardless of how much is pushed', () => {
const c = new BoundedCapture(100, 100);
for (let i = 0; i < 500; i++) c.push('y'.repeat(1000));
expect(c.total).toBe(500_000);
// Retained text is the two ends plus one marker line, not the 500 KB pushed.
expect(c.text().length).toBeLessThan(400);
});

it('splits a chunk that straddles the head boundary', () => {
const c = new BoundedCapture(4, 4);
c.push('abcdefghij');
expect(c.text().startsWith('abcd')).toBe(true);
expect(c.text().endsWith('ghij')).toBe(true);
expect(c.omitted).toBe(2);
});
});
117 changes: 117 additions & 0 deletions packages/core/src/spill/bound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Head+tail bounding — the one primitive both the capture buffer and the
// model-visible preview are built from.
//
// Spec: docs/DSH_ADOPTION_PLAN.md §1.1
//
// Why head AND tail: a stack trace, an assertion diff, and a non-zero exit line
// all live at the END of a command's output, which is exactly what head-only
// truncation throws away. Keeping both ends costs nothing and is the difference
// between a usable excerpt and a useless one.
//
// Units are UTF-16 code units (JS string length), not bytes. Byte counts are
// reported only for content actually written to disk.

/** A string reduced to its two ends, plus how much was dropped between them. */
export interface BoundedText {
/** The retained head. Empty when `headChars` is 0. */
head: string;
/** The retained tail. Empty when nothing was dropped (all of it is in `head`). */
tail: string;
/** Code units dropped between head and tail. 0 means `head + tail` is the whole input. */
omitted: number;
}

/**
* Trim a lone surrogate off the end of a slice, so cutting mid-pair can never
* emit an unpaired code unit.
*/
function trimEnd(s: string): string {
const last = s.charCodeAt(s.length - 1);
return last >= 0xd800 && last <= 0xdbff ? s.slice(0, -1) : s;
}

/** Trim a lone low surrogate off the start of a slice, for the same reason. */
function trimStart(s: string): string {
const first = s.charCodeAt(0);
return first >= 0xdc00 && first <= 0xdfff ? s.slice(1) : s;
}

/**
* Reduce `text` to at most `headChars` from the front and `tailChars` from the
* back. Returns the whole string as `head` when it already fits.
*
* @param text Input string.
* @param headChars Maximum code units to keep from the front (>= 0).
* @param tailChars Maximum code units to keep from the back (>= 0).
* @returns The two retained ends and the count dropped between them.
*/
export function boundText(text: string, headChars: number, tailChars: number): BoundedText {
if (text.length <= headChars + tailChars) return { head: text, tail: '', omitted: 0 };
const head = trimEnd(text.slice(0, headChars));
const tail = tailChars > 0 ? trimStart(text.slice(text.length - tailChars)) : '';
return { head, tail, omitted: text.length - head.length - tail.length };
}

/**
* Streaming head+tail buffer, for output that arrives in chunks and whose total
* size is not known in advance.
*
* Memory stays bounded at roughly `headChars + tailChars` code units no matter
* how much is pushed through it — a command that prints gigabytes costs the same
* as one that prints kilobytes.
*/
export class BoundedCapture {
#head = '';
#tail = '';
#total = 0;

/**
* @param headChars Maximum code units retained from the front.
* @param tailChars Maximum code units retained from the back.
*/
constructor(
private readonly headChars: number,
private readonly tailChars: number,
) {}

/**
* Append a chunk, discarding from the middle as needed.
*
* @param chunk Text to append.
*/
push(chunk: string): void {
this.#total += chunk.length;
let rest = chunk;
if (this.#head.length < this.headChars) {
const room = this.headChars - this.#head.length;
this.#head += rest.slice(0, room);
rest = rest.slice(room);
}
if (rest.length === 0) return;
const merged = this.#tail + rest;
this.#tail =
merged.length > this.tailChars ? merged.slice(merged.length - this.tailChars) : merged;
}

/** Code units pushed in total, including those since discarded. */
get total(): number {
return this.#total;
}

/** Code units discarded from the middle. */
get omitted(): number {
return this.#total - this.#head.length - this.#tail.length;
}

/**
* The retained text. When nothing was discarded this is the exact input;
* otherwise the two ends are joined by a marker naming the gap.
*
* @returns Retained text, with an inline marker when a gap exists.
*/
text(): string {
const gap = this.omitted;
if (gap <= 0) return this.#head + this.#tail;
return `${trimEnd(this.#head)}\n... [${gap.toLocaleString('en-US')} characters not captured — output exceeded the capture limit] ...\n${trimStart(this.#tail)}`;
}
}
15 changes: 15 additions & 0 deletions packages/core/src/spill/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Tool-output spill — entry point.
// Spec: docs/DSH_ADOPTION_PLAN.md §1.1
//
// `local.js` is deliberately NOT re-exported here: it imports node:fs, and this
// module is reachable from the renderer bundle. Hosts with a filesystem import
// it directly.

export { boundText, BoundedCapture, type BoundedText } from './bound.js';
export {
applySpillPolicy,
DEFAULT_SPILL_THRESHOLD_CHARS,
type SpillPolicyOptions,
type SpillOutcome,
} from './policy.js';
export type { SaveTextRequest, SpillRef, SpillSource, SpillStore } from './types.js';
Loading
Loading