diff --git a/src/agent.ts b/src/agent.ts index e60e045..aeea0a0 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,6 +1,10 @@ import type { Membrane, NormalizedMessage, NormalizedRequest, ContentBlock, YieldingStream } from '@animalabs/membrane'; import { isAbortedResponse } from '@animalabs/membrane'; -import { toolResultDataToHistoryString } from './tool-result-history.js'; +import { + toolResultDataToHistoryString, + truncateForHistory, + DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, +} from './tool-result-history.js'; export interface StartStreamResult { stream: YieldingStream; @@ -890,12 +894,17 @@ export class Agent { // Tool results go as a user message with tool_result blocks. The history // serializer turns MCP image blocks into `[image: type, size]` so the // persisted transcript stays small and survives truncation. + // + // This is the direct-Agent-API path (the framework stores results itself + // via buildStoredToolResultContent, with workspace spill). No workspace + // is reachable from here, so the house default cap applies as plain + // truncation — bounded beats unbounded, for errors too. const content = results.map((r) => ({ type: 'tool_result' as const, toolUseId: r.id, content: r.result.isError - ? r.result.error ?? 'Unknown error' - : toolResultDataToHistoryString(r.result.data), + ? truncateForHistory(r.result.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS) + : toolResultDataToHistoryString(r.result.data, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS), isError: r.result.isError, })); diff --git a/src/framework.ts b/src/framework.ts index e676529..74b1afa 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -37,6 +37,7 @@ import type { ToolCall, ToolCallEvent, ToolResult, + CompletedToolCall, AgentConfig, InferenceRequest, AgentState, @@ -67,7 +68,11 @@ import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry import { ConversationRouter } from './mcpl/conversation-router.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; -import { toolResultDataToHistoryString } from './tool-result-history.js'; +import { + toolResultDataToHistoryString, + truncateForHistory, + DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, +} from './tool-result-history.js'; import { randomUUID } from 'node:crypto'; import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js'; import { @@ -784,6 +789,9 @@ export class AgentFramework { * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted — * the lift is meant as a temporary gate, reverts on restart/reset. */ private toolResultInlineMaxCharsOverride: Map = new Map(); + /** Durable residence-configured inline cap from + * FrameworkConfig.toolResultInlineMaxChars; null → house default. */ + private toolResultInlineMaxCharsConfig: number | null = null; private mcplTools: import('./types/index.js').ToolDefinition[] = []; /** Namespaced tool name → stateful feature-set attribution from tools/list. */ @@ -1063,6 +1071,14 @@ export class AgentFramework { // retained when enabled — everything downstream gates on the field. framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null; + if (config.toolResultInlineMaxChars !== undefined) { + const cap = config.toolResultInlineMaxChars; + if (!Number.isFinite(cap) || cap < 1000) { + throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000'); + } + framework.toolResultInlineMaxCharsConfig = Math.floor(cap); + } + // Initialize MCPL subsystems if configured if (config.mcplServers && config.mcplServers.length > 0) { // Validate tool prefixes: no collisions with module names or between servers @@ -1536,18 +1552,28 @@ export class AgentFramework { tool_result_inline_max_chars: { type: 'number', description: - 'Inline size cap (chars) for tool results and background-script wake payloads. ' + - 'Content over the cap is written to a workspace file under tool-results/ and ' + - 'replaced by a truncated preview + file reference. Raise it temporarily when you ' + - 'genuinely want a large result inline; reset restores the default (derived from ' + - 'your strategy). Ephemeral — reverts on host restart.', + 'Inline size cap (chars) for tool results (successes and errors) and ' + + 'background-script wake payloads. Content over the cap is written to a workspace ' + + 'file under tool-results/ and replaced by a truncated preview + file reference. ' + + 'Raise it temporarily when you genuinely want a large result inline; reset ' + + 'restores the durable residence default. Ephemeral — reverts on host restart. ' + + 'The effective cap and its source are reported as ' + + 'tool_result_inline_max_chars_effective / _source on get.', }, }, keys: ['tool_result_inline_max_chars'], - get: (agentName: string) => ({ - tool_result_inline_max_chars: - this.toolResultInlineMaxCharsOverride.get(agentName) ?? null, - }), + get: (agentName: string) => { + const agent = this.agents.get(agentName); + const resolved = agent ? this.resolveToolResultInlineCap(agent) : null; + return { + tool_result_inline_max_chars: + this.toolResultInlineMaxCharsOverride.get(agentName) ?? null, + tool_result_inline_max_chars_effective: resolved?.cap ?? null, + tool_result_inline_max_chars_source: resolved + ? resolved.source + (resolved.strategyClamped ? ' (strategy-clamped)' : '') + : null, + }; + }, update: (agentName: string, patch: Record) => { const n = Number(patch.tool_result_inline_max_chars); if (!Number.isFinite(n) || n < 1000) { @@ -3713,38 +3739,18 @@ export class AgentFramework { this.pendingAssistantBlocks.delete(agent.name); } - // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars) - const maxChars = this.getMaxToolResultChars(agent); - - // Oversized results spill to a workspace file (truncated preview + - // file reference) instead of being blind-truncated. Computed ONCE - // per call and reused for both the history copy and the live wire - // copy below — the two must stay byte-matched (the window stores - // what the membrane sends; divergence breaks the compile prefix). - const spilled = new Map(); - const dateLabel = new Date().toISOString().slice(0, 10); - for (const tc of currentState.toolResults) { - if (tc.result.isError) continue; - spilled.set(tc.id, await this.spillOrTruncate( - toolResultDataToHistoryString(tc.result.data, undefined), - maxChars, - `${dateLabel}-${tc.id}`, - agent.name, - )); - } - - // Store tool results as a user message (tool_result blocks). - // Use the history serializer so MCP image blocks become a short - // `[image: type, size]` placeholder instead of megabytes of base64 - // that would corrupt under truncation. - const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => ({ - type: 'tool_result' as const, - toolUseId: tc.id, - content: tc.result.isError - ? tc.result.error ?? 'Unknown error' - : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars), - isError: tc.result.isError, - })); + // Effective inline cap: agent_settings override → durable config → + // house default, clamped to the strategy bound (see resolver). + const maxChars = this.resolveToolResultInlineCap(agent).cap; + + // Oversized results — successes AND errors — spill to a workspace + // file (truncated preview + file reference) instead of being + // blind-truncated. Computed ONCE per call and reused for both the + // history copy and the live wire copy below — the two must stay + // byte-matched (the window stores what the membrane sends; + // divergence breaks the compile prefix). + const { blocks: toolResultContent, spilled } = + await this.buildStoredToolResultContent(currentState.toolResults, maxChars); agent.getContextManager().addMessage('user', toolResultContent); // Flush any messages that were deferred while this turn was in @@ -5785,17 +5791,15 @@ export class AgentFramework { agent.addAssistantResponse(pending); this.pendingAssistantBlocks.delete(agent.name); } - // Store tool results + // Store tool results — same bounded spill policy as the + // ordinary path (issue #89: this guard path must not become the + // one door a giant blob still walks through). const readyState = agent.state as AgentState; if (readyState.status === 'ready') { - const toolResultContent: ContentBlock[] = readyState.toolResults.map(tc => ({ - type: 'tool_result' as const, - toolUseId: tc.id, - content: tc.result.isError - ? (tc.result.error ?? 'Unknown error') - : toolResultDataToHistoryString(tc.result.data), - isError: tc.result.isError, - })); + const { blocks: toolResultContent } = await this.buildStoredToolResultContent( + readyState.toolResults, + this.resolveToolResultInlineCap(agent).cap, + ); agent.getContextManager().addMessage('user', toolResultContent); } } @@ -6840,8 +6844,12 @@ export class AgentFramework { ? 'null' : JSON.stringify(payload, null, 1) ?? String(payload); const agent = this.agents.get(record.agentName); - const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined; - const payloadText = await this.spillOrTruncate( + // Same resolved cap as tool results (override applies here too); an + // unknown agent still gets the bounded house default, never unbounded. + const maxChars = agent + ? this.resolveToolResultInlineCap(agent).cap + : DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS; + const { text: payloadText } = await this.spillOrTruncate( payloadJson, maxChars, `${record.id}-wake${record.wakes}`, ); @@ -6923,45 +6931,101 @@ export class AgentFramework { } /** - * Oversized content policy (antra, 2026-07-31): never hit the agent in - * the face with a huge blob, never silently destroy it either. Content - * over the inline cap is materialized to a workspace file (readable with - * the agent's own tools) and replaced by a truncated head + a trailing - * file reference. Falls back to plain truncation when there is no - * writable workspace. The cap can be lifted temporarily via - * agent_settings `tool_result_inline_max_chars`. + * Oversized content policy (antra, 2026-07-31; completed for issue #89): + * never hit the agent in the face with a huge blob, never silently destroy + * it either. Content over the inline cap is materialized to a workspace + * file (readable with the agent's own tools — deterministic name, kept + * until the workspace owner deletes it) and replaced by a truncated head + + * a trailing file reference. Falls back to EXPLICIT plain truncation when + * there is no writable workspace. Cap resolution lives in + * resolveToolResultInlineCap; callers pass the resolved cap. */ + /** + * One spill policy for every stored tool_result block: serialize (history + * serializer — image blocks become placeholders), then spill/truncate at + * the resolved cap. Errors follow the same policy as successes — a giant + * error string is exactly as context-hostile as a giant success (issue + * #89). Returns the stored blocks plus the per-call spill outcomes so the + * live wire copy can reuse the identical strings. + */ + private async buildStoredToolResultContent( + toolResults: CompletedToolCall[], + maxChars: number | undefined, + ): Promise<{ + blocks: ContentBlock[]; + spilled: Map; + }> { + const spilled = new Map(); + const dateLabel = new Date().toISOString().slice(0, 10); + for (const tc of toolResults) { + const raw = tc.result.isError + ? tc.result.error ?? 'Unknown error' + : toolResultDataToHistoryString(tc.result.data, undefined); + spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`)); + } + const blocks: ContentBlock[] = toolResults.map(tc => ({ + type: 'tool_result' as const, + toolUseId: tc.id, + content: spilled.get(tc.id)!.text, + isError: tc.result.isError, + })); + return { blocks, spilled }; + } + private async spillOrTruncate( content: string, - maxChars: number | undefined, + cap: number | undefined, label: string, - agentName?: string, - ): Promise { - const override = agentName !== undefined - ? this.toolResultInlineMaxCharsOverride.get(agentName) - : undefined; - const cap = override ?? maxChars; - if (!cap || content.length <= cap) return content; + ): Promise<{ text: string; filePath: string | null }> { + if (!cap || content.length <= cap) return { text: content, filePath: null }; const workspace = this.getWorkspaceModule(); const mountName = workspace ? this.firstWritableMountName(workspace) : null; if (workspace && mountName) { const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80); const path = `${mountName}/tool-results/${safeLabel}.txt`; + let failure: string; try { const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain'); if (result.success) { - return safeSlice(content, 0, cap) - + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. ` - + 'Read/grep it with your file tools, or raise the inline cap temporarily via ' - + 'agent_settings update tool_result_inline_max_chars.]'; + return { + text: safeSlice(content, 0, cap) + + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. ` + + 'Read/grep it with your file tools, or raise the inline cap temporarily via ' + + 'agent_settings update tool_result_inline_max_chars.]', + filePath: path, + }; } - } catch { - // fall through to plain truncation + failure = result.error ?? 'write refused'; + } catch (err) { + failure = err instanceof Error ? err.message : String(err); } + // A workspace was there and the write FAILED — say that, loudly and + // distinctly. Telling the agent "no writable workspace" would teach + // them their residence lacks a capability it actually has. + failure = failure.slice(0, 200); + this.emitTrace({ + type: 'tool:spill_failed', + label: safeLabel, + path, + contentLength: content.length, + error: failure, + }); + console.error( + `[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`, + ); + return { + text: safeSlice(content, 0, cap) + + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED` + + ` (${failure}); content over the cap was not retained]`, + filePath: null, + }; } - return safeSlice(content, 0, cap) - + '\n\n[truncated — original was ' + content.length + ' chars]'; + return { + text: safeSlice(content, 0, cap) + + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]', + filePath: null, + }; } private getOrCreateScriptRunner(agentName: string): PyRunner { @@ -7072,23 +7136,30 @@ export class AgentFramework { callId: string, afResult: ToolResult, maxChars?: number, - /** Pre-spilled string from the history path — used for the non-image + /** Pre-spilled outcome from the history path — used for the non-image * path so the live wire copy byte-matches what the window stored. */ - precomputed?: string, + precomputed?: { text: string; filePath: string | null }, ): MembraneToolResult { if (afResult.isError) { - return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true }; + // Same spill policy as successes: reuse the history copy so a giant + // error string stays bounded on the wire too (byte-matched). + return { + toolUseId: callId, + content: precomputed?.text ?? truncateForHistory( + afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS), + isError: true, + }; } // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image // blocks natively rather than JSON-stringifying them away. Anything else // (objects, scalars) falls through to JSON. The error path was handled // above, so isError is always false on these return paths. - const blocks = this.tryNativeToolResultContent(afResult.data, maxChars); + const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null); if (blocks) { return { toolUseId: callId, content: blocks, isError: false }; } if (precomputed !== undefined) { - return { toolUseId: callId, content: precomputed, isError: false }; + return { toolUseId: callId, content: precomputed.text, isError: false }; } // JSON.stringify returns the VALUE undefined (not a string) for undefined // input — a module tool returning `{ success: true }` with no data would @@ -7108,7 +7179,13 @@ export class AgentFramework { * `maxChars`, when provided, caps each accompanying text block so an image * inlined alongside an enormous text payload can't blow the context. */ - private tryNativeToolResultContent(data: unknown, maxChars?: number): ToolResultContentBlock[] | null { + private tryNativeToolResultContent( + data: unknown, + maxChars?: number, + /** Spill file written by the history path — referenced from truncation + * notices so an image-adjacent giant text block stays recoverable. */ + spillPath: string | null = null, + ): ToolResultContentBlock[] | null { if (!Array.isArray(data)) return null; let hasImage = false; const blocks: ToolResultContentBlock[] = []; @@ -7119,7 +7196,9 @@ export class AgentFramework { let text = b.text; if (maxChars && text.length > maxChars) { text = safeSlice(text, 0, maxChars) - + '\n\n[truncated — original was ' + text.length + ' chars]'; + + '\n\n[truncated — original was ' + text.length + ' chars' + + (spillPath ? `; full serialized result: workspace file ${spillPath}` : '') + + ']'; } blocks.push({ type: 'text', text }); } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') { @@ -7135,13 +7214,42 @@ export class AgentFramework { return hasImage ? blocks : null; } - private getMaxToolResultChars(agent: Agent): number | undefined { + /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */ + private strategyDerivedToolResultChars(agent: Agent): number | undefined { const strategy = agent.getContextManager().getStrategy(); const maxTokens = strategy.maxMessageTokens; if (maxTokens && maxTokens > 0) return maxTokens * 4; return undefined; } + /** + * Effective tool-result inline cap for an agent, with provenance: + * agent_settings hot override (wins outright, ephemeral) → durable + * FrameworkConfig.toolResultInlineMaxChars → house default (5000). The + * durable/default value is clamped down to the strategy-derived bound when + * that is smaller (a message must still fit maxMessageTokens); the explicit + * override escapes the clamp — a deliberate temporary lift. + */ + private resolveToolResultInlineCap(agent: Agent): { + cap: number; + source: 'agent-settings-override' | 'framework-config' | 'default'; + strategyClamped: boolean; + } { + const override = this.toolResultInlineMaxCharsOverride.get(agent.name); + if (override !== undefined) { + return { cap: override, source: 'agent-settings-override', strategyClamped: false }; + } + const configured = this.toolResultInlineMaxCharsConfig; + const base = configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS; + const strategyBound = this.strategyDerivedToolResultChars(agent); + const strategyClamped = strategyBound !== undefined && strategyBound < base; + return { + cap: strategyClamped ? strategyBound : base, + source: configured !== null ? 'framework-config' : 'default', + strategyClamped, + }; + } + private approximateDecodedBase64Bytes(base64: string): number { const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0; return Math.max(0, Math.floor(base64.length * 3 / 4) - padding); diff --git a/src/tool-result-history.ts b/src/tool-result-history.ts index 48f9c76..6dd4eb3 100644 --- a/src/tool-result-history.ts +++ b/src/tool-result-history.ts @@ -16,14 +16,26 @@ import { safeSlice } from './safe-slice.js'; +/** + * House-safe default inline cap (chars) for tool results, error results, and + * background-script wake payloads (issue #89: a resident should never eat a + * 42k blob they didn't ask for). Durable per-residence value comes from + * `FrameworkConfig.toolResultInlineMaxChars`; a temporary per-agent lift from + * agent_settings `tool_result_inline_max_chars`. + */ +export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 5000; + export function toolResultDataToHistoryString(data: unknown, maxChars?: number): string { const fromArray = tryHistoryStringFromContentArray(data); const str = fromArray ?? JSON.stringify(data); - if (maxChars && str.length > maxChars) { - return safeSlice(str, 0, maxChars) - + '\n\n[truncated — original was ' + str.length + ' chars]'; - } - return str; + return maxChars ? truncateForHistory(str, maxChars) : str; +} + +/** Bounded copy of an arbitrary string with the standard truncation notice. */ +export function truncateForHistory(str: string, maxChars: number): string { + if (str.length <= maxChars) return str; + return safeSlice(str, 0, maxChars) + + '\n\n[truncated — original was ' + str.length + ' chars]'; } /** diff --git a/src/types/framework.ts b/src/types/framework.ts index a3dbfb6..97968b4 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -138,6 +138,20 @@ export interface FrameworkConfig { */ codeExecution?: CodeExecutionConfig; + /** + * Durable inline cap (chars) for tool results — successes AND errors — and + * background-script wake payloads. Content over the cap is written to a + * workspace file under `tool-results/` (deterministic `-.txt` + * name, overwritten on collision, retained until the workspace owner + * deletes it — never auto-GC'd) and replaced inline by a bounded preview + * plus the file reference; with no writable workspace the fallback is + * explicit plain truncation. Default 5000 (house-safe; issue #89). Must be + * >= 1000. Values above the strategy-derived bound (maxMessageTokens * 4) + * are clamped down to it; the ephemeral agent_settings override + * `tool_result_inline_max_chars` still wins outright for one agent. + */ + toolResultInlineMaxChars?: number; + /** Inference routing policy for server-initiated inference (optional). */ inferenceRouting?: InferenceRoutingPolicy; diff --git a/src/types/trace.ts b/src/types/trace.ts index bea4562..2312adc 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -169,6 +169,19 @@ export type TraceEvent = agentStatus: string; result: unknown; }) + | (TraceEventBase & { + /** + * A writable workspace existed but the oversized-result spill write + * failed (size cap, storeBlob failure, …) — the over-cap tail was NOT + * retained. Distinct from the no-workspace fallback, which is silent + * by design (nothing unexpected happened). + */ + type: 'tool:spill_failed'; + label: string; + path: string; + contentLength: number; + error: string; + }) // Module lifecycle | (TraceEventBase & { type: 'module:added'; moduleName: string }) diff --git a/test/tool-result-spill.test.ts b/test/tool-result-spill.test.ts new file mode 100644 index 0000000..56f4284 --- /dev/null +++ b/test/tool-result-spill.test.ts @@ -0,0 +1,476 @@ +/** + * Oversized tool-result spill — completion coverage for issue #89. + * + * The mechanism (spill to workspace file + bounded preview) landed in + * f231bbf; these tests pin the completion semantics: the house-safe 5000 + * default (no more 42k accidental ingests), the durable + * FrameworkConfig.toolResultInlineMaxChars cap, hot-override provenance and + * restart behavior, error results under the same policy, the explicit + * no-writable-workspace fallback, history/wire byte-identity, and native + * image blocks surviving next to a spilled text payload. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { + EventResponse, + Module, + ProcessEvent, + ToolDefinition, + ToolResult, +} from '../src/index.js'; +import { AgentFramework, PassthroughStrategy } from '../src/index.js'; +import { WorkspaceModule } from '../src/modules/workspace/index.js'; +import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; + +class CappedPassthroughStrategy extends PassthroughStrategy { + readonly maxMessageTokens = 1000; +} + +function tempStorePath(prefix: string): { tempDir: string; storePath: string } { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); + return { tempDir, storePath: join(tempDir, 'store') }; +} + +/** Module returning one canned result for its single `fetch` tool. */ +class CannedToolModule implements Module { + readonly name = 'canned'; + constructor(private result: ToolResult) {} + async start(): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ + name: 'fetch', + description: 'Returns the canned payload.', + inputSchema: { type: 'object', properties: {} }, + }]; + } + async handleToolCall(): Promise { + return this.result; + } + async onProcess(event: ProcessEvent): Promise { + if (event.type === 'external-message') { + return { + addMessages: [{ participant: 'User', content: (event as { content: unknown }).content as never }], + requestInference: true, + }; + } + return {}; + } +} + +interface SpillHarness { + framework: AgentFramework; + membrane: MockMembrane; + workspace: WorkspaceModule | null; + tempDir: string; + storePath: string; +} + +async function startSpillTurn(opts: { + prefix: string; + result: ToolResult; + withWorkspace: boolean; + toolResultInlineMaxChars?: number; + workspaceMaxFileSize?: number; + cappedStrategy?: boolean; + storePath?: string; + tempDir?: string; +}): Promise { + const { tempDir, storePath } = opts.storePath && opts.tempDir + ? { tempDir: opts.tempDir, storePath: opts.storePath } + : tempStorePath(opts.prefix); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Fetching.' }, + { type: 'tool_use', id: 'call_fetch', name: 'canned--fetch', input: {} }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Handled.' }])); + + let workspace: WorkspaceModule | null = null; + const modules: Module[] = [new CannedToolModule(opts.result)]; + if (opts.withWorkspace) { + const mountDir = join(tempDir, 'mount'); + mkdirSync(mountDir, { recursive: true }); + workspace = new WorkspaceModule({ + mounts: [{ + name: 'files', + path: mountDir, + mode: 'read-write', + watch: 'never', + ...(opts.workspaceMaxFileSize !== undefined + ? { maxFileSize: opts.workspaceMaxFileSize } + : {}), + }], + }); + modules.push(workspace as unknown as Module); + } + + const framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [{ + name: 'prime', + model: 'test-model', + systemPrompt: 'You are prime.', + allowedTools: 'all', + ...(opts.cappedStrategy ? { strategy: new CappedPassthroughStrategy() as never } : {}), + }], + modules, + syncIntervalMs: 0, + ...(opts.toolResultInlineMaxChars !== undefined + ? { toolResultInlineMaxChars: opts.toolResultInlineMaxChars } + : {}), + }); + workspace?.initStore(framework.getStore()); + framework.start(); + framework.pushEvent({ + type: 'external-message', + source: 'test', + content: [{ type: 'text', text: 'fetch it' }], + metadata: {}, + triggerInference: true, + } as unknown as ProcessEvent); + return { framework, membrane, workspace, tempDir, storePath }; +} + +/** Poll the agent's stored context for the first tool_result block. */ +async function waitForStoredToolResult( + framework: AgentFramework, + timeoutMs = 20_000, +): Promise<{ content: string; isError: boolean } | null> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + const cm = framework.getAgent('prime')?.getContextManager(); + const msgs = (cm?.queryMessages({}).messages ?? []) as unknown as Array<{ + content: Array<{ type: string; content?: unknown; isError?: boolean }>; + }>; + for (const m of msgs) { + for (const b of m.content ?? []) { + if (b.type === 'tool_result') { + return { content: b.content as string, isError: b.isError === true }; + } + } + } + } + return null; +} + +/** Effective-cap provenance through the framework's own settings extension. */ +function capProvenance(framework: AgentFramework): Record { + const extensions = (framework as unknown as { + collectAgentSettingsExtensions(): Map }>; + }).collectAgentSettingsExtensions(); + return extensions.get('_framework')!.get('prime'); +} + +function frameworkExtension(framework: AgentFramework): { + update(agentName: string, patch: Record): Record; + reset(agentName: string, keys?: string[]): Record; +} { + const extensions = (framework as unknown as { + collectAgentSettingsExtensions(): Map; + }).collectAgentSettingsExtensions(); + return extensions.get('_framework') as ReturnType; +} + +describe('tool-result spill completion (issue #89)', () => { + it('caps at the house default 5000 with no config and no strategy bound', async () => { + // Pre-#89 behavior: PassthroughStrategy has no maxMessageTokens, so the + // cap was undefined and a 42k result went inline whole. This pins the fix. + const h = await startSpillTurn({ + prefix: 'spill-default-', + result: { success: true, data: { blob: 'x'.repeat(42_000) } }, + withWorkspace: true, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + assert.ok(stored.content.length < 6_000, `inline copy must be near the 5000 cap, got ${stored.content.length}`); + assert.match(stored.content, /showing 5000 of \d+ chars; full content: workspace file files\/tool-results\//); + const prov = capProvenance(h.framework); + assert.strictEqual(prov.tool_result_inline_max_chars, null); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 5000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'default'); + // Full content is recoverable from the spill file. + const refMatch = stored.content.match(/workspace file (files\/tool-results\/\S+\.txt)/); + assert.ok(refMatch, 'reference should name the spill file'); + const file = await h.workspace!.readBinary(refMatch[1]); + assert.ok('data' in file, `spill file should be readable: ${JSON.stringify(file)}`); + assert.ok((file as { data: Buffer }).data.byteLength >= 42_000, 'full content in file'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('honors the durable FrameworkConfig cap and reports framework-config provenance', async () => { + const h = await startSpillTurn({ + prefix: 'spill-config-', + result: { success: true, data: { blob: 'x'.repeat(42_000) } }, + withWorkspace: true, + toolResultInlineMaxChars: 12_000, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + assert.match(stored.content, /showing 12000 of \d+ chars/); + const prov = capProvenance(h.framework); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 12_000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'framework-config'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('hot override wins over config; reset restores the durable cap', async () => { + const h = await startSpillTurn({ + prefix: 'spill-override-', + result: { success: true, data: { small: true } }, + withWorkspace: true, + toolResultInlineMaxChars: 12_000, + }); + try { + const ext = frameworkExtension(h.framework); + ext.update('prime', { tool_result_inline_max_chars: 50_000 }); + let prov = capProvenance(h.framework); + assert.strictEqual(prov.tool_result_inline_max_chars, 50_000); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 50_000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'agent-settings-override'); + ext.reset('prime'); + prov = capProvenance(h.framework); + assert.strictEqual(prov.tool_result_inline_max_chars, null); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 12_000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'framework-config'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('spills giant ERROR results under the same policy, preserving isError', async () => { + const h = await startSpillTurn({ + prefix: 'spill-error-', + result: { success: false, error: 'E'.repeat(42_000), isError: true }, + withWorkspace: true, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'error tool result should be stored'); + assert.strictEqual(stored.isError, true); + assert.ok(stored.content.length < 6_000, `inline error copy must be capped, got ${stored.content.length}`); + assert.match(stored.content, /full content: workspace file files\/tool-results\//); + // Wire copy byte-matches the stored copy. + const wire = h.membrane.lastStream?.receivedToolResults[0] as + Array<{ content: unknown; isError?: boolean }> | undefined; + assert.ok(wire && wire.length === 1, 'one wire tool result expected'); + assert.strictEqual(wire[0].isError, true); + assert.strictEqual(wire[0].content, stored.content, 'history and live wire must be byte-identical'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('keeps history and live wire byte-identical for spilled successes', async () => { + const h = await startSpillTurn({ + prefix: 'spill-identity-', + result: { success: true, data: { blob: 'y'.repeat(42_000) } }, + withWorkspace: true, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + const wire = h.membrane.lastStream?.receivedToolResults[0] as + Array<{ content: unknown }> | undefined; + assert.ok(wire && wire.length === 1, 'one wire tool result expected'); + assert.strictEqual(wire[0].content, stored.content, 'history and live wire must be byte-identical'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('falls back to EXPLICIT plain truncation with no writable workspace', async () => { + const h = await startSpillTurn({ + prefix: 'spill-nows-', + result: { success: true, data: { blob: 'z'.repeat(42_000) } }, + withWorkspace: false, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + assert.ok(stored.content.length < 6_000, 'inline copy must be capped'); + assert.match(stored.content, /no writable workspace, full content not retained/); + assert.doesNotMatch(stored.content, /workspace file/); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('preserves native image blocks and references the spill file from the wire text block', async () => { + // 1x1 transparent PNG. + const png = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + const h = await startSpillTurn({ + prefix: 'spill-image-', + result: { + success: true, + data: [ + { type: 'text', text: 'T'.repeat(42_000) }, + { type: 'image', data: png, mimeType: 'image/png' }, + ], + }, + withWorkspace: true, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + // History copy: text spilled; the image placeholder sits past the + // preview boundary, so it lives in the spill file, never as base64. + assert.match(stored.content, /full content: workspace file/); + assert.doesNotMatch(stored.content, new RegExp(png.slice(0, 24))); + const refMatch = stored.content.match(/workspace file (files\/tool-results\/\S+\.txt)/); + assert.ok(refMatch, 'reference should name the spill file'); + const file = await h.workspace!.readBinary(refMatch[1]); + assert.ok('data' in file, 'spill file should be readable'); + const full = (file as { data: Buffer }).data.toString('utf8'); + assert.match(full, /\[image: image\/png/, 'spill file keeps the image placeholder, not base64'); + // Wire copy: native blocks — image bytes intact, text bounded with a + // pointer at the same spill file. + const wire = h.membrane.lastStream?.receivedToolResults[0] as + Array<{ content: unknown }> | undefined; + assert.ok(wire && wire.length === 1, 'one wire tool result expected'); + const blocks = wire[0].content as Array< + { type: string; text?: string; source?: { data?: string } } + >; + assert.ok(Array.isArray(blocks), 'wire content should be native blocks'); + const image = blocks.find((b) => b.type === 'image'); + assert.ok(image, 'image block must survive'); + assert.strictEqual(image.source?.data, png, 'base64 must be byte-intact'); + const text = blocks.find((b) => b.type === 'text'); + assert.ok(text?.text, 'text block expected'); + assert.ok(text.text.length < 6_000, 'text block must be capped'); + assert.match(text.text, /full serialized result: workspace file files\/tool-results\//); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('reports a FAILED spill write distinctly from having no workspace, with a trace', async () => { + // A writable mount exists but refuses the write (size cap). The agent + // must NOT be told "no writable workspace" — that teaches them their + // residence lacks a capability it actually has (opus-rev finding #1). + const h = await startSpillTurn({ + prefix: 'spill-wfail-', + result: { success: true, data: { blob: 'w'.repeat(60_000) } }, + withWorkspace: true, + workspaceMaxFileSize: 20_000, + }); + const traces: Array> = []; + h.framework.onTrace((e) => { + if ((e as { type: string }).type === 'tool:spill_failed') { + traces.push(e as unknown as Record); + } + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + assert.match(stored.content, /spill to workspace file files\/tool-results\/\S+\.txt FAILED \(/); + assert.match(stored.content, /content over the cap was not retained/); + assert.doesNotMatch(stored.content, /no writable workspace/); + assert.strictEqual(traces.length, 1, 'exactly one tool:spill_failed trace expected'); + assert.strictEqual(traces[0].contentLength, 60_011); + assert.match(String(traces[0].path), /files\/tool-results\//); + assert.ok(String(traces[0].error).length > 0, 'trace should carry the failure reason'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('clamps the default down to the strategy bound and reports it', async () => { + // maxMessageTokens=1000 → strategy bound 4000 < default 5000. This branch + // decides the cap for every resident on a bounded strategy — pin it. + const h = await startSpillTurn({ + prefix: 'spill-clamp-', + result: { success: true, data: { blob: 'c'.repeat(42_000) } }, + withWorkspace: true, + cappedStrategy: true, + }); + try { + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + assert.match(stored.content, /showing 4000 of \d+ chars/); + const prov = capProvenance(h.framework); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 4000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'default (strategy-clamped)'); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + + it('restart keeps the durable cap and drops the ephemeral override', async () => { + const { tempDir, storePath } = tempStorePath('spill-restart-'); + const first = await startSpillTurn({ + prefix: 'unused-', + result: { success: true, data: { small: true } }, + withWorkspace: true, + toolResultInlineMaxChars: 8_000, + tempDir, + storePath, + }); + try { + frameworkExtension(first.framework).update('prime', { tool_result_inline_max_chars: 60_000 }); + assert.strictEqual(capProvenance(first.framework).tool_result_inline_max_chars_effective, 60_000); + await first.framework.stop(); + + const second = await startSpillTurn({ + prefix: 'unused-', + result: { success: true, data: { small: true } }, + withWorkspace: true, + toolResultInlineMaxChars: 8_000, + tempDir, + storePath, + }); + try { + const prov = capProvenance(second.framework); + assert.strictEqual(prov.tool_result_inline_max_chars, null, 'override must not survive restart'); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 8_000, 'configured cap must survive restart'); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'framework-config'); + } finally { + await second.framework.stop(); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects an invalid configured cap at create()', async () => { + const { tempDir, storePath } = tempStorePath('spill-invalid-'); + const membrane = new MockMembrane(); + try { + await assert.rejects( + AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [{ name: 'prime', model: 'test-model', systemPrompt: 'p', allowedTools: 'all' }], + modules: [], + syncIntervalMs: 0, + toolResultInlineMaxChars: 500, + }), + /toolResultInlineMaxChars must be a number >= 1000/, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +});