From 190b178d4754a709bf22bb2394cf9be4bf8c273a Mon Sep 17 00:00:00 2001 From: antra-tess Date: Fri, 7 Aug 2026 13:15:29 -0700 Subject: [PATCH] feat(tool-results): raise the house inline cap 5000 -> 24000 The 5000 default (#91) is tuned for conversation, not for code. Mica is the first residence to actually run it -- 23h, 48 spills, and 71% of them are ordinary shell output from source review: git diff, test runs, `sed -n '450,590p'`-style excerpts, which routinely land at 5-15k. Under a 5000 cap a resident doing code work round-trips almost every file read through a spill file. Measured on that corpus: 20000 keeps 76% inline, 24000 keeps 88%, 32000 only reaches 90%. 24000 is the elbow -- ordinary excerpts and test summaries stay inline while genuinely large payloads (full channel histories, registry dumps, broad greps) still spill, which is the point. Nothing about the mechanism changes: spill target, provenance, the hard clamp to the strategy bound (#94), and the explicit no-workspace fallback are all untouched. Residences and residents can still override in either direction. Also replaces the hardcoded `< 6_000` assertion bounds in the spill suite with a CAPPED constant derived from DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS. Those magic numbers silently pinned the 5000-era default -- three tests that only meant to assert "something capped this" failed on the raise. Deriving them means the next change to the default doesn't have to chase them. Co-Authored-By: Claude Opus 5 --- src/framework.ts | 2 +- src/tool-result-history.ts | 14 ++++++++++++-- src/types/framework.ts | 2 +- test/tool-result-spill.test.ts | 28 +++++++++++++++++++--------- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index a2e8175..60d6330 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7334,7 +7334,7 @@ export class AgentFramework { /** * Effective tool-result inline cap for an agent, with provenance. Desired * value: resident's durable agent_settings value → residence - * FrameworkConfig.toolResultInlineMaxChars → house default (5000). + * FrameworkConfig.toolResultInlineMaxChars → house default (24000). * Effective value: min(desired, strategy bound) for EVERY source — the * strategy's per-message safety limit is a ceiling, not a suggestion * (Sol's #94 ruling: a durable preference must not be a durable path for diff --git a/src/tool-result-history.ts b/src/tool-result-history.ts index 6dd4eb3..7bf8783 100644 --- a/src/tool-result-history.ts +++ b/src/tool-result-history.ts @@ -20,10 +20,20 @@ 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 + * `FrameworkConfig.toolResultInlineMaxChars`; a per-agent value from * agent_settings `tool_result_inline_max_chars`. + * + * Raised 5000 → 24000 (2026-08-07) on production evidence rather than taste. + * The first residence to actually run the 5000 default spilled 48 results in + * 23h, 71% of them ordinary shell output from source review — `git diff`, test + * runs, `sed -n '450,590p'`-style excerpts, which routinely land at 5–15k. A + * 5000-char cap is tuned for conversation and turns every file read into a + * spill round-trip for a resident doing code work. Measured on that corpus: + * 20000 keeps 76% inline, 24000 keeps 88%, 32000 only reaches 90% — 24000 is + * the elbow. Genuinely large payloads (full histories, channel registries, + * broad greps) still spill, which is the point. */ -export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 5000; +export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 24000; export function toolResultDataToHistoryString(data: unknown, maxChars?: number): string { const fromArray = tryHistoryStringFromContentArray(data); diff --git a/src/types/framework.ts b/src/types/framework.ts index 39bca40..2f25b57 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -145,7 +145,7 @@ export interface FrameworkConfig { * 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 + * explicit plain truncation. Default 24000 (house-safe; issue #89). Must be * >= 1000. A resident's own agent_settings value * `tool_result_inline_max_chars` (durable, persisted in framework state) * takes precedence over this for that agent; the EFFECTIVE cap for every diff --git a/test/tool-result-spill.test.ts b/test/tool-result-spill.test.ts index b9ce429..f31211c 100644 --- a/test/tool-result-spill.test.ts +++ b/test/tool-result-spill.test.ts @@ -2,7 +2,7 @@ * 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 + * f231bbf; these tests pin the completion semantics: the house-safe 24000 * 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 @@ -23,9 +23,19 @@ import type { ToolResult, } from '../src/index.js'; import { AgentFramework, PassthroughStrategy } from '../src/index.js'; +import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../src/tool-result-history.js'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; +/** + * Bound for "inline copy is capped" assertions: the cap itself plus room for + * the truncation notice. Derived from the constant on purpose — these bounds + * were once hardcoded at the 5000-era value and so silently pinned the old + * default; raising it broke three tests that were only ever checking that + * SOMETHING capped the payload. + */ +const CAPPED = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS + 1000; + class CappedPassthroughStrategy extends PassthroughStrategy { readonly maxMessageTokens = 1000; } @@ -179,7 +189,7 @@ function frameworkExtension(framework: AgentFramework): { } describe('tool-result spill completion (issue #89)', () => { - it('caps at the house default 5000 with no config and no strategy bound', async () => { + it('caps at the house default 24000 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({ @@ -190,11 +200,11 @@ describe('tool-result spill completion (issue #89)', () => { 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\//); + assert.ok(stored.content.length < CAPPED, `inline copy must be near the cap, got ${stored.content.length}`); + assert.match(stored.content, /showing 24000 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_effective, 24000); 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)/); @@ -263,7 +273,7 @@ describe('tool-result spill completion (issue #89)', () => { 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.ok(stored.content.length < CAPPED, `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 @@ -305,7 +315,7 @@ describe('tool-result spill completion (issue #89)', () => { 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.ok(stored.content.length < CAPPED, 'inline copy must be capped'); assert.match(stored.content, /no writable workspace, full content not retained/); assert.doesNotMatch(stored.content, /workspace file/); } finally { @@ -356,7 +366,7 @@ describe('tool-result spill completion (issue #89)', () => { 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.ok(text.text.length < CAPPED, 'text block must be capped'); assert.match(text.text, /full serialized result: workspace file files\/tool-results\//); } finally { await h.framework.stop(); @@ -397,7 +407,7 @@ describe('tool-result spill completion (issue #89)', () => { }); it('clamps the default down to the strategy bound and reports it', async () => { - // maxMessageTokens=1000 → strategy bound 4000 < default 5000. This branch + // maxMessageTokens=1000 → strategy bound 4000 < default 24000. This branch // decides the cap for every resident on a bounded strategy — pin it. const h = await startSpillTurn({ prefix: 'spill-clamp-',