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
111 changes: 81 additions & 30 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,9 +785,10 @@ export class AgentFramework {
* provenance envelope + payload and requests inference. Keyed by script id. */
private backgroundScripts: Map<string, BackgroundScriptRecord> = 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<string, number> = new Map();
/** Durable residence-configured inline cap from
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1520,10 +1527,11 @@ export class AgentFramework {
private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
const result = new Map<string, AgentSettingsExtension>();
const taken = new Set<string>(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));
Expand Down Expand Up @@ -1555,10 +1563,11 @@ 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 ' +
'tool_result_inline_max_chars_effective / _source on get.',
'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 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'],
Expand All @@ -1569,9 +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)' : '')
: 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<string, unknown>) => {
Expand All @@ -1580,11 +1588,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 };
},
Expand Down Expand Up @@ -2014,6 +2024,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<string, number> {
try {
const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
toolResultInlineCaps?: Record<string, unknown>;
} | null;
const out: Record<string, number> = {};
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<string, unknown>;
const all = {
...((state.toolResultInlineCaps as Record<string, number> | 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,
Expand Down Expand Up @@ -6991,7 +7039,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,
};
Expand Down Expand Up @@ -7223,30 +7271,33 @@ export class AgentFramework {
}

/**
* 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.
* 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;
/** 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) {
return { cap: override, source: 'agent-settings-override', strategyClamped: false };
}
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,
};
}

Expand Down
9 changes: 6 additions & 3 deletions src/types/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +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; the ephemeral agent_settings override
* `tool_result_inline_max_chars` still wins outright for one agent.
* >= 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
* 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;

Expand Down
77 changes: 58 additions & 19 deletions test/tool-result-spill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,49 +411,88 @@ 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 });
}
});

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,
toolResultInlineMaxChars: 8_000,
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('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: { 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, 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 });
}
});

it('rejects an invalid configured cap at create()', async () => {
const { tempDir, storePath } = tempStorePath('spill-invalid-');
const membrane = new MockMembrane();
Expand Down