From c4ea559d019ed3fee87ae019f29db93ef02ca57b Mon Sep 17 00:00:00 2001 From: antra-tess Date: Thu, 6 Aug 2026 10:34:10 -0700 Subject: [PATCH 1/2] feat(agent-settings): tool_result_inline_max_chars is a durable resident setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol's ruling on #91 (antra concurring): the resident-set inline cap must not be an ephemeral hot override — it joins the durable settings plane like context budget/tail/transition settings. - update persists the value to framework/state (toolResultInlineCaps slot, same store plane as agentRuntimeSettings) and create() restores it, with load-time validation that drops invalid entries loudly. - reset clears the persisted value and deliberately returns the agent to the residence default (FrameworkConfig.toolResultInlineMaxChars → house 5000); the reset itself survives restarts. - Provenance: a resident value pinned above the strategy-derived bound is still honored (the resident owns this setting) but agent_settings get now reports 'agent-settings-override (exceeds strategy bound)' — a durable over-budget pin must never be silent (opus-rev's review point). - Docs/notices updated: no more 'temporary lift' framing. Refs #89. Co-Authored-By: Claude Fable 5 --- src/framework.ts | 97 ++++++++++++++++++++++++++++------ src/types/framework.ts | 5 +- test/tool-result-spill.test.ts | 69 +++++++++++++++++------- 3 files changed, 134 insertions(+), 37 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 74b1afa..33bbd3a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -785,9 +785,10 @@ export class AgentFramework { * provenance envelope + payload and requests inference. Keyed by script id. */ private backgroundScripts: Map = new Map(); private backgroundScriptCounter = 0; - /** Ephemeral per-agent override of the tool-result inline cap (chars). - * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted — - * the lift is meant as a temporary gate, reverts on restart/reset. */ + /** Resident-set per-agent tool-result inline cap (chars), via + * agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in + * framework state like the core runtime settings and restored at create + * (antra + Sol, 08-06); reset clears it back to the residence default. */ private toolResultInlineMaxCharsOverride: Map = new Map(); /** Durable residence-configured inline cap from * FrameworkConfig.toolResultInlineMaxChars; null → house default. */ @@ -1079,6 +1080,12 @@ export class AgentFramework { framework.toolResultInlineMaxCharsConfig = Math.floor(cap); } + // Restore resident-set inline caps (agent_settings) — durable like the + // core runtime settings, same framework/state slot (antra + Sol, 08-06). + for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) { + framework.toolResultInlineMaxCharsOverride.set(agentName, cap); + } + // Initialize MCPL subsystems if configured if (config.mcplServers && config.mcplServers.length > 0) { // Validate tool prefixes: no collisions with module names or between servers @@ -1520,10 +1527,11 @@ export class AgentFramework { private collectAgentSettingsExtensions(): Map { const result = new Map(); const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS); - // Framework-owned extension: the tool-result inline cap lift. Registered - // through the same extension surface modules use so get/update/reset all - // work with zero extra plumbing. EPHEMERAL by design (a temporary gate — - // reverts on restart), unlike module extensions which persist their own. + // Framework-owned extension: the resident's tool-result inline cap. + // Registered through the same extension surface modules use so + // get/update/reset all work with zero extra plumbing. DURABLE: persisted + // in framework state and restored at create, like the core runtime + // settings (antra + Sol, 08-06); reset returns to the residence default. { const ext = this.spillSettingsExtension(); ext.keys.forEach((k) => taken.add(k)); @@ -1555,9 +1563,9 @@ export class AgentFramework { '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 ' + + 'This is YOUR durable setting: it persists across restarts, like your other ' + + 'agent_settings. Update it when you want a different inline size; reset restores ' + + 'the residence default. The effective cap and its source are reported as ' + 'tool_result_inline_max_chars_effective / _source on get.', }, }, @@ -1570,7 +1578,9 @@ export class AgentFramework { 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)' : '') + ? resolved.source + + (resolved.strategyClamped ? ' (strategy-clamped)' : '') + + (resolved.exceedsStrategyBound ? ' (exceeds strategy bound)' : '') : null, }; }, @@ -1580,11 +1590,13 @@ export class AgentFramework { throw new Error('tool_result_inline_max_chars must be a number >= 1000'); } this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n)); + this.persistToolResultInlineCap(agentName, Math.floor(n)); return { tool_result_inline_max_chars: Math.floor(n) }; }, reset: (agentName: string, keys?: string[]) => { if (!keys || keys.includes('tool_result_inline_max_chars')) { this.toolResultInlineMaxCharsOverride.delete(agentName); + this.persistToolResultInlineCap(agentName, null); } return { tool_result_inline_max_chars: null }; }, @@ -2014,6 +2026,44 @@ export class AgentFramework { return overrides; } + /** Load resident-set inline caps from framework state, dropping (loudly) + * anything that fails the same validation the live update enforces. */ + private readPersistedToolResultInlineCaps(): Record { + try { + const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as { + toolResultInlineCaps?: Record; + } | null; + const out: Record = {}; + for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) { + const n = Number(value); + if (Number.isFinite(n) && n >= 1000) { + out[name] = Math.floor(n); + } else { + console.error( + `[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`, + ); + } + } + return out; + } catch { + return {}; + } + } + + /** Write (cap) or clear (null) one agent's resident-set inline cap. */ + private persistToolResultInlineCap(agentName: string, cap: number | null): void { + const data = this.store.getStateJson(FRAMEWORK_STATE_ID); + const state = (data && typeof data === 'object' ? data : {}) as Record; + const all = { + ...((state.toolResultInlineCaps as Record | undefined) ?? {}), + }; + if (cap === null) delete all[agentName]; + else all[agentName] = cap; + if (Object.keys(all).length === 0) delete state.toolResultInlineCaps; + else state.toolResultInlineCaps = all; + this.store.setStateJson(FRAMEWORK_STATE_ID, state); + } + private persistAgentRuntimeSettings( agentName: string, overrides: AgentRuntimeSettingsOverrides, @@ -6991,7 +7041,7 @@ export class AgentFramework { 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 ' + + 'Read/grep it with your file tools, or raise the inline cap via ' + 'agent_settings update tool_result_inline_max_chars.]', filePath: path, }; @@ -7224,20 +7274,33 @@ export class AgentFramework { /** * Effective tool-result inline cap for an agent, with provenance: - * agent_settings hot override (wins outright, ephemeral) → durable + * resident's durable agent_settings value (wins outright) → residence * 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. + * residence/default value is clamped down to the strategy-derived bound + * when that is smaller (a message must still fit maxMessageTokens); the + * resident's explicit value escapes the clamp but provenance flags it + * (`exceedsStrategyBound`) so the pin is never silent. */ private resolveToolResultInlineCap(agent: Agent): { cap: number; source: 'agent-settings-override' | 'framework-config' | 'default'; strategyClamped: boolean; + /** Resident's durable setting sits ABOVE the strategy bound — honored + * (the resident owns this), but provenance must say so: a silent + * over-budget pin that survives restarts is how compiles break later. */ + exceedsStrategyBound?: boolean; } { const override = this.toolResultInlineMaxCharsOverride.get(agent.name); if (override !== undefined) { - return { cap: override, source: 'agent-settings-override', strategyClamped: false }; + const strategyBound = this.strategyDerivedToolResultChars(agent); + return { + cap: override, + source: 'agent-settings-override', + strategyClamped: false, + ...(strategyBound !== undefined && override > strategyBound + ? { exceedsStrategyBound: true } + : {}), + }; } const configured = this.toolResultInlineMaxCharsConfig; const base = configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS; diff --git a/src/types/framework.ts b/src/types/framework.ts index 97968b4..1701824 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -147,8 +147,9 @@ export interface FrameworkConfig { * 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. + * are clamped down to it; a resident's own agent_settings value + * `tool_result_inline_max_chars` (durable, persisted in framework state) + * still wins outright for that agent. */ toolResultInlineMaxChars?: number; diff --git a/test/tool-result-spill.test.ts b/test/tool-result-spill.test.ts index 56f4284..d0f71a3 100644 --- a/test/tool-result-spill.test.ts +++ b/test/tool-result-spill.test.ts @@ -418,9 +418,11 @@ describe('tool-result spill completion (issue #89)', () => { } }); - it('restart keeps the durable cap and drops the ephemeral override', async () => { + it('restart keeps BOTH the configured cap and the resident-set value; reset returns to config', async () => { + // The resident's agent_settings value is durable (antra + Sol, 08-06): + // it persists in framework state like the core runtime settings. const { tempDir, storePath } = tempStorePath('spill-restart-'); - const first = await startSpillTurn({ + const boot = () => startSpillTurn({ prefix: 'unused-', result: { success: true, data: { small: true } }, withWorkspace: true, @@ -428,32 +430,63 @@ describe('tool-result spill completion (issue #89)', () => { tempDir, storePath, }); + + const first = await boot(); 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); + } finally { 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(); - } + const second = await boot(); + try { + const prov = capProvenance(second.framework); + assert.strictEqual(prov.tool_result_inline_max_chars, 60_000, 'resident value must survive restart'); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 60_000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'agent-settings-override'); + frameworkExtension(second.framework).reset('prime'); + const afterReset = capProvenance(second.framework); + assert.strictEqual(afterReset.tool_result_inline_max_chars_effective, 8_000, 'reset returns to the residence config'); + assert.strictEqual(afterReset.tool_result_inline_max_chars_source, 'framework-config'); } finally { + await second.framework.stop(); + } + + const third = await boot(); + try { + const prov = capProvenance(third.framework); + assert.strictEqual(prov.tool_result_inline_max_chars, null, 'reset must also survive restart'); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 8_000); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'framework-config'); + } finally { + await third.framework.stop(); rmSync(tempDir, { recursive: true, force: true }); } }); + it('flags a resident value pinned above the strategy bound in provenance', async () => { + const h = await startSpillTurn({ + prefix: 'spill-exceeds-', + result: { success: true, data: { small: true } }, + withWorkspace: true, + cappedStrategy: true, // strategy bound 4000 + }); + try { + frameworkExtension(h.framework).update('prime', { tool_result_inline_max_chars: 50_000 }); + const prov = capProvenance(h.framework); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 50_000, 'resident value is honored'); + assert.strictEqual( + prov.tool_result_inline_max_chars_source, + 'agent-settings-override (exceeds strategy bound)', + 'the over-bound pin must be visible, never silent', + ); + } finally { + await h.framework.stop(); + rmSync(h.tempDir, { recursive: true, force: true }); + } + }); + it('rejects an invalid configured cap at create()', async () => { const { tempDir, storePath } = tempStorePath('spill-invalid-'); const membrane = new MockMembrane(); From 5b9f0a7e2fef0d1f51be65b9f02f34f6b2d44c03 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Thu, 6 Aug 2026 11:53:04 -0700 Subject: [PATCH 2/2] feat(agent-settings): hard-clamp the effective inline cap to the strategy bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol's #94 ruling: persist the resident's DESIRED cap, but the EFFECTIVE inline cap is min(desired, strategy bound) for every source — a durable preference must not be a durable path for one tool result to exceed the context strategy's per-message safety limit. The way to see the whole result is the spill file, not an over-bound blob in live context. agent_settings get now reports the full quartet: - tool_result_inline_max_chars — persisted desired value (null if unset) - ..._effective — min(desired, strategy bound) - ..._source — agent-settings-override | framework-config | default - ..._clamped_by — 'strategy-bound' | null (replaces the previous ' (strategy-clamped)' / ' (exceeds strategy bound)' source-string suffixes with a clean dedicated key). Co-Authored-By: Claude Fable 5 --- src/framework.ts | 58 ++++++++++++++-------------------- src/types/framework.ts | 8 +++-- test/tool-result-spill.test.ts | 24 ++++++++------ 3 files changed, 43 insertions(+), 47 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 33bbd3a..9fd4a7c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1565,8 +1565,9 @@ export class AgentFramework { 'file under tool-results/ and replaced by a truncated preview + file reference. ' + 'This is YOUR durable setting: it persists across restarts, like your other ' + 'agent_settings. Update it when you want a different inline size; reset restores ' + - 'the residence default. The effective cap and its source are reported as ' + - 'tool_result_inline_max_chars_effective / _source on get.', + 'the residence default. The effective cap is min(your value, your strategy\'s ' + + 'per-message bound) — the bound is a safety ceiling, and the full content is ' + + 'always in the spill file. get reports desired/_effective/_source/_clamped_by.', }, }, keys: ['tool_result_inline_max_chars'], @@ -1577,11 +1578,8 @@ export class AgentFramework { 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)' : '') - + (resolved.exceedsStrategyBound ? ' (exceeds strategy bound)' : '') - : null, + tool_result_inline_max_chars_source: resolved?.source ?? null, + tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null, }; }, update: (agentName: string, patch: Record) => { @@ -7273,43 +7271,33 @@ export class AgentFramework { } /** - * Effective tool-result inline cap for an agent, with provenance: - * resident's durable agent_settings value (wins outright) → residence - * FrameworkConfig.toolResultInlineMaxChars → house default (5000). The - * residence/default value is clamped down to the strategy-derived bound - * when that is smaller (a message must still fit maxMessageTokens); the - * resident's explicit value escapes the clamp but provenance flags it - * (`exceedsStrategyBound`) so the pin is never silent. + * Effective tool-result inline cap for an agent, with provenance. Desired + * value: resident's durable agent_settings value → residence + * FrameworkConfig.toolResultInlineMaxChars → house default (5000). + * 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 + * one tool result to exceed maxMessageTokens; the way to see the whole + * result is the spill file, not an over-bound blob in live context). */ private resolveToolResultInlineCap(agent: Agent): { cap: number; source: 'agent-settings-override' | 'framework-config' | 'default'; - strategyClamped: boolean; - /** Resident's durable setting sits ABOVE the strategy bound — honored - * (the resident owns this), but provenance must say so: a silent - * over-budget pin that survives restarts is how compiles break later. */ - exceedsStrategyBound?: boolean; + /** Set when the desired value was reduced to the strategy bound. */ + clampedBy: 'strategy-bound' | null; } { const override = this.toolResultInlineMaxCharsOverride.get(agent.name); - if (override !== undefined) { - const strategyBound = this.strategyDerivedToolResultChars(agent); - return { - cap: override, - source: 'agent-settings-override', - strategyClamped: false, - ...(strategyBound !== undefined && override > strategyBound - ? { exceedsStrategyBound: true } - : {}), - }; - } const configured = this.toolResultInlineMaxCharsConfig; - const base = configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS; + const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS; + const source = override !== undefined + ? 'agent-settings-override' as const + : configured !== null ? 'framework-config' as const : 'default' as const; const strategyBound = this.strategyDerivedToolResultChars(agent); - const strategyClamped = strategyBound !== undefined && strategyBound < base; + const clamped = strategyBound !== undefined && strategyBound < desired; return { - cap: strategyClamped ? strategyBound : base, - source: configured !== null ? 'framework-config' : 'default', - strategyClamped, + cap: clamped ? strategyBound : desired, + source, + clampedBy: clamped ? 'strategy-bound' : null, }; } diff --git a/src/types/framework.ts b/src/types/framework.ts index 1701824..39bca40 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -146,10 +146,12 @@ export interface FrameworkConfig { * 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; a resident's own agent_settings value + * >= 1000. A resident's own agent_settings value * `tool_result_inline_max_chars` (durable, persisted in framework state) - * still wins outright for that agent. + * takes precedence over this for that agent; the EFFECTIVE cap for every + * source is min(desired, strategy bound) — desired values above the + * strategy-derived bound (maxMessageTokens * 4) are hard-clamped to it, + * with the clamp reported via agent_settings get. */ toolResultInlineMaxChars?: number; diff --git a/test/tool-result-spill.test.ts b/test/tool-result-spill.test.ts index d0f71a3..b9ce429 100644 --- a/test/tool-result-spill.test.ts +++ b/test/tool-result-spill.test.ts @@ -411,7 +411,8 @@ describe('tool-result spill completion (issue #89)', () => { 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)'); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'default'); + assert.strictEqual(prov.tool_result_inline_max_chars_clamped_by, 'strategy-bound'); } finally { await h.framework.stop(); rmSync(h.tempDir, { recursive: true, force: true }); @@ -465,22 +466,27 @@ describe('tool-result spill completion (issue #89)', () => { } }); - it('flags a resident value pinned above the strategy bound in provenance', async () => { + it('hard-clamps a resident value above the strategy bound, reporting desired AND effective', async () => { + // Sol's #94 ruling: persist the desired cap, but the effective inline + // cap is min(desired, strategy bound) — durable preference must not be a + // durable path past the per-message safety limit. const h = await startSpillTurn({ prefix: 'spill-exceeds-', - result: { success: true, data: { small: true } }, + result: { success: true, data: { blob: 'q'.repeat(42_000) } }, withWorkspace: true, cappedStrategy: true, // strategy bound 4000 }); try { frameworkExtension(h.framework).update('prime', { tool_result_inline_max_chars: 50_000 }); const prov = capProvenance(h.framework); - assert.strictEqual(prov.tool_result_inline_max_chars_effective, 50_000, 'resident value is honored'); - assert.strictEqual( - prov.tool_result_inline_max_chars_source, - 'agent-settings-override (exceeds strategy bound)', - 'the over-bound pin must be visible, never silent', - ); + assert.strictEqual(prov.tool_result_inline_max_chars, 50_000, 'desired value is preserved'); + assert.strictEqual(prov.tool_result_inline_max_chars_effective, 4_000, 'effective is hard-clamped'); + assert.strictEqual(prov.tool_result_inline_max_chars_source, 'agent-settings-override'); + assert.strictEqual(prov.tool_result_inline_max_chars_clamped_by, 'strategy-bound'); + // The clamp is enforced on the wire, not just reported. + const stored = await waitForStoredToolResult(h.framework); + assert.ok(stored, 'tool result should be stored'); + assert.match(stored.content, /showing 4000 of \d+ chars/); } finally { await h.framework.stop(); rmSync(h.tempDir, { recursive: true, force: true });