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
2 changes: 1 addition & 1 deletion src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/tool-result-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/types/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 19 additions & 9 deletions test/tool-result-spill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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({
Expand All @@ -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)/);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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-',
Expand Down