From 804adc5509f3075fc3381bd68539fb591b54c4c6 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 12:19:28 -0400 Subject: [PATCH 01/16] fix(settings): a lower context budget the agent had reached comes back reached after a restart context-manager #162 (Xenophanes, CM 0.7.0 + AF 0.11.0): a persisted lower contextBudgetTokens restarted its paced descent at boot. Probed on AF 02adf27 with CM 0.13.0 and on AF 0.11.0 with CM 0.7.0 (~/.local/state/ada-1017/cm162): a settled descent re-settles on the first compile after a restart, since the carried frontier fits, but until then the snapshot says converging and compiles plan at the recipe's budget. "Restarts from scratch and blocks" didn't reproduce; the fix doesn't depend on how the reporter's store lost its carried frontier. Design agreed with Oscar-1005 (room-555 #70648, #70676). - The snapshot gains effectiveContextBudgetTokens, the budget compiles plan at now (the agent's live contextBudgetTokens, or the default), beside contextBudgetTokens, which stays the target so readers don't change. - The persisted record gains contextBudgetReached: true when the live budget is at the persisted target with no descent running: persistedRuntimeSettingsOf() adds it at the update, reset and cancel persists (an immediate decrease), and a settle records it. - A settle is recorded. Agent.onRuntimeSettingsSettled(listener) is told when settleRuntimeSettingsTransition settles; the framework sets it per agent at createAgent. recordSettledRuntimeSettings marks the persisted record only when its own contextBudgetTokens is the budget that settled, so a descent applied with persist: false writes nothing, and no other ephemeral change is promoted. A failure is logged; the settle stands. - Restore: Agent.restoreRuntimeSettings applies a marked budget through the immediate path. Oscar-1005 checked the safety on CM's side (kv-control): the store fit that budget at the stop, so bootstrap or infeasible adopts the ideal cut at once even without a carried frontier; only escalated refuses, and that can't arise for a budget that fit. A record without the marker (a descent still running, or an earlier release's record) restores paced, as before: a missing marker must not mean reached, since an old record can hold a target below the prepared-window floor. - validatePersistedAgentRuntimeSettings drops a marker that isn't true, with a log line (the budget then restores paced). AgentRuntimeSettingsOverrides is the patch plus the optional marker; the Agent's in-memory map never holds it. Out of scope, as agreed: keeping a running descent's live budget across a restart (it resumes from the recipe's budget, as today). Tests (agent-runtime-settings, 20): a descent that settled comes back stable at its budget before any compile, with the settle's write read back from framework/state; an immediate decrease persists the marker and comes back stable; a record without the marker, and one with an invalid marker, restore paced (the latter logs); a settle marks only the persisted budget it reached; an ephemeral descent's settle writes nothing. The existing snapshots carry the effective budget, and the interrupted descent's restore is converging at the recipe's 100k. The realistic probe (168k of messages, 250k -> 120k): after a restart, before any compile, stable at 120k; the first compile keeps the carried 100,980 frontier. Mutations of the compiled sources, each failing a test: restore ignoring the marker, the settle telling no one, the effective budget reporting the target, an applied budget never getting the marker, a settle marking a record whose budget differs, and an invalid marker kept. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 2 + src/agent.ts | 21 ++- src/framework.ts | 52 ++++++- src/types/agent.ts | 21 ++- test/agent-runtime-settings.test.ts | 144 ++++++++++++++++++ 5 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 changelog.d/budget-reached-across-restart.fixed.md diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md new file mode 100644 index 000000000..60f5548e7 --- /dev/null +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -0,0 +1,2 @@ +- A lower context budget an agent had reached comes back reached after a restart (context-manager #162). The persisted runtime settings now record `contextBudgetReached: true` beside `contextBudgetTokens` once the live budget is at it: when an immediate decrease applies, or when a paced descent settles, which until now wrote nothing. A restore with the marker applies the budget at once, through the immediate path. Before, every restore paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. Since the store fit that budget when it was reached, the planner can fold to it at once even if its carried frontier was lost. A record without the marker restores paced, as before: a descent still running at the stop, or a record from an earlier release. A descent applied with `persist: false` writes nothing when it settles, and an invalid marker is ignored with a log line. +- The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now. While a lower budget converges, it is still the budget the descent started from; `contextBudgetTokens` stays the budget the agent is set to. diff --git a/src/agent.ts b/src/agent.ts index 412707a33..b2903d116 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -141,7 +141,9 @@ export class Agent { private readonly configuredTransitionPaceTokens?: number; private readonly configuredSameRoundThinkTextPolicy?: SameRoundThinkTextPolicy; private contextBudgetTargetTokens?: number; - private runtimeSettingsOverrides: AgentRuntimeSettingsOverrides = {}; + private runtimeSettingsOverrides: AgentRuntimeSettingsPatch = {}; + /** Told when a paced descent settles at its target (the framework records it). */ + private runtimeSettingsSettledListener?: () => void; private contextManager: ContextManager; private membrane: Membrane; @@ -284,6 +286,7 @@ export class Agent { this.contextBudgetTargetTokens ?? this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS, + effectiveContextBudgetTokens: this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS, ...(hot ? { tailTokens: hot.tailTokens } : {}), ...(hot?.transitionPaceTokens !== undefined ? { transitionPaceTokens: hot.transitionPaceTokens } @@ -307,6 +310,11 @@ export class Agent { return { ...this.runtimeSettingsOverrides }; } + /** Set (or clear) the one listener told when a paced descent settles at its target. */ + onRuntimeSettingsSettled(listener: (() => void) | undefined): void { + this.runtimeSettingsSettledListener = listener; + } + /** * Read-only mirror of `updateRuntimeSettings`' budget semantics: derive what * the next compile would actually plan at if `patch` were applied, WITHOUT @@ -515,7 +523,15 @@ export class Agent { } restoreRuntimeSettings(overrides: AgentRuntimeSettingsOverrides): AgentRuntimeSettingsSnapshot { - return this.updateRuntimeSettings(overrides); + const { contextBudgetReached, ...settings } = overrides; + // A lower budget the agent had reached before the stop comes back + // reached, through the immediate path: the store fit it then. Without + // the marker, a descent resumes paced from the recipe's budget. + return this.updateRuntimeSettings( + contextBudgetReached === true && settings.contextBudgetTokens !== undefined + ? { ...settings, immediate: true } + : settings, + ); } private validateRuntimeSettingsPatch(patch: AgentRuntimeSettingsPatch): void { @@ -551,6 +567,7 @@ export class Agent { this.contextBudgetTokens = this.contextBudgetTargetTokens; this.contextBudgetTargetTokens = undefined; this.updateHotContextSettings({ preparedWindowTokens: null }); + this.runtimeSettingsSettledListener?.(); } /** diff --git a/src/framework.ts b/src/framework.ts index 349bdd693..dfe86085c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2944,7 +2944,7 @@ export class AgentFramework { } const result = agent.updateRuntimeSettings(patch); if (opts?.persist !== false) { - this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides()); + this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent)); } this.recordOperatorAction({ kind: 'settings-update', @@ -2965,7 +2965,7 @@ export class AgentFramework { if (!agent) throw new Error(`Unknown agent: ${agentName}`); const result = agent.resetRuntimeSettings(keys); if (opts?.persist !== false) { - this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides()); + this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent)); } this.recordOperatorAction({ kind: 'settings-reset', @@ -2984,7 +2984,7 @@ export class AgentFramework { const agent = this.agents.get(agentName); if (!agent) throw new Error(`Unknown agent: ${agentName}`); const result = agent.cancelRuntimeSettingsTransition(); - this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides()); + this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent)); this.recordOperatorAction({ kind: 'settings-cancel-transition', agent: agentName, @@ -3980,6 +3980,15 @@ export class AgentFramework { agentName: string, overrides: AgentRuntimeSettingsOverrides, ): AgentRuntimeSettingsOverrides { + if (overrides.contextBudgetReached !== undefined && overrides.contextBudgetReached !== true) { + // Unreadable, so not trusted: the budget restores paced, as before the marker existed. + console.error( + `[agent-settings] ignoring an invalid persisted contextBudgetReached for agent "${agentName}": ` + + `${JSON.stringify(overrides.contextBudgetReached)}`, + ); + const { contextBudgetReached: _ignored, ...rest } = overrides; + overrides = rest; + } if ( overrides.sameRoundThinkTextPolicy !== undefined && overrides.sameRoundThinkTextPolicy !== 'public' && @@ -4030,6 +4039,42 @@ export class AgentFramework { this.store.setStateJson(FRAMEWORK_STATE_ID, state); } + /** + * What persists for an agent: its overrides, and `contextBudgetReached` + * when its live budget is at the overridden budget with no descent + * running (context-manager #162). + */ + private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides { + const overrides = agent.getRuntimeSettingsOverrides(); + const snapshot = agent.getRuntimeSettings(); + const reached = overrides.contextBudgetTokens !== undefined + && snapshot.transition === 'stable' + && snapshot.effectiveContextBudgetTokens === overrides.contextBudgetTokens; + return reached ? { ...overrides, contextBudgetReached: true } : overrides; + } + + /** + * A paced descent settled. When the persisted record holds the budget it + * settled at, mark that budget reached, so a restart applies it at once + * instead of pacing a descent again (context-manager #162). Only the + * marker is written: a descent applied with `persist: false` left no + * record of its target, so it stays unwritten, and other ephemeral changes + * aren't promoted. + */ + private recordSettledRuntimeSettings(agentName: string, agent: Agent): void { + try { + const persisted = this.readAgentRuntimeSettings(agentName); + if (!persisted) return; + // The record's own budget, and no other: a descent applied without + // persisting may have settled somewhere else. + if (persisted.contextBudgetTokens !== agent.getRuntimeSettings().effectiveContextBudgetTokens) return; + this.persistAgentRuntimeSettings(agentName, { ...persisted, contextBudgetReached: true }); + } catch (error) { + // The settle itself stands; a restart then paces the descent again. + console.error(`[agent-settings] couldn't record that ${agentName}'s budget settled:`, error); + } + } + private persistAgentRuntimeSettings( agentName: string, overrides: AgentRuntimeSettingsOverrides, @@ -6466,6 +6511,7 @@ export class AgentFramework { this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings), ); } + agent.onRuntimeSettingsSettled(() => this.recordSettledRuntimeSettings(config.name, agent)); this.agents.set(config.name, agent); this.agentConfigs.set(config.name, config); diff --git a/src/types/agent.ts b/src/types/agent.ts index bbf4971ca..1a4930115 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -223,7 +223,14 @@ export interface AgentRuntimeSettingsPatch { } export interface AgentRuntimeSettingsSnapshot { + /** The budget the agent is set to: while a lower one converges, its target. */ contextBudgetTokens: number; + /** + * The budget compiles plan at now. It equals `contextBudgetTokens` except + * while a lower budget converges, when it is still the budget the descent + * started from (context-manager #162). + */ + effectiveContextBudgetTokens: number; tailTokens?: number; transitionPaceTokens?: number; sameRoundThinkTextPolicy: SameRoundThinkTextPolicy; @@ -233,7 +240,19 @@ export interface AgentRuntimeSettingsSnapshot { } /** Persisted values differ from recipe defaults; omitted keys inherit config. */ -export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch; +export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { + /** + * Written beside `contextBudgetTokens` once the agent's live budget has + * reached it: by an immediate apply, or when a paced descent settles. A + * restore then applies that budget at once, rather than pacing a descent + * again from the recipe's budget (context-manager #162). The store fit + * that budget when it was reached, so the planner can fold to it at once + * even if its carried frontier was lost. Without it (a descent still + * running at the stop, or a record from an earlier release) the restore + * paces a descent, as before. Never part of a patch. + */ + contextBudgetReached?: true; +}; /** * Result of running inference. diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 90c482f84..c87ed80ef 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -50,6 +50,7 @@ it('agent_settings is one typed tool for the hot runtime surface', async () => { ); assert.deepEqual(framework.getAgentRuntimeSettings('agent'), { contextBudgetTokens: 100_000, + effectiveContextBudgetTokens: 100_000, tailTokens: 30_000, transitionPaceTokens: 8_000, sameRoundThinkTextPolicy: 'public', @@ -66,6 +67,7 @@ it('agent_settings is one typed tool for the hot runtime surface', async () => { }), { contextBudgetTokens: 60_000, + effectiveContextBudgetTokens: 100_000, tailTokens: 20_000, transitionPaceTokens: 4_000, sameRoundThinkTextPolicy: 'private', @@ -79,6 +81,7 @@ it('agent_settings is one typed tool for the hot runtime surface', async () => { ); assert.deepEqual(framework.resetAgentRuntimeSettings('agent'), { contextBudgetTokens: 100_000, + effectiveContextBudgetTokens: 100_000, tailTokens: 30_000, transitionPaceTokens: 8_000, sameRoundThinkTextPolicy: 'public', @@ -120,8 +123,11 @@ it('runtime overrides persist across framework restart', async () => { try { framework = await AgentFramework.create(config()); + // The descent was still running at the stop, so it resumes paced from the + // recipe's budget. assert.deepEqual(framework.getAgentRuntimeSettings('agent'), { contextBudgetTokens: 70_000, + effectiveContextBudgetTokens: 100_000, tailTokens: 18_000, transitionPaceTokens: 5_000, sameRoundThinkTextPolicy: 'private', @@ -134,6 +140,140 @@ it('runtime overrides persist across framework restart', async () => { } }); +// context-manager #162: a lower budget the agent had reached comes back reached. +describe('a reached budget across a restart', () => { + const persisted = (framework: AgentFramework) => + (framework.getStore().getStateJson('framework/state') as { agentRuntimeSettings?: Record } | null) + ?.agentRuntimeSettings?.agent; + const setup = () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-settings-reached-')); + const storePath = join(dir, 'store'); + const config = () => ({ + storePath, + membrane, + agents: [{ + name: 'agent', model: 'test-model', systemPrompt: 'test', + strategy: strategy(), contextBudgetTokens: 100_000, maxTokens: 10_000, + }], + modules: [], + }); + return { dir, storePath, config }; + }; + const budgets = (framework: AgentFramework) => { + const s = framework.getAgentRuntimeSettings('agent'); + return { target: s.contextBudgetTokens, effective: s.effectiveContextBudgetTokens, transition: s.transition }; + }; + + it('a descent that settled comes back settled, before any compile', async () => { + const { dir, config } = setup(); + let framework = await AgentFramework.create(config()); + try { + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 100_000, transition: 'converging' }); + assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000 }, 'not reached yet'); + // The store is empty, so the first compile's frontier fits and the descent settles. + await framework.getAgent('agent')!.compileContext(); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000, contextBudgetReached: true }, 'the settle is recorded'); + await framework.stop(); + framework = await AgentFramework.create(config()); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('an immediate decrease is recorded as reached, and comes back so', async () => { + const { dir, config } = setup(); + let framework = await AgentFramework.create(config()); + try { + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000, contextBudgetReached: true }); + await framework.stop(); + framework = await AgentFramework.create(config()); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a record without the marker (an earlier release) restores paced, as before', async () => { + const { dir, config } = setup(); + let framework = await AgentFramework.create(config()); + try { + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + const store = framework.getStore(); + const state = store.getStateJson('framework/state') as { agentRuntimeSettings: Record> }; + delete state.agentRuntimeSettings.agent.contextBudgetReached; + store.setStateJson('framework/state', state); + await framework.stop(); + framework = await AgentFramework.create(config()); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 100_000, transition: 'converging' }); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('an invalid marker is ignored, and the budget restores paced', async () => { + const { dir, config } = setup(); + let framework = await AgentFramework.create(config()); + const errors: string[] = []; + const original = console.error; + try { + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + const store = framework.getStore(); + const state = store.getStateJson('framework/state') as { agentRuntimeSettings: Record> }; + state.agentRuntimeSettings.agent.contextBudgetReached = 'yes'; + store.setStateJson('framework/state', state); + await framework.stop(); + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }; + framework = await AgentFramework.create(config()); + console.error = original; + assert.deepEqual(budgets(framework), { target: 60_000, effective: 100_000, transition: 'converging' }); + assert.ok(errors.some((line) => line.includes('ignoring an invalid persisted contextBudgetReached')), errors.join('\n')); + } finally { + console.error = original; + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a settle marks only the persisted budget it reached', async () => { + const { dir, config } = setup(); + const framework = await AgentFramework.create(config()); + try { + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); + await framework.getAgent('agent')!.compileContext(); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(framework), { contextBudgetTokens: 80_000 }, 'the record\'s 80k was never the live budget'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a descent applied without persisting settles without writing anything', async () => { + const { dir, config } = setup(); + let framework = await AgentFramework.create(config()); + try { + framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); + await framework.getAgent('agent')!.compileContext(); + assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.equal(persisted(framework), undefined, 'nothing written for an ephemeral descent'); + await framework.stop(); + framework = await AgentFramework.create(config()); + assert.deepEqual(budgets(framework), { target: 100_000, effective: 100_000, transition: 'stable' }, 'the recipe budget'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + it('reset-all skips tail controls unsupported by the active strategy', async () => { const dir = mkdtempSync(join(tmpdir(), 'agent-settings-basic-')); const framework = await AgentFramework.create({ @@ -145,6 +285,7 @@ it('reset-all skips tail controls unsupported by the active strategy', async () try { assert.deepEqual(framework.resetAgentRuntimeSettings('agent'), { contextBudgetTokens: 100_000, + effectiveContextBudgetTokens: 100_000, sameRoundThinkTextPolicy: 'public', sameRoundThinkTextPolicySource: 'compatibility_default', transition: 'stable', @@ -171,6 +312,7 @@ it('same_round_think_text_policy reports recipe/runtime/default sources and reje try { assert.deepEqual(framework.getAgentRuntimeSettings('agent'), { contextBudgetTokens: 100_000, + effectiveContextBudgetTokens: 100_000, sameRoundThinkTextPolicy: 'private', sameRoundThinkTextPolicySource: 'recipe', transition: 'stable', @@ -180,6 +322,7 @@ it('same_round_think_text_policy reports recipe/runtime/default sources and reje framework.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'public' }), { contextBudgetTokens: 100_000, + effectiveContextBudgetTokens: 100_000, sameRoundThinkTextPolicy: 'public', sameRoundThinkTextPolicySource: 'runtime_override', transition: 'stable', @@ -190,6 +333,7 @@ it('same_round_think_text_policy reports recipe/runtime/default sources and reje framework.resetAgentRuntimeSettings('agent', ['sameRoundThinkTextPolicy']), { contextBudgetTokens: 100_000, + effectiveContextBudgetTokens: 100_000, sameRoundThinkTextPolicy: 'private', sameRoundThinkTextPolicySource: 'recipe', transition: 'stable', From 5243c5df742dc661f9c0190102fa26ba7cd80200 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 12:38:28 -0400 Subject: [PATCH 02/16] fix(settings): a budget counts as reached only when a compile fits at it, and boot checks it still fits The review of 804adc5 found the marker could vouch for a budget nothing had shown fits. Oscar-1005 (room-555 #71420) and the review workflow (af-cm162, wf-296, I1 and its siblings) found the same hole from two sides: - An immediate decrease was marked at the update, before any compile ran at it, including one applied with allowInfeasible or without a preview. - The restore applied a marked budget through the immediate path with no feasibility check, though a restart is when a new system prompt, tools or maxTokens land, and the agent may have run at another budget (a persist: false change) since the record was written. Boot restore's guarantee, that a persisted budget can never brick startup, no longer held. What the field means now, and where it's kept: - contextBudgetReachedTokens (was contextBudgetReached: true) holds the budget the agent's last compile planned at, written while that compile fit. It is valid only when equal to contextBudgetTokens, so a value an older release carried forward beside a budget it changed restores paced (I13). - Agent notes each compile at its live budget (compileAtLiveBudget, used by compileContext and compileWithInjections when no budget is given): one that fits notes the live budget after any settle (a settle proves the target, since the frontier fit the window prepared for it); one the context manager refuses as OverBudgetError notes it refused. The listener (onContextBudgetCompiled) hears only changes. - The framework keeps the persisted record true to that (recordCompiledContextBudget): it writes the field when a compile fits at the record's own budget and withdraws it when that budget is refused (I5). A compile at any other budget writes nothing. persistedRuntimeSettingsOf, at update, reset and cancel, writes it only for the budget the last compile fit at, so an immediate change carries none until a compile fits at it (I1). - Boot: restorePersistedRuntimeSettings applies a record whose two values match through the immediate path only when previewAgentRuntimeSettings says it fits now (Oscar-1005's fix), and the agent then holds that budget as proven until a compile says otherwise. Otherwise it restores paced, with a log line naming the folded floor or why it can't be checked. The agent is registered before its settings are restored, since the preview finds it by name. - Agent.restoreRuntimeSettings(settings, { provenBudget }) restores nothing for a record with no settings (I20). Only contextBudgetTokens, tailTokens and transitionPaceTokens reach the override map, so a patch can't set the field (I12). An invalid value is ignored with a line bounded to 80 characters (I11). - Docs: the field, the snapshot's effective budget (the context manager's own default is the same when the recipe sets none, I10), and the fragment, rewritten for all of this, including what a test double that builds a snapshot must add (I19). Tests (agent-runtime-settings, 27): a settled descent comes back stable, a persisting call before any compile keeps the record's proof, and the first compile after the restart plans at the reached budget (I14); a restored descent that settles after the restart is recorded then (I7); an immediate decrease is recorded once a compile fits at it, not before, and one that never compiled comes back paced (I1); a refused compile withdraws the field (I5); a reached budget the preflight says no longer fits restores paced with its line; a record without the field, or with one for another budget, restores paced (I13); an invalid field is ignored with a bounded line, and a record holding only it boots (I11, I20); a patch can't set the field (I12); every persisting call keeps the field true to the live state, through a tail update, two cancels, a new descent and a reset (I3); a settle marks only the record's budget; an ephemeral descent writes nothing and logs nothing (I15); a failed write is logged and the compile stands (I6). The realistic probe (168k of messages, 250k -> 120k) still comes back stable at 120k before any compile, with the preflight passing. Mutations of the compiled sources, each failing a test (~/.local/state/ada-1017/tmp/chk/162/mutants-3.txt, mutants-4.txt): restore ignoring the field, restore skipping the preflight, a fitting compile or a refused one not noted, persisting never writing the field or writing it for any override, a compile writing it for another budget, the no-record guard removed, an invalid field kept, a patch setting any key, an empty record applied, the restore proving nothing, and the effective budget reporting the target. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 4 +- src/agent.ts | 88 ++++-- src/framework.ts | 103 ++++-- src/types/agent.ts | 27 +- test/agent-runtime-settings.test.ts | 292 ++++++++++++------ 5 files changed, 349 insertions(+), 165 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 60f5548e7..eac5dfd51 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,2 +1,2 @@ -- A lower context budget an agent had reached comes back reached after a restart (context-manager #162). The persisted runtime settings now record `contextBudgetReached: true` beside `contextBudgetTokens` once the live budget is at it: when an immediate decrease applies, or when a paced descent settles, which until now wrote nothing. A restore with the marker applies the budget at once, through the immediate path. Before, every restore paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. Since the store fit that budget when it was reached, the planner can fold to it at once even if its carried frontier was lost. A record without the marker restores paced, as before: a descent still running at the stop, or a record from an earlier release. A descent applied with `persist: false` writes nothing when it settles, and an invalid marker is ignored with a log line. -- The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now. While a lower budget converges, it is still the budget the descent started from; `contextBudgetTokens` stays the budget the agent is set to. +- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. The persisted runtime settings now carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget. An immediate change carries none until a compile fits at it. At boot, a record whose two values match is applied through the immediate path, but only if the preflight says the budget fits now, since a restart can bring a new system prompt, tools or `maxTokens`. Otherwise, and for a record without the field (a descent still running at the stop, or a record from an earlier release) or with a value for another budget, it restores paced, as before, with a log line when a reached budget no longer fits. A boot restore still never refuses a persisted budget. A patch can't set the field, an invalid value is ignored with a log line, and a record holding nothing else restores nothing. +- The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now. While a lower budget converges, it is still the budget the descent started from; `contextBudgetTokens` stays the budget the agent is set to. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. diff --git a/src/agent.ts b/src/agent.ts index b2903d116..b0228fd23 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -37,6 +37,7 @@ import type { HotContextSettingsUpdate, MessageMetadata, } from '@animalabs/context-manager'; +import { OverBudgetError } from '@animalabs/context-manager'; import type { AgentConfig, AgentState, @@ -142,8 +143,10 @@ export class Agent { private readonly configuredSameRoundThinkTextPolicy?: SameRoundThinkTextPolicy; private contextBudgetTargetTokens?: number; private runtimeSettingsOverrides: AgentRuntimeSettingsPatch = {}; - /** Told when a paced descent settles at its target (the framework records it). */ - private runtimeSettingsSettledListener?: () => void; + /** The live budget the last compile planned at, and whether it fit. */ + private lastCompiledBudget?: { budget: number; fit: boolean }; + /** Told when that changes (the framework records it beside the persisted budget). */ + private compiledBudgetListener?: (budget: number, fit: boolean) => void; private contextManager: ContextManager; private membrane: Membrane; @@ -310,9 +313,42 @@ export class Agent { return { ...this.runtimeSettingsOverrides }; } - /** Set (or clear) the one listener told when a paced descent settles at its target. */ - onRuntimeSettingsSettled(listener: (() => void) | undefined): void { - this.runtimeSettingsSettledListener = listener; + /** + * Set (or clear) the one listener told when a compile at the live budget + * comes out differently from the last: it fit at a budget the last one + * didn't plan at (after a settle, the target), or the context manager + * refused a budget the last one fit at. + */ + onContextBudgetCompiled(listener: ((budget: number, fit: boolean) => void) | undefined): void { + this.compiledBudgetListener = listener; + } + + /** The live budget the last compile planned at and fit, if it fit. */ + getProvenContextBudgetTokens(): number | undefined { + return this.lastCompiledBudget?.fit ? this.lastCompiledBudget.budget : undefined; + } + + private noteCompiledBudget(budget: number, fit: boolean): void { + if (this.lastCompiledBudget?.budget === budget && this.lastCompiledBudget.fit === fit) return; + this.lastCompiledBudget = { budget, fit }; + this.compiledBudgetListener?.(budget, fit); + } + + /** Compile at the live budget (no explicit one), noting whether it fit. */ + private async compileAtLiveBudget(compile: () => Promise): Promise { + const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; + let result: CompileResult; + try { + result = await compile(); + } catch (error) { + const err = error as { name?: unknown } | null; + if (error instanceof OverBudgetError || err?.name === 'OverBudgetError') this.noteCompiledBudget(live, false); + throw error; + } + // A settle proves the target: the frontier fit the window prepared for it. + this.settleRuntimeSettingsTransition(); + this.noteCompiledBudget(this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS, true); + return result; } /** @@ -447,10 +483,10 @@ export class Agent { if (appliedDefaultPace) { this.runtimeSettingsOverrides.transitionPaceTokens = DEFAULT_TRANSITION_PACE_TOKENS; } - for (const [key, value] of Object.entries(patch)) { - if (key === 'sameRoundThinkTextPolicy') continue; - if (key === 'immediate') continue; // a mode for this apply, never a persisted setting - if (value !== undefined) (this.runtimeSettingsOverrides as Record)[key] = value; + // Only settings reach the override map: `immediate` is a mode for this + // apply, and nothing else a caller passes is a setting. + for (const key of ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens'] as const) { + if (patch[key] !== undefined) this.runtimeSettingsOverrides[key] = patch[key]; } if (patch.sameRoundThinkTextPolicy !== undefined) { this.runtimeSettingsOverrides.sameRoundThinkTextPolicy = patch.sameRoundThinkTextPolicy; @@ -522,16 +558,19 @@ export class Agent { return this.getRuntimeSettings(); } - restoreRuntimeSettings(overrides: AgentRuntimeSettingsOverrides): AgentRuntimeSettingsSnapshot { - const { contextBudgetReached, ...settings } = overrides; - // A lower budget the agent had reached before the stop comes back - // reached, through the immediate path: the store fit it then. Without - // the marker, a descent resumes paced from the recipe's budget. - return this.updateRuntimeSettings( - contextBudgetReached === true && settings.contextBudgetTokens !== undefined - ? { ...settings, immediate: true } - : settings, - ); + /** + * Apply persisted settings at construction. `provenBudget` is a budget the + * caller has shown fits (the framework, from the record and a preflight): + * it stands as the last compile's until a compile here says otherwise. + */ + restoreRuntimeSettings( + settings: AgentRuntimeSettingsPatch, + opts?: { provenBudget?: number }, + ): AgentRuntimeSettingsSnapshot { + if (Object.keys(settings).length === 0) return this.getRuntimeSettings(); + const snapshot = this.updateRuntimeSettings(settings); + if (opts?.provenBudget !== undefined) this.lastCompiledBudget = { budget: opts.provenBudget, fit: true }; + return snapshot; } private validateRuntimeSettingsPatch(patch: AgentRuntimeSettingsPatch): void { @@ -567,7 +606,6 @@ export class Agent { this.contextBudgetTokens = this.contextBudgetTargetTokens; this.contextBudgetTargetTokens = undefined; this.updateHotContextSettings({ preparedWindowTokens: null }); - this.runtimeSettingsSettledListener?.(); } /** @@ -612,9 +650,9 @@ export class Agent { // RFC-006 §3.3: consumption is determined at assembly — a compile that // fails assembled nothing, so the watermark moves only on success. const watermark = this.pendingWatermark(); - const result = await this.contextManager.compile(this.compileBudget(budget)); + const compile = () => this.contextManager.compile(this.compileBudget(budget)); + const result = budget ? await compile() : await this.compileAtLiveBudget(compile); if (watermark) this.consumedWatermark = watermark; - if (!budget) this.settleRuntimeSettingsTransition(); return result; } @@ -629,11 +667,9 @@ export class Agent { opts?: { kvUnifiedImmutablePrefixHash?: string }, ): Promise { const watermark = this.pendingWatermark(); - const result = await this.contextManager.compile( - this.compileBudget(budget), injections, opts as never, - ); + const compile = () => this.contextManager.compile(this.compileBudget(budget), injections, opts as never); + const result = budget ? await compile() : await this.compileAtLiveBudget(compile); if (watermark) this.consumedWatermark = watermark; - if (!budget) this.settleRuntimeSettingsTransition(); return result; } diff --git a/src/framework.ts b/src/framework.ts index dfe86085c..4b14fa7a5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3980,13 +3980,14 @@ export class AgentFramework { agentName: string, overrides: AgentRuntimeSettingsOverrides, ): AgentRuntimeSettingsOverrides { - if (overrides.contextBudgetReached !== undefined && overrides.contextBudgetReached !== true) { - // Unreadable, so not trusted: the budget restores paced, as before the marker existed. + const reached = overrides.contextBudgetReachedTokens; + if (reached !== undefined && !(Number.isSafeInteger(reached) && reached > 0)) { + // Unreadable, so not trusted: the budget restores paced, as without it. console.error( - `[agent-settings] ignoring an invalid persisted contextBudgetReached for agent "${agentName}": ` + - `${JSON.stringify(overrides.contextBudgetReached)}`, + `[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "${agentName}": ` + + `${String(JSON.stringify(reached)).slice(0, 80)}`, ); - const { contextBudgetReached: _ignored, ...rest } = overrides; + const { contextBudgetReachedTokens: _ignored, ...rest } = overrides; overrides = rest; } if ( @@ -4040,38 +4041,74 @@ export class AgentFramework { } /** - * What persists for an agent: its overrides, and `contextBudgetReached` - * when its live budget is at the overridden budget with no descent - * running (context-manager #162). + * Apply an agent's persisted settings at boot. A lower budget restores + * paced, as a descent from the recipe's budget, and a boot restore is never + * refused: a persisted budget that no longer fits can't brick startup. + * When the record says the last compile before the stop fit at its budget + * (`contextBudgetReachedTokens`), that budget is applied at once instead, + * but only if the preflight says it fits now: the restart may bring a new + * system prompt, tools or maxTokens, and the agent may have run at another + * budget since the record was written (context-manager #162). Otherwise it + * restores paced, with a line saying why. + */ + private restorePersistedRuntimeSettings( + agentName: string, + agent: Agent, + overrides: AgentRuntimeSettingsOverrides, + ): void { + const { contextBudgetReachedTokens: reached, ...settings } = overrides; + const budget = settings.contextBudgetTokens; + if (budget !== undefined && reached === budget) { + const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; + const preview = this.previewAgentRuntimeSettings(agentName, patch); + if (preview.available && preview.effective?.fits) { + agent.restoreRuntimeSettings(patch, { provenBudget: budget }); + return; + } + console.error( + `[agent-settings] ${agentName}: its last compile before the stop fit at contextBudgetTokens=${budget}, but ` + + (preview.available + ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` + : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`) + + '; restoring it as a paced descent', + ); + } + agent.restoreRuntimeSettings(settings); + } + + /** + * What persists for an agent: its overrides, and `contextBudgetReachedTokens` + * when the agent's last compile fit at the overridden budget + * (context-manager #162). An immediate change of budget carries none until + * a compile at it fits. */ private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides { const overrides = agent.getRuntimeSettingsOverrides(); - const snapshot = agent.getRuntimeSettings(); - const reached = overrides.contextBudgetTokens !== undefined - && snapshot.transition === 'stable' - && snapshot.effectiveContextBudgetTokens === overrides.contextBudgetTokens; - return reached ? { ...overrides, contextBudgetReached: true } : overrides; + const proven = agent.getProvenContextBudgetTokens(); + return overrides.contextBudgetTokens !== undefined && proven === overrides.contextBudgetTokens + ? { ...overrides, contextBudgetReachedTokens: proven } + : overrides; } /** - * A paced descent settled. When the persisted record holds the budget it - * settled at, mark that budget reached, so a restart applies it at once - * instead of pacing a descent again (context-manager #162). Only the - * marker is written: a descent applied with `persist: false` left no - * record of its target, so it stays unwritten, and other ephemeral changes - * aren't promoted. + * A compile at the agent's live budget came out differently from the last + * (Agent.onContextBudgetCompiled). Keep the persisted record's + * `contextBudgetReachedTokens` true to it: written when a compile fits at + * the record's own budget, withdrawn when the context manager refuses that + * budget. Nothing else in the record changes, and a budget other than the + * record's (one applied with `persist: false`, or a descent's starting + * budget) writes nothing. A failed write is logged; an unreadable record + * counts as none. Either way the restore then paces, as without the field. */ - private recordSettledRuntimeSettings(agentName: string, agent: Agent): void { + private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): void { try { const persisted = this.readAgentRuntimeSettings(agentName); - if (!persisted) return; - // The record's own budget, and no other: a descent applied without - // persisting may have settled somewhere else. - if (persisted.contextBudgetTokens !== agent.getRuntimeSettings().effectiveContextBudgetTokens) return; - this.persistAgentRuntimeSettings(agentName, { ...persisted, contextBudgetReached: true }); + if (!persisted || persisted.contextBudgetTokens !== budget) return; + if ((persisted.contextBudgetReachedTokens === budget) === fit) return; + const { contextBudgetReachedTokens: _previous, ...settings } = persisted; + this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings); } catch (error) { - // The settle itself stands; a restart then paces the descent again. - console.error(`[agent-settings] couldn't record that ${agentName}'s budget settled:`, error); + console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error); } } @@ -6505,15 +6542,19 @@ export class AgentFramework { this.restoreToolResultGuardSetting(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable this.sharedSlotAgents.add(agent); + // Registered before its settings are restored: the restore's preflight + // previews the agent by name. + this.agents.set(config.name, agent); + this.agentConfigs.set(config.name, config); const restoredSettings = this.readAgentRuntimeSettings(config.name); if (restoredSettings) { - agent.restoreRuntimeSettings( + this.restorePersistedRuntimeSettings( + config.name, + agent, this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings), ); } - agent.onRuntimeSettingsSettled(() => this.recordSettledRuntimeSettings(config.name, agent)); - this.agents.set(config.name, agent); - this.agentConfigs.set(config.name, config); + agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit)); // First non-ephemeral agent becomes the primary for message routing if (!this.primaryAgentName) { diff --git a/src/types/agent.ts b/src/types/agent.ts index 1a4930115..f85b94f6f 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -226,9 +226,10 @@ export interface AgentRuntimeSettingsSnapshot { /** The budget the agent is set to: while a lower one converges, its target. */ contextBudgetTokens: number; /** - * The budget compiles plan at now. It equals `contextBudgetTokens` except - * while a lower budget converges, when it is still the budget the descent - * started from (context-manager #162). + * The budget compiles plan at now: the live budget, or the default when the + * recipe sets none (the context manager's own default is the same). It + * equals `contextBudgetTokens` except while a lower budget converges, when + * it is still the budget the descent started from (context-manager #162). */ effectiveContextBudgetTokens: number; tailTokens?: number; @@ -242,16 +243,18 @@ export interface AgentRuntimeSettingsSnapshot { /** Persisted values differ from recipe defaults; omitted keys inherit config. */ export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { /** - * Written beside `contextBudgetTokens` once the agent's live budget has - * reached it: by an immediate apply, or when a paced descent settles. A - * restore then applies that budget at once, rather than pacing a descent - * again from the recipe's budget (context-manager #162). The store fit - * that budget when it was reached, so the planner can fold to it at once - * even if its carried frontier was lost. Without it (a descent still - * running at the stop, or a record from an earlier release) the restore - * paces a descent, as before. Never part of a patch. + * The budget the agent's last compile planned at, written while that + * compile fit: a compile that succeeds at the agent's live budget proves + * the store fits it, and one the context manager refuses as over budget + * withdraws it. When it equals `contextBudgetTokens`, a restore applies + * that budget at once rather than pacing a descent again from the + * recipe's budget (context-manager #162). Since the store fit it at the + * last compile, the planner can fold to it at once even if its carried + * frontier was lost. Any other value is stale (the budget changed since, + * or an earlier release carried the field forward) and the restore paces, + * as it does without the field. Never part of a patch. */ - contextBudgetReached?: true; + contextBudgetReachedTokens?: number; }; /** diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index c87ed80ef..a9f71f318 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; -import { AutobiographicalStrategy } from '@animalabs/context-manager'; +import { AutobiographicalStrategy, ContextManager } from '@animalabs/context-manager'; import { AgentFramework, BudgetPreflightError } from '../src/index.js'; const membrane = {} as any; @@ -142,14 +142,19 @@ it('runtime overrides persist across framework restart', async () => { // context-manager #162: a lower budget the agent had reached comes back reached. describe('a reached budget across a restart', () => { + type Record = { [key: string]: unknown }; const persisted = (framework: AgentFramework) => - (framework.getStore().getStateJson('framework/state') as { agentRuntimeSettings?: Record } | null) + (framework.getStore().getStateJson('framework/state') as { agentRuntimeSettings?: { [name: string]: Record } } | null) ?.agentRuntimeSettings?.agent; + const writeRecord = (framework: AgentFramework, record: Record) => { + const store = framework.getStore(); + const state = (store.getStateJson('framework/state') ?? {}) as { agentRuntimeSettings?: { [name: string]: Record } }; + store.setStateJson('framework/state', { ...state, agentRuntimeSettings: { ...state.agentRuntimeSettings, agent: record } }); + }; const setup = () => { const dir = mkdtempSync(join(tmpdir(), 'agent-settings-reached-')); - const storePath = join(dir, 'store'); const config = () => ({ - storePath, + storePath: join(dir, 'store'), membrane, agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', @@ -157,120 +162,219 @@ describe('a reached budget across a restart', () => { }], modules: [], }); - return { dir, storePath, config }; + return { dir, config }; }; const budgets = (framework: AgentFramework) => { const s = framework.getAgentRuntimeSettings('agent'); return { target: s.contextBudgetTokens, effective: s.effectiveContextBudgetTokens, transition: s.transition }; }; - - it('a descent that settled comes back settled, before any compile', async () => { + const compile = (framework: AgentFramework) => framework.getAgent('agent')!.compileContext(); + /** Run `fn` with ContextManager.prototype[method] replaced. */ + async function withPatched(method: string, impl: unknown, fn: () => Promise): Promise { + const proto = ContextManager.prototype as unknown as { [key: string]: unknown }; + const original = proto[method]; + proto[method] = impl; + try { return await fn(); } finally { proto[method] = original; } + } + const quietErrors = () => { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + return { lines, restore: () => { console.error = original; } }; + }; + /** Each test's framework, stopped and removed afterwards. */ + async function run(fn: (h: { config: ReturnType['config']; framework: () => AgentFramework; restart: () => Promise }) => Promise) { const { dir, config } = setup(); let framework = await AgentFramework.create(config()); try { - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 100_000, transition: 'converging' }); - assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000 }, 'not reached yet'); - // The store is empty, so the first compile's frontier fits and the descent settles. - await framework.getAgent('agent')!.compileContext(); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); - assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000, contextBudgetReached: true }, 'the settle is recorded'); - await framework.stop(); - framework = await AgentFramework.create(config()); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); + await fn({ + config, + framework: () => framework, + restart: async () => { await framework.stop(); framework = await AgentFramework.create(config()); return framework; }, + }); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); } + } + + it('a descent that settled comes back settled, and the first compile plans at it', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.deepEqual(budgets(framework()), { target: 60_000, effective: 100_000, transition: 'converging' }); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000 }, 'not reached yet'); + // The store is empty, so the first compile's frontier fits and the descent settles. + await compile(framework()); + assert.deepEqual(budgets(framework()), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the settle is recorded'); + const restarted = await restart(); + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 60_000, transition: 'stable' }); + // The record's proof stands until a compile here says otherwise, so a + // persisting call before any compile keeps it. + restarted.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + assert.equal(persisted(restarted)?.contextBudgetReachedTokens, 60_000); + const planned: number[] = []; + const cm = restarted.getAgent('agent')!.getContextManager() as unknown as { compile: (budget?: { maxTokens: number }) => Promise }; + const original = cm.compile.bind(cm); + cm.compile = (budget, ...rest) => { planned.push(budget?.maxTokens ?? -1); return (original as (...a: unknown[]) => Promise)(budget, ...rest); }; + await compile(restarted); + assert.deepEqual(planned, [60_000], 'the first compile after the restart plans at the reached budget'); + }); }); - it('an immediate decrease is recorded as reached, and comes back so', async () => { - const { dir, config } = setup(); - let framework = await AgentFramework.create(config()); - try { - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); - assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000, contextBudgetReached: true }); - await framework.stop(); - framework = await AgentFramework.create(config()); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); - } finally { - await framework.stop(); - rmSync(dir, { recursive: true, force: true }); - } + it('a restored descent that settles after the restart is recorded then', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + const restarted = await restart(); + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 100_000, transition: 'converging' }); + await compile(restarted); + assert.deepEqual(persisted(restarted), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + }); }); - it('a record without the marker (an earlier release) restores paced, as before', async () => { - const { dir, config } = setup(); - let framework = await AgentFramework.create(config()); - try { - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); - const store = framework.getStore(); - const state = store.getStateJson('framework/state') as { agentRuntimeSettings: Record> }; - delete state.agentRuntimeSettings.agent.contextBudgetReached; - store.setStateJson('framework/state', state); - await framework.stop(); - framework = await AgentFramework.create(config()); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 100_000, transition: 'converging' }); - } finally { - await framework.stop(); - rmSync(dir, { recursive: true, force: true }); - } + it('an immediate decrease is recorded once a compile fits at it, not before', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000 }, 'nothing has compiled at it yet'); + await compile(framework()); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + assert.deepEqual(budgets(await restart()), { target: 60_000, effective: 60_000, transition: 'stable' }); + }); }); - it('an invalid marker is ignored, and the budget restores paced', async () => { - const { dir, config } = setup(); - let framework = await AgentFramework.create(config()); - const errors: string[] = []; - const original = console.error; - try { - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); - const store = framework.getStore(); - const state = store.getStateJson('framework/state') as { agentRuntimeSettings: Record> }; - state.agentRuntimeSettings.agent.contextBudgetReached = 'yes'; - store.setStateJson('framework/state', state); - await framework.stop(); - console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }; - framework = await AgentFramework.create(config()); - console.error = original; - assert.deepEqual(budgets(framework), { target: 60_000, effective: 100_000, transition: 'converging' }); - assert.ok(errors.some((line) => line.includes('ignoring an invalid persisted contextBudgetReached')), errors.join('\n')); - } finally { - console.error = original; - await framework.stop(); - rmSync(dir, { recursive: true, force: true }); - } + it('an immediate decrease that never compiled comes back paced', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + assert.deepEqual(budgets(await restart()), { target: 60_000, effective: 100_000, transition: 'converging' }); + }); + }); + + it('a compile refused at the reached budget withdraws the record, and the restart paces', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(framework()); + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + await withPatched('compile', async () => { throw refusal; }, async () => { + await assert.rejects(() => compile(framework()), /over budget/); + }); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000 }); + assert.deepEqual(budgets(await restart()), { target: 60_000, effective: 100_000, transition: 'converging' }); + }); + }); + + it('a reached budget the preflight says no longer fits restores paced, with a line saying why', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(framework()); + const log = quietErrors(); + try { + // A new recipe (a longer system prompt, say) raised the folded floor. + const restarted = await withPatched('previewContext', (budget: { maxTokens: number }) => ({ + finalTokens: 70_000, budgetTokens: budget.maxTokens, fits: 70_000 <= budget.maxTokens, + }), restart); + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 100_000, transition: 'converging' }); + } finally { + log.restore(); + } + assert.ok(log.lines.some((line) => line.includes('folded floor renders 70000 tokens against it now')), log.lines.join('\n')); + }); + }); + + it('a record without the field, or with one for another budget, restores paced', async () => { + await run(async ({ framework, restart }) => { + // An earlier release's record; then one an older release carried + // forward beside a budget it changed. + writeRecord(framework(), { contextBudgetTokens: 60_000 }); + assert.equal(budgets(await restart()).transition, 'converging'); + writeRecord(framework(), { contextBudgetTokens: 70_000, contextBudgetReachedTokens: 60_000 }); + assert.deepEqual(budgets(await restart()), { target: 70_000, effective: 100_000, transition: 'converging' }); + }); + }); + + it('an invalid field is ignored with a bounded line, and a record holding only it still boots', async () => { + await run(async ({ framework, restart }) => { + writeRecord(framework(), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 'yes'.repeat(100) }); + const log = quietErrors(); + try { + assert.equal(budgets(await restart()).transition, 'converging'); + writeRecord(framework(), { contextBudgetReachedTokens: -5 }); + assert.deepEqual(budgets(await restart()), { target: 100_000, effective: 100_000, transition: 'stable' }, 'the recipe budget'); + } finally { + log.restore(); + } + const line = log.lines.find((l) => l.includes('ignoring an invalid persisted contextBudgetReachedTokens')); + assert.ok(line && line.length < 200, log.lines.join('\n')); + }); + }); + + it('a patch cannot set the field', async () => { + await run(async ({ framework }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 } as never); + assert.deepEqual(framework().getAgent('agent')!.getRuntimeSettingsOverrides(), { contextBudgetTokens: 60_000 }); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000 }); + }); + }); + + it('every persisting call keeps the field true to the live state', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + f.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, tailTokens: 20_000, contextBudgetReachedTokens: 60_000 }, 'another setting keeps it'); + f.cancelAgentRuntimeSettingsTransition('agent'); + assert.equal(persisted(f)?.contextBudgetReachedTokens, 60_000, 'a cancel with no descent keeps it'); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 50_000 }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 50_000, tailTokens: 20_000 }, 'a new descent has reached nothing'); + f.cancelAgentRuntimeSettingsTransition('agent'); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, tailTokens: 20_000, contextBudgetReachedTokens: 60_000 }, 'cancelled back to the reached budget'); + f.resetAgentRuntimeSettings('agent', ['contextBudgetTokens']); + assert.deepEqual(persisted(f), { tailTokens: 20_000 }, 'reset to the recipe'); + }); }); it('a settle marks only the persisted budget it reached', async () => { - const { dir, config } = setup(); - const framework = await AgentFramework.create(config()); - try { - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); - await framework.getAgent('agent')!.compileContext(); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); - assert.deepEqual(persisted(framework), { contextBudgetTokens: 80_000 }, 'the record\'s 80k was never the live budget'); - } finally { - await framework.stop(); - rmSync(dir, { recursive: true, force: true }); - } + await run(async ({ framework }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); + await compile(framework()); + assert.deepEqual(budgets(framework()), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 80_000 }, "the record's 80k was never compiled at"); + }); }); it('a descent applied without persisting settles without writing anything', async () => { - const { dir, config } = setup(); - let framework = await AgentFramework.create(config()); - try { - framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); - await framework.getAgent('agent')!.compileContext(); - assert.deepEqual(budgets(framework), { target: 60_000, effective: 60_000, transition: 'stable' }); - assert.equal(persisted(framework), undefined, 'nothing written for an ephemeral descent'); - await framework.stop(); - framework = await AgentFramework.create(config()); - assert.deepEqual(budgets(framework), { target: 100_000, effective: 100_000, transition: 'stable' }, 'the recipe budget'); - } finally { - await framework.stop(); - rmSync(dir, { recursive: true, force: true }); - } + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); + const log = quietErrors(); + try { + await compile(framework()); + } finally { + log.restore(); + } + assert.deepEqual(log.lines.filter((l) => l.includes('[agent-settings]')), [], 'no record is no error'); + assert.deepEqual(budgets(framework()), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.equal(persisted(framework()), undefined, 'nothing written for an ephemeral descent'); + assert.deepEqual(budgets(await restart()), { target: 100_000, effective: 100_000, transition: 'stable' }, 'the recipe budget'); + }); + }); + + it('a failed write of the field is logged, and the compile stands', async () => { + await run(async ({ framework }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + const store = framework().getStore() as unknown as { setStateJson: (...args: unknown[]) => void }; + const original = store.setStateJson.bind(store); + store.setStateJson = () => { throw new Error('disk full'); }; + const log = quietErrors(); + try { + await compile(framework()); + } finally { + store.setStateJson = original; + log.restore(); + } + assert.ok(log.lines.some((l) => l.includes("couldn't record agent's compile at budget 60000")), log.lines.join('\n')); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000 }); + }); }); }); From eec732686450ba82e124698de468a7844a1420a2 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 12:45:26 -0400 Subject: [PATCH 03/16] fix(settings): boot's preflight previews the agent in hand, so it registers last again Oscar-1005's read of 5243c5d (room-555 #71491): createAgent registered the agent before restoring its settings, so the preflight could find it by name. If the restore then threw (its paced path throws "cannot prepare a smaller window live" when a persisted lower budget meets a strategy without hot settings, say after a recipe changed strategy), the agent stayed in both maps although its creation failed. previewAgentRuntimeSettings and previewContextSettings now look the agent up and delegate to previewRuntimeSettingsFor(agent, patch) and previewContextFor(agent, ...), and the boot restore previews the agent in hand. createAgent restores first and registers the agent last, as before 5243c5d. Unchanged behaviour: agent-runtime-settings 27/27, overbudget-drain-breaker 10/10, tool-result-guard 39/39; the realistic probe still comes back stable at 120k before any compile; every mutation in mutate2.py still fails a test (~/.local/state/ada-1017/tmp/chk/162/mutants-5.txt). Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 4b14fa7a5..b909ef97c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3748,6 +3748,15 @@ export class AgentFramework { ): unknown { const agent = this.agents.get(agentName); if (!agent) throw new Error(`Unknown agent: ${agentName}`); + return this.previewContextFor(agent, budgetTokens, overrides, opts); + } + + private previewContextFor( + agent: Agent, + budgetTokens: number, + overrides?: Record, + opts?: { render?: boolean }, + ): unknown { const cm = agent.getContextManager() as unknown as { previewContext?: ( budget: TokenBudget, @@ -3791,6 +3800,14 @@ export class AgentFramework { ): RuntimeSettingsPreview { const agent = this.agents.get(agentName); if (!agent) throw new Error(`Unknown agent: ${agentName}`); + return this.previewRuntimeSettingsFor(agent, patch); + } + + /** previewAgentRuntimeSettings for an agent in hand: boot restore previews one not yet registered. */ + private previewRuntimeSettingsFor( + agent: Agent, + patch?: AgentRuntimeSettingsPatch, + ): RuntimeSettingsPreview { const plan = agent.planRuntimeSettings(patch); const settings = agent.getRuntimeSettings(); const base: RuntimeSettingsPreview = { @@ -3806,8 +3823,8 @@ export class AgentFramework { reason?: string; } => { try { - const result = this.previewContextSettings( - agentName, + const result = this.previewContextFor( + agent, budgetTokens, Object.keys(plan.overrides).length > 0 ? plan.overrides : undefined, ); @@ -4060,7 +4077,7 @@ export class AgentFramework { const budget = settings.contextBudgetTokens; if (budget !== undefined && reached === budget) { const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; - const preview = this.previewAgentRuntimeSettings(agentName, patch); + const preview = this.previewRuntimeSettingsFor(agent, patch); if (preview.available && preview.effective?.fits) { agent.restoreRuntimeSettings(patch, { provenBudget: budget }); return; @@ -6542,10 +6559,6 @@ export class AgentFramework { this.restoreToolResultGuardSetting(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable this.sharedSlotAgents.add(agent); - // Registered before its settings are restored: the restore's preflight - // previews the agent by name. - this.agents.set(config.name, agent); - this.agentConfigs.set(config.name, config); const restoredSettings = this.readAgentRuntimeSettings(config.name); if (restoredSettings) { this.restorePersistedRuntimeSettings( @@ -6555,6 +6568,8 @@ export class AgentFramework { ); } agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit)); + this.agents.set(config.name, agent); + this.agentConfigs.set(config.name, config); // First non-ephemeral agent becomes the primary for message routing if (!this.primaryAgentName) { From 6bcd743c3d7d3657729c3962d15e3037b434cbb0 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 13:27:18 -0400 Subject: [PATCH 04/16] fix(settings): a compile proves only the budget it planned at, a failed record is told again, and the preflight's reach is stated Greptile's review of #290 at eec7326, three P2s, each real: - Wrong budget marked reached. compileAtLiveBudget noted this.contextBudgetTokens as read after the compile, so a budget changed while the compile ran (an immediate decrease from 100k to 60k) was noted as fit by a compile that planned at 100k, and the record took 60k as reached. It now captures the live budget and the descent's target before the compile, and notes the target only when this compile settled that same descent; otherwise the budget it planned at. A refusal notes the captured budget, as before. - Failed saves never retried. noteCompiledBudget skipped the listener when the outcome matched the last one in memory, so a record whose write failed was never told again. The Agent now keeps the last outcome the listener recorded apart from the last compile's (which the in-memory proof still reads), and the listener (recordCompiledContextBudget) returns false when its write fails, so the next compile tells it again. It already skips records that need no change. - The prompt check overstated. The boot preflight plans the stored context as the context manager will; the system prompt and tools are outside the context manager's budget. The restore's doc, the test's comment and the changelog now say what it sees: a new maxTokens (the response reserve) or strategy configuration a restart brings, and a context that grew while the agent ran at another budget. Tests (agent-runtime-settings, 28): a budget changed to 60k at once while a 100k compile runs is not proven by it, and the next compile proves it; the failed-write test now compiles again once writes work and finds the field. Mutations (~/.local/state/ada-1017/tmp/chk/162/mutants-6.txt), 17, each failing a test, including the three new ones: noting the budget read after the compile, never crediting a settle to its target, and a failed record never told again. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 2 +- src/agent.ts | 24 ++++++++++++++----- src/framework.ts | 19 ++++++++++----- test/agent-runtime-settings.test.ts | 21 +++++++++++++++- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index eac5dfd51..9518f8e37 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,2 +1,2 @@ -- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. The persisted runtime settings now carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget. An immediate change carries none until a compile fits at it. At boot, a record whose two values match is applied through the immediate path, but only if the preflight says the budget fits now, since a restart can bring a new system prompt, tools or `maxTokens`. Otherwise, and for a record without the field (a descent still running at the stop, or a record from an earlier release) or with a value for another budget, it restores paced, as before, with a log line when a reached budget no longer fits. A boot restore still never refuses a persisted budget. A patch can't set the field, an invalid value is ignored with a log line, and a record holding nothing else restores nothing. +- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. The persisted runtime settings now carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget. An immediate change carries none until a compile fits at it. At boot, a record whose two values match is applied through the immediate path, but only if the preflight says the budget fits now. The preflight plans the stored context as the context manager will, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. Otherwise, and for a record without the field (a descent still running at the stop, or a record from an earlier release) or with a value for another budget, it restores paced, as before, with a log line when a reached budget no longer fits. A boot restore still never refuses a persisted budget. A patch can't set the field, an invalid value is ignored with a log line, and a record holding nothing else restores nothing. - The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now. While a lower budget converges, it is still the budget the descent started from; `contextBudgetTokens` stays the budget the agent is set to. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. diff --git a/src/agent.ts b/src/agent.ts index b0228fd23..08dd51d8f 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -145,8 +145,10 @@ export class Agent { private runtimeSettingsOverrides: AgentRuntimeSettingsPatch = {}; /** The live budget the last compile planned at, and whether it fit. */ private lastCompiledBudget?: { budget: number; fit: boolean }; + /** The last outcome the listener recorded; a failed record is told again. */ + private recordedCompiledBudget?: { budget: number; fit: boolean }; /** Told when that changes (the framework records it beside the persisted budget). */ - private compiledBudgetListener?: (budget: number, fit: boolean) => void; + private compiledBudgetListener?: (budget: number, fit: boolean) => boolean; private contextManager: ContextManager; private membrane: Membrane; @@ -319,7 +321,7 @@ export class Agent { * didn't plan at (after a settle, the target), or the context manager * refused a budget the last one fit at. */ - onContextBudgetCompiled(listener: ((budget: number, fit: boolean) => void) | undefined): void { + onContextBudgetCompiled(listener: ((budget: number, fit: boolean) => boolean) | undefined): void { this.compiledBudgetListener = listener; } @@ -329,14 +331,20 @@ export class Agent { } private noteCompiledBudget(budget: number, fit: boolean): void { - if (this.lastCompiledBudget?.budget === budget && this.lastCompiledBudget.fit === fit) return; this.lastCompiledBudget = { budget, fit }; - this.compiledBudgetListener?.(budget, fit); + const recorded = this.recordedCompiledBudget; + if (recorded?.budget === budget && recorded.fit === fit) return; + // A listener that couldn't record it returns false, and hears it again + // at the next compile. + if (this.compiledBudgetListener?.(budget, fit) !== false) this.recordedCompiledBudget = { budget, fit }; } /** Compile at the live budget (no explicit one), noting whether it fit. */ private async compileAtLiveBudget(compile: () => Promise): Promise { + // The budget this compile plans at, and the descent under way when it + // started: a change made while it runs is no part of what it shows. const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; + const target = this.contextBudgetTargetTokens; let result: CompileResult; try { result = await compile(); @@ -345,9 +353,13 @@ export class Agent { if (error instanceof OverBudgetError || err?.name === 'OverBudgetError') this.noteCompiledBudget(live, false); throw error; } - // A settle proves the target: the frontier fit the window prepared for it. this.settleRuntimeSettingsTransition(); - this.noteCompiledBudget(this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS, true); + // A settle of the descent this compile began under proves its target: + // the frontier fits the window prepared for it. Otherwise the compile + // proves the budget it planned at. + const settled = target !== undefined && this.contextBudgetTargetTokens === undefined + && this.contextBudgetTokens === target; + this.noteCompiledBudget(settled ? target : live, true); return result; } diff --git a/src/framework.ts b/src/framework.ts index b909ef97c..25500434e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4063,9 +4063,13 @@ export class AgentFramework { * refused: a persisted budget that no longer fits can't brick startup. * When the record says the last compile before the stop fit at its budget * (`contextBudgetReachedTokens`), that budget is applied at once instead, - * but only if the preflight says it fits now: the restart may bring a new - * system prompt, tools or maxTokens, and the agent may have run at another - * budget since the record was written (context-manager #162). Otherwise it + * but only if the preflight says it fits now. The preflight plans the + * stored context as the context manager will, so it sees what a restart can + * change there: a new maxTokens (the response reserve) or strategy + * configuration. It also sees a context that grew while the agent ran at + * another budget after the record was written, through a persist: false + * change. The system prompt and tools are outside the context manager's + * budget, so they don't enter it (context-manager #162). Otherwise it * restores paced, with a line saying why. */ private restorePersistedRuntimeSettings( @@ -4117,15 +4121,18 @@ export class AgentFramework { * budget) writes nothing. A failed write is logged; an unreadable record * counts as none. Either way the restore then paces, as without the field. */ - private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): void { + private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean { try { const persisted = this.readAgentRuntimeSettings(agentName); - if (!persisted || persisted.contextBudgetTokens !== budget) return; - if ((persisted.contextBudgetReachedTokens === budget) === fit) return; + if (!persisted || persisted.contextBudgetTokens !== budget) return true; + if ((persisted.contextBudgetReachedTokens === budget) === fit) return true; const { contextBudgetReachedTokens: _previous, ...settings } = persisted; this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings); + return true; } catch (error) { + // The agent tells it again at its next compile. console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error); + return false; } } diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index a9f71f318..f391ecee8 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -268,7 +268,7 @@ describe('a reached budget across a restart', () => { await compile(framework()); const log = quietErrors(); try { - // A new recipe (a longer system prompt, say) raised the folded floor. + // A restart with a new maxTokens or strategy configuration raised the folded floor. const restarted = await withPatched('previewContext', (budget: { maxTokens: number }) => ({ finalTokens: 70_000, budgetTokens: budget.maxTokens, fits: 70_000 <= budget.maxTokens, }), restart); @@ -374,6 +374,25 @@ describe('a reached budget across a restart', () => { } assert.ok(log.lines.some((l) => l.includes("couldn't record agent's compile at budget 60000")), log.lines.join('\n')); assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000 }); + // The next compile at that budget records it, once writes work again. + await compile(framework()); + assert.deepEqual(persisted(framework()), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + }); + }); + + it('a budget changed while a compile runs is not proven by that compile', async () => { + await run(async ({ framework }) => { + const f = framework(); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + // The compile was asked for at the recipe's 100k; the budget drops to + // 60k at once while it runs. + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + return original.apply(this, args); + }, () => compile(f)); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'the 100k compile proves nothing about 60k'); + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); }); }); }); From f392d97c8575f1f22f4e08ecb59e39a3bb8330b5 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 13:39:26 -0400 Subject: [PATCH 05/16] fix(settings): credit a target only to the compile whose settle made it live Oscar-1005's review of 6bcd743 (room-555 #71727): compileAtLiveBudget inferred a settle from the state after the compile (a target captured, none now, and the live budget equal to it). An immediate change to exactly the descent's target, landing while a compile ran at the old budget, ends the descent with no settle, yet matched that inference, so the compile that planned at 100k credited the 60k target, one compile early. settleRuntimeSettingsTransition now returns whether it settled, and the compile credits the target only when its own call settled the descent it began under; otherwise the budget it planned at, as the commit message of 6bcd743 already said. Test (agent-runtime-settings, 29): a paced descent to 60k, then the same 60k applied at once while a 100k compile runs: the agent is stable at 60k and the record carries no reached budget. Mutations (mutants-7.txt): 18, each failing a test, including inferring the settle from the state. Co-Authored-By: Claude Opus 5.5 --- src/agent.ts | 20 ++++++++++---------- test/agent-runtime-settings.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index 08dd51d8f..deddf5f5c 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -353,13 +353,11 @@ export class Agent { if (error instanceof OverBudgetError || err?.name === 'OverBudgetError') this.noteCompiledBudget(live, false); throw error; } - this.settleRuntimeSettingsTransition(); - // A settle of the descent this compile began under proves its target: - // the frontier fits the window prepared for it. Otherwise the compile - // proves the budget it planned at. - const settled = target !== undefined && this.contextBudgetTargetTokens === undefined - && this.contextBudgetTokens === target; - this.noteCompiledBudget(settled ? target : live, true); + // A settle, here, of the descent this compile began under proves its + // target: the frontier fits the window prepared for it. Otherwise the + // compile proves the budget it planned at, whatever changed meanwhile. + const settled = this.settleRuntimeSettingsTransition() && target !== undefined && this.contextBudgetTokens === target; + this.noteCompiledBudget(settled ? target! : live, true); return result; } @@ -611,13 +609,15 @@ export class Agent { } } - private settleRuntimeSettingsTransition(): void { - if (this.contextBudgetTargetTokens === undefined) return; + /** Settle a converging budget once the window is prepared; true if it settled. */ + private settleRuntimeSettingsTransition(): boolean { + if (this.contextBudgetTargetTokens === undefined) return false; const hot = this.getHotContextSettings(); - if (!hot?.prepared) return; + if (!hot?.prepared) return false; this.contextBudgetTokens = this.contextBudgetTargetTokens; this.contextBudgetTargetTokens = undefined; this.updateHotContextSettings({ preparedWindowTokens: null }); + return true; } /** diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index f391ecee8..5bc5da25e 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -395,6 +395,23 @@ describe('a reached budget across a restart', () => { assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); }); }); + + it('a descent ended by an immediate change to its own target is not credited to the compile that ran meanwhile', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.equal(budgets(f).transition, 'converging'); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + // The compile plans at the live 100k; the same 60k is applied at once + // while it runs, ending the descent without a settle. + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + return original.apply(this, args); + }, () => compile(f)); + assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'nothing settled, and the compile planned at 100k'); + }); + }); }); it('reset-all skips tail controls unsupported by the active strategy', async () => { From 05e074156bf96e9df5a323ba04f722ed9540987d Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 10:29:06 -0400 Subject: [PATCH 06/16] fix(settings): the reached-budget proof stands only for the live budget a whole compile saw; a boot restore never throws, and falls back when refused From the review of the whole change: The proof. Each compile noted its outcome in completion order, at the budget it captured when it started, into one slot that no budget change cleared. So a compile that planned before another compile's settle and finished after it replaced the settled target's proof with the old budget (and the next persisting write dropped the marker); a change back to a proven budget, at once or paced after an increase, carried the old proof into the record; and a descent re-armed to the same number was credited by value to a compile that planned under another. The agent now counts changes of the live budget and of the descent target separately. A compile notes its outcome only if the live budget didn't change while it ran; a settle proves its target only to a compile that began under that same descent; and the proof carries the live budget's epoch, standing only until the next change of it (arming or cancelling a descent leaves the live budget, and its proof, as they were). The dedupe of what the listener was told carries that epoch too, and a listener call that fails leaves nothing counted as told, so a withdrawal that failed before a persisting write no longer leaves the marker unwritten at the next fit. The settle expression's dead `target !== undefined` clause and the instanceof check (redundant: the context manager's OverBudgetError names itself, which a second copy of the package matches too) are gone, and the import with them. The boot. A preflight or a restore that threw (a persisted budget at or below a raised maxTokens, or a strategy without hot context settings) threw out of createAgent; restorePersistedRuntimeSettings now counts a throwing preview as unchecked and restores without the proof, and a restore that throws is logged and the agent boots at the recipe's settings. A budget the boot applied at once whose first compile the context manager refuses had no way back; the agent now restores it paced from the recipe's budget and compiles again (the refusal still withdraws the marker). The boot preview of an immediate change planned with the persisted transition pace as its reach, though its compile plans at the configured reach once the immediate apply clears the prepared window; planRuntimeSettings now adds the pace as the reach only for a paced or settings-only patch. A record read for the listener reads strictly, so a store that can't be read is logged and told again. The invalid-field log line names the value's kind, not its content, and the restore's line no longer calls an increase paced. Docs: the marker's doc and persistedRuntimeSettingsOf's say when a proof stands and when a restore applies it; the changelog fragment too. Tests (agent-runtime-settings, 38): two compiles in flight across a settle, completing out of order; an immediate change back to a proven budget, and an increase then a paced decrease back to it; a descent re-armed during a compile, with and without a change of the live budget; compileWithInjections proving and recording; an unreadable record told again; a failed withdrawal, a persisting write, then a fit; a boot budget refused at its first compile; a maxTokens raised above the persisted budget, with and without the marker; an immediate patch's preview reach. The invalid-field test checks the exact lines, and the never-preflighted test says what it checks now. Each fix, undone on its own, fails its test (8 of 8). --- .../budget-reached-across-restart.fixed.md | 2 +- src/agent.ts | 134 ++++++++---- src/framework.ts | 64 ++++-- src/types/agent.ts | 20 +- test/agent-runtime-settings.test.ts | 200 +++++++++++++++++- 5 files changed, 348 insertions(+), 72 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 9518f8e37..800275ce5 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,2 +1,2 @@ -- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. The persisted runtime settings now carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget. An immediate change carries none until a compile fits at it. At boot, a record whose two values match is applied through the immediate path, but only if the preflight says the budget fits now. The preflight plans the stored context as the context manager will, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. Otherwise, and for a record without the field (a descent still running at the stop, or a record from an earlier release) or with a value for another budget, it restores paced, as before, with a log line when a reached budget no longer fits. A boot restore still never refuses a persisted budget. A patch can't set the field, an invalid value is ignored with a log line, and a record holding nothing else restores nothing. +- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. The persisted runtime settings now carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget. A compile counts only if the live budget didn't change while it ran, and a settle only for a compile that began under that descent, so of two compiles in flight, one that planned before a change proves nothing. What a compile showed stands only until the live budget changes: after any change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. At boot, a record whose two values match is applied through the immediate path, but only if the preflight says the budget fits now. The preflight plans the stored context as the context manager will, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. Otherwise, and for a record without the field (a descent still running at the stop, or a record from an earlier release) or with a value for another budget, it restores as before, paced when it's below the recipe's budget, with a log line when a reached budget no longer fits. If the first compile at a budget applied at once is refused, the agent restores it paced then and compiles again. A boot restore never refuses a persisted budget: settings that can't be applied at all (a budget no longer above `maxTokens`, or a strategy that can't prepare a smaller window) are logged, and the agent boots at the recipe's. A preview of an immediate change now plans at the configured reach, as its compile will, not at the transition pace. A patch can't set the field, an invalid value is ignored with a log line naming its kind, and a record holding nothing else restores nothing. A record that can't be read, or written, when a compile tells it is logged and told again at the next compile. - The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now. While a lower budget converges, it is still the budget the descent started from; `contextBudgetTokens` stays the budget the agent is set to. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. diff --git a/src/agent.ts b/src/agent.ts index deddf5f5c..ced51e630 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -37,7 +37,6 @@ import type { HotContextSettingsUpdate, MessageMetadata, } from '@animalabs/context-manager'; -import { OverBudgetError } from '@animalabs/context-manager'; import type { AgentConfig, AgentState, @@ -143,10 +142,29 @@ export class Agent { private readonly configuredSameRoundThinkTextPolicy?: SameRoundThinkTextPolicy; private contextBudgetTargetTokens?: number; private runtimeSettingsOverrides: AgentRuntimeSettingsPatch = {}; - /** The live budget the last compile planned at, and whether it fit. */ - private lastCompiledBudget?: { budget: number; fit: boolean }; - /** The last outcome the listener recorded; a failed record is told again. */ - private recordedCompiledBudget?: { budget: number; fit: boolean }; + /** + * Counts changes of the live budget (an update, a reset, a restore, a + * settle). A compile speaks for the budget it planned at only if the live + * budget didn't change while it ran, and what it showed stands only until + * the next change. + */ + private liveBudgetEpoch = 0; + /** + * Counts changes of the descent target (a paced update, a reset, a cancel, + * a settle). A settle proves its target only to a compile that began under + * that same descent. + */ + private descentEpoch = 0; + /** The live budget the last compile planned at, whether it fit, and the live budget's epoch then. */ + private lastCompiledBudget?: { budget: number; fit: boolean; epoch: number }; + /** The last outcome the listener recorded; a failed record is told again, and so is any after a change. */ + private recordedCompiledBudget?: { budget: number; fit: boolean; epoch: number }; + /** + * A boot restore that applied a proven budget at once: the settings to + * restore paced instead, if the first compile at that budget is refused, + * and the live budget's epoch it was applied at. + */ + private restoredAtOnce?: { settings: AgentRuntimeSettingsPatch; epoch: number }; /** Told when that changes (the framework records it beside the persisted budget). */ private compiledBudgetListener?: (budget: number, fit: boolean) => boolean; private contextManager: ContextManager; @@ -325,39 +343,76 @@ export class Agent { this.compiledBudgetListener = listener; } - /** The live budget the last compile planned at and fit, if it fit. */ + /** + * The live budget the last compile planned at and fit, if it fit and the + * live budget hasn't changed since. + */ getProvenContextBudgetTokens(): number | undefined { - return this.lastCompiledBudget?.fit ? this.lastCompiledBudget.budget : undefined; + const last = this.lastCompiledBudget; + return last?.fit && last.epoch === this.liveBudgetEpoch ? last.budget : undefined; + } + + /** Set the live budget and the descent target, counting a change of each. */ + private setContextBudget(budget: number | undefined, target: number | undefined): void { + if (budget !== this.contextBudgetTokens) this.liveBudgetEpoch++; + if (target !== this.contextBudgetTargetTokens) this.descentEpoch++; + this.contextBudgetTokens = budget; + this.contextBudgetTargetTokens = target; } private noteCompiledBudget(budget: number, fit: boolean): void { - this.lastCompiledBudget = { budget, fit }; + const epoch = this.liveBudgetEpoch; + this.lastCompiledBudget = { budget, fit, epoch }; const recorded = this.recordedCompiledBudget; - if (recorded?.budget === budget && recorded.fit === fit) return; - // A listener that couldn't record it returns false, and hears it again - // at the next compile. - if (this.compiledBudgetListener?.(budget, fit) !== false) this.recordedCompiledBudget = { budget, fit }; + if (recorded?.budget === budget && recorded.fit === fit && recorded.epoch === epoch) return; + // A listener that couldn't record it returns false: nothing is assumed + // recorded then, so the next compile tells it again whatever it shows. + this.recordedCompiledBudget = this.compiledBudgetListener?.(budget, fit) !== false ? { budget, fit, epoch } : undefined; } /** Compile at the live budget (no explicit one), noting whether it fit. */ private async compileAtLiveBudget(compile: () => Promise): Promise { // The budget this compile plans at, and the descent under way when it - // started: a change made while it runs is no part of what it shows. + // started: a change of either while it runs is no part of what it shows. const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; - const target = this.contextBudgetTargetTokens; + const epoch = this.liveBudgetEpoch; + const descent = this.descentEpoch; let result: CompileResult; try { result = await compile(); } catch (error) { - const err = error as { name?: unknown } | null; - if (error instanceof OverBudgetError || err?.name === 'OverBudgetError') this.noteCompiledBudget(live, false); - throw error; + if ((error as { name?: unknown } | null)?.name !== 'OverBudgetError' || this.liveBudgetEpoch !== epoch) throw error; + this.noteCompiledBudget(live, false); + // A budget a boot restore applied at once, refused at its first test: + // restore it paced instead, as the boot would have without its proof, + // and compile again from the recipe's budget. + const restored = this.restoredAtOnce; + if (restored?.epoch !== epoch) throw error; + this.restoredAtOnce = undefined; + try { + this.resetRuntimeSettings(['contextBudgetTokens']); + this.updateRuntimeSettings(restored.settings); + } catch (fallback) { + console.error(`[agent-settings] ${this.name}: couldn't restore contextBudgetTokens=${live} paced after its refusal:`, fallback); + throw error; + } + console.error( + `[agent-settings] ${this.name}: the context manager refused contextBudgetTokens=${live}, which the boot ` + + 'applied at once; restored it as a paced descent from the recipe\'s budget', + ); + return this.compileAtLiveBudget(compile); } + if (this.restoredAtOnce?.epoch === epoch) this.restoredAtOnce = undefined; // A settle, here, of the descent this compile began under proves its - // target: the frontier fits the window prepared for it. Otherwise the - // compile proves the budget it planned at, whatever changed meanwhile. - const settled = this.settleRuntimeSettingsTransition() && target !== undefined && this.contextBudgetTokens === target; - this.noteCompiledBudget(settled ? target! : live, true); + // target: the frontier fits the window prepared for it. Without a settle, + // the compile proves the budget it planned at. Either only if the live + // budget didn't change while it ran; a settle of a descent armed while it + // ran proves nothing (the next compile at the target does). + const sameLive = this.liveBudgetEpoch === epoch; + const sameDescent = this.descentEpoch === descent; + const settled = this.settleRuntimeSettingsTransition(); + if (sameLive && settled && sameDescent) this.noteCompiledBudget(this.contextBudgetTokens!, true); + else if (sameLive && !settled) this.noteCompiledBudget(live, true); return result; } @@ -403,14 +458,14 @@ export class Agent { if (patch?.tailTokens !== undefined) { overrides.recentWindowTokens = patch.tailTokens; } - if ( - patch?.transitionPaceTokens !== undefined && - hot?.preparedWindowTokens === undefined - ) { - overrides.kvStableReachTokens = patch.transitionPaceTokens; - } + // The pace is a descent's reach, which a preview with no prepared window + // models as the reach itself. An immediate change starts no descent and + // clears any prepared window, so its next compile plans at the + // configured reach, and the pace is no part of its preview. + const paceAsReach = patch?.transitionPaceTokens !== undefined && hot?.preparedWindowTokens === undefined; if (patch?.contextBudgetTokens === undefined) { + if (paceAsReach) overrides.kvStableReachTokens = patch!.transitionPaceTokens; return { effectiveBudgetTokens: live, liveBudgetTokens: live, path: 'none', overrides }; } if (patch.contextBudgetTokens >= live || patch.immediate) { @@ -421,6 +476,7 @@ export class Agent { overrides, }; } + if (paceAsReach) overrides.kvStableReachTokens = patch.transitionPaceTokens; return { effectiveBudgetTokens: live, liveBudgetTokens: live, @@ -488,8 +544,7 @@ export class Agent { if (Object.keys(hotPatch).length > 0) { this.updateHotContextSettings(hotPatch); } - this.contextBudgetTokens = nextContextBudget; - this.contextBudgetTargetTokens = nextContextTarget; + this.setContextBudget(nextContextBudget, nextContextTarget); if (appliedDefaultPace) { this.runtimeSettingsOverrides.transitionPaceTokens = DEFAULT_TRANSITION_PACE_TOKENS; } @@ -541,8 +596,7 @@ export class Agent { : this.configuredTransitionPaceTokens ?? null; } if (Object.keys(hotPatch).length > 0) this.updateHotContextSettings(hotPatch); - this.contextBudgetTokens = nextContextBudget; - this.contextBudgetTargetTokens = nextContextTarget; + this.setContextBudget(nextContextBudget, nextContextTarget); if (reset.has('contextBudgetTokens')) delete this.runtimeSettingsOverrides.contextBudgetTokens; if (reset.has('tailTokens')) delete this.runtimeSettingsOverrides.tailTokens; if (reset.has('transitionPaceTokens')) { @@ -557,7 +611,7 @@ export class Agent { cancelRuntimeSettingsTransition(): AgentRuntimeSettingsSnapshot { if (this.contextBudgetTargetTokens !== undefined) { this.updateHotContextSettings({ preparedWindowTokens: null }); - this.contextBudgetTargetTokens = undefined; + this.setContextBudget(this.contextBudgetTokens, undefined); const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; if (live === (this.configuredContextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS)) { delete this.runtimeSettingsOverrides.contextBudgetTokens; @@ -570,8 +624,10 @@ export class Agent { /** * Apply persisted settings at construction. `provenBudget` is a budget the - * caller has shown fits (the framework, from the record and a preflight): - * it stands as the last compile's until a compile here says otherwise. + * caller has shown fits (the framework, from the record and a preflight), + * applied at once: it stands as the last compile's until a compile here + * says otherwise, and if the first compile at it is refused, the settings + * are restored paced instead. */ restoreRuntimeSettings( settings: AgentRuntimeSettingsPatch, @@ -579,7 +635,12 @@ export class Agent { ): AgentRuntimeSettingsSnapshot { if (Object.keys(settings).length === 0) return this.getRuntimeSettings(); const snapshot = this.updateRuntimeSettings(settings); - if (opts?.provenBudget !== undefined) this.lastCompiledBudget = { budget: opts.provenBudget, fit: true }; + if (opts?.provenBudget !== undefined) { + const epoch = this.liveBudgetEpoch; + this.lastCompiledBudget = { budget: opts.provenBudget, fit: true, epoch }; + const { immediate: _atOnce, ...paced } = settings; + this.restoredAtOnce = { settings: paced, epoch }; + } return snapshot; } @@ -614,8 +675,7 @@ export class Agent { if (this.contextBudgetTargetTokens === undefined) return false; const hot = this.getHotContextSettings(); if (!hot?.prepared) return false; - this.contextBudgetTokens = this.contextBudgetTargetTokens; - this.contextBudgetTargetTokens = undefined; + this.setContextBudget(this.contextBudgetTargetTokens, undefined); this.updateHotContextSettings({ preparedWindowTokens: null }); return true; } diff --git a/src/framework.ts b/src/framework.ts index 25500434e..d97b1ffdd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3981,14 +3981,16 @@ export class AgentFramework { } } - private readAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsOverrides | null { + /** An agent's persisted record, or null when it has none. A store that can't be read counts as none, unless `strict`, which throws. */ + private readAgentRuntimeSettings(agentName: string, opts?: { strict?: boolean }): AgentRuntimeSettingsOverrides | null { try { const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as { agentRuntimeSettings?: Record; } | null; const stored = data?.agentRuntimeSettings?.[agentName]; return stored ? { ...stored } : null; - } catch { + } catch (error) { + if (opts?.strict) throw error; return null; } } @@ -4002,7 +4004,7 @@ export class AgentFramework { // Unreadable, so not trusted: the budget restores paced, as without it. console.error( `[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "${agentName}": ` + - `${String(JSON.stringify(reached)).slice(0, 80)}`, + (typeof reached === 'number' ? 'a number that isn\'t a positive safe integer' : `a ${reached === null ? 'null' : typeof reached}`), ); const { contextBudgetReachedTokens: _ignored, ...rest } = overrides; overrides = rest; @@ -4070,7 +4072,12 @@ export class AgentFramework { * another budget after the record was written, through a persist: false * change. The system prompt and tools are outside the context manager's * budget, so they don't enter it (context-manager #162). Otherwise it - * restores paced, with a line saying why. + * restores as though no compile had fit it, with a line saying why. If the + * agent's first compile at a budget applied at once is refused, the agent + * restores it paced then (Agent.restoreRuntimeSettings). And settings that + * can't be applied at all (a budget no longer above maxTokens, or a + * strategy that can't prepare a smaller window) are logged and left out: + * the agent boots at the recipe's. */ private restorePersistedRuntimeSettings( agentName: string, @@ -4079,29 +4086,42 @@ export class AgentFramework { ): void { const { contextBudgetReachedTokens: reached, ...settings } = overrides; const budget = settings.contextBudgetTokens; - if (budget !== undefined && reached === budget) { - const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; - const preview = this.previewRuntimeSettingsFor(agent, patch); - if (preview.available && preview.effective?.fits) { - agent.restoreRuntimeSettings(patch, { provenBudget: budget }); - return; + try { + if (budget !== undefined && reached === budget) { + const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; + let why: string; + try { + const preview = this.previewRuntimeSettingsFor(agent, patch); + if (preview.available && preview.effective?.fits) { + agent.restoreRuntimeSettings(patch, { provenBudget: budget }); + return; + } + why = preview.available + ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` + : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`; + } catch (error) { + why = `whether it fits now can't be checked (${error instanceof Error ? error.message : String(error)})`; + } + console.error( + `[agent-settings] ${agentName}: its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}; ` + + 'restoring it as though no compile had fit it (a budget below the boot\'s descends paced)', + ); } + agent.restoreRuntimeSettings(settings); + } catch (error) { console.error( - `[agent-settings] ${agentName}: its last compile before the stop fit at contextBudgetTokens=${budget}, but ` + - (preview.available - ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` - : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`) + - '; restoring it as a paced descent', + `[agent-settings] ${agentName}: its persisted settings can't be applied now (` + + `${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`, ); } - agent.restoreRuntimeSettings(settings); } /** * What persists for an agent: its overrides, and `contextBudgetReachedTokens` - * when the agent's last compile fit at the overridden budget - * (context-manager #162). An immediate change of budget carries none until - * a compile at it fits. + * when the agent's last compile fit at the overridden budget and neither + * the budget nor the descent has changed since (context-manager #162). A + * change of budget, immediate or not, carries none until a compile at it + * fits. */ private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides { const overrides = agent.getRuntimeSettingsOverrides(); @@ -4118,12 +4138,12 @@ export class AgentFramework { * the record's own budget, withdrawn when the context manager refuses that * budget. Nothing else in the record changes, and a budget other than the * record's (one applied with `persist: false`, or a descent's starting - * budget) writes nothing. A failed write is logged; an unreadable record - * counts as none. Either way the restore then paces, as without the field. + * budget) writes nothing. A record that can't be read, or written, is + * logged, and the agent tells it again at its next compile. */ private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean { try { - const persisted = this.readAgentRuntimeSettings(agentName); + const persisted = this.readAgentRuntimeSettings(agentName, { strict: true }); if (!persisted || persisted.contextBudgetTokens !== budget) return true; if ((persisted.contextBudgetReachedTokens === budget) === fit) return true; const { contextBudgetReachedTokens: _previous, ...settings } = persisted; diff --git a/src/types/agent.ts b/src/types/agent.ts index f85b94f6f..004b6168d 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -244,15 +244,17 @@ export interface AgentRuntimeSettingsSnapshot { export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { /** * The budget the agent's last compile planned at, written while that - * compile fit: a compile that succeeds at the agent's live budget proves - * the store fits it, and one the context manager refuses as over budget - * withdraws it. When it equals `contextBudgetTokens`, a restore applies - * that budget at once rather than pacing a descent again from the - * recipe's budget (context-manager #162). Since the store fit it at the - * last compile, the planner can fold to it at once even if its carried - * frontier was lost. Any other value is stale (the budget changed since, - * or an earlier release carried the field forward) and the restore paces, - * as it does without the field. Never part of a patch. + * compile fit: a compile that succeeds at the agent's live budget, with + * nothing about the budget changed while it ran, proves the store fits it, + * and one the context manager refuses as over budget withdraws it. When it + * equals `contextBudgetTokens`, a restore applies that budget at once, + * rather than pacing a descent again from the recipe's budget + * (context-manager #162), if a preview at boot says it still fits; if not, + * or if the first compile at it is refused, the restore paces. Since the + * store fit it at the last compile, the planner can fold to it at once even + * if its carried frontier was lost. Any other value is stale (the budget + * changed since, or an earlier release carried the field forward) and the + * restore paces, as it does without the field. Never part of a patch. */ contextBudgetReachedTokens?: number; }; diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 5bc5da25e..2f3f8e1cc 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -302,8 +302,12 @@ describe('a reached budget across a restart', () => { } finally { log.restore(); } - const line = log.lines.find((l) => l.includes('ignoring an invalid persisted contextBudgetReachedTokens')); - assert.ok(line && line.length < 200, log.lines.join('\n')); + // The line names the kind of value, never its content. + const lines = log.lines.filter((l) => l.includes('ignoring an invalid persisted contextBudgetReachedTokens')); + assert.deepEqual(lines, [ + '[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "agent": a string', + '[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "agent": a number that isn\'t a positive safe integer', + ], log.lines.join('\n')); }); }); @@ -396,6 +400,196 @@ describe('a reached budget across a restart', () => { }); }); + it('of two compiles in flight across a settle, the one that finishes later proves nothing it planned before it', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let calls = 0; + // Both plan at the live 100k; the first is held until the second has + // finished and settled the descent. + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + const n = ++calls; + const result = await original.apply(this, args); + if (n === 1) await held; + return result; + }, async () => { + const first = compile(f); + await compile(f); + assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the second settled it'); + release(); + await first; + }); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), 60_000, "the first's 100k plan doesn't replace the proof"); + f.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + assert.equal(persisted(f)?.contextBudgetReachedTokens, 60_000, 'so a persisting call keeps the marker'); + }); + }); + + it('a proof stands only until the live budget changes: back at a proven budget, nothing is marked before a compile fits', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + assert.equal(persisted(f)?.contextBudgetReachedTokens, 60_000); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'back at once: no compile has fit at it since'); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.equal(budgets(f).transition, 'converging'); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'back paced: the descent has reached nothing'); + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'its settle marks it'); + }); + }); + + it('a descent re-armed to the same target while a compile runs is not credited to that compile', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + // The compile plans at the live 100k; meanwhile the budget is raised + // at once and lowered back, paced, to the same 60k. + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 120_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + return original.apply(this, args); + }, () => compile(f)); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'the compile planned at 100k under another descent'); + }); + // The same with the live budget left alone: the descent is cancelled and + // armed again, to the same 60k, while the compile runs. + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + f.cancelAgentRuntimeSettingsTransition('agent'); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + return original.apply(this, args); + }, () => compile(f)); + assert.equal(persisted(f)?.contextBudgetReachedTokens, undefined, 'a descent armed again is another descent'); + }); + }); + + it('a compile without a budget through compileWithInjections proves and records it too', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await f.getAgent('agent')!.compileWithInjections(); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + }); + }); + + it('a record that cannot be read is logged, and told again at the next compile', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + const store = f.getStore() as unknown as { getStateJson: (...args: unknown[]) => unknown }; + const original = store.getStateJson.bind(store); + store.getStateJson = () => { throw new Error('store unreadable'); }; + const log = quietErrors(); + try { + await compile(f); + } finally { + store.getStateJson = original; + log.restore(); + } + assert.ok(log.lines.some((l) => l.includes("couldn't record agent's compile at budget 60000")), log.lines.join('\n')); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }); + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + }); + }); + + it('after a withdrawal that failed and a persisting write without the marker, the next fit writes it again', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + assert.equal(persisted(f)?.contextBudgetReachedTokens, 60_000); + const store = f.getStore() as unknown as { setStateJson: (...args: unknown[]) => void }; + const write = store.setStateJson.bind(store); + store.setStateJson = () => { throw new Error('disk full'); }; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + const log = quietErrors(); + try { + await withPatched('compile', async () => { throw refusal; }, async () => { + await assert.rejects(() => compile(f), /over budget/); + }); + } finally { + store.setStateJson = write; + log.restore(); + } + f.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, tailTokens: 20_000 }, 'the refusal stands in the record now'); + await compile(f); + assert.equal(persisted(f)?.contextBudgetReachedTokens, 60_000, 'the next fit is told, and written'); + }); + }); + + it('a budget applied at once at boot whose first compile is refused is restored paced, and the compile goes on', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(framework()); + const restarted = await restart(); + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 60_000, transition: 'stable' }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const planned: number[] = []; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + const log = quietErrors(); + try { + await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }, ...rest: unknown[]) { + planned.push(budget?.maxTokens ?? -1); + if (planned.length === 1) throw refusal; + return original.apply(this, [budget, ...rest]); + }, () => compile(restarted)); + } finally { + log.restore(); + } + assert.deepEqual(planned, [60_000, 100_000], 'refused at the boot budget, then compiled from the recipe\'s'); + assert.ok(log.lines.some((l) => l.includes('restored it as a paced descent')), log.lines.join('\n')); + assert.equal(budgets(restarted).target, 60_000, 'the descent to it is armed (or settled)'); + assert.equal(persisted(restarted)?.contextBudgetTokens, 60_000); + }); + }); + + it("a persisted budget the agent can't take now (maxTokens raised above it) boots at the recipe's, with a line", async () => { + const { dir, config } = setup(); + const raised = () => ({ ...config(), agents: config().agents.map((a) => ({ ...a, maxTokens: 70_000 })) }); + try { + for (const record of [{ contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, { contextBudgetTokens: 60_000 }]) { + const writer = await AgentFramework.create(config()); + writeRecord(writer, record); + await writer.stop(); + const log = quietErrors(); + let booted: AgentFramework | undefined; + try { + booted = await AgentFramework.create(raised()); + assert.deepEqual(budgets(booted), { target: 100_000, effective: 100_000, transition: 'stable' }, JSON.stringify(record)); + } finally { + log.restore(); + await booted?.stop(); + } + assert.ok(log.lines.some((l) => l.includes("its persisted settings can't be applied now")), log.lines.join('\n')); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("an immediate patch's preview plans at the configured reach, and a paced one's at the pace", async () => { + await run(async ({ framework }) => { + const agent = framework().getAgent('agent')!; + assert.equal(agent.planRuntimeSettings({ contextBudgetTokens: 60_000, transitionPaceTokens: 4_000, immediate: true }).overrides.kvStableReachTokens, undefined); + assert.equal(agent.planRuntimeSettings({ contextBudgetTokens: 60_000, transitionPaceTokens: 4_000 }).overrides.kvStableReachTokens, 4_000); + }); + }); + it('a descent ended by an immediate change to its own target is not credited to the compile that ran meanwhile', async () => { await run(async ({ framework }) => { const f = framework(); @@ -751,7 +945,7 @@ it('previewAgentRuntimeSettings: paced patches report effective=live plus an adv }); }); -it('preflight: boot restore is never preflighted — an infeasible persisted budget cannot brick startup', async () => { +it('preflight: a boot restore without a matching reached budget is never preflighted, so an infeasible persisted budget cannot brick startup', async () => { const dir = mkdtempSync(join(tmpdir(), 'agent-settings-preflight-boot-')); const storePath = join(dir, 'store'); const config = () => ({ From 4208d0c09e662bb77a959e9c053605785aabd74d Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 11:12:31 -0400 Subject: [PATCH 07/16] fix(settings): a boot restore's line names the step that failed when applying a fitting budget at once throws restorePersistedRuntimeSettings applied a reached budget at once inside the same try as its preflight, so when the preflight said the budget fits and Agent.restoreRuntimeSettings then threw, the catch logged "whether it fits now can't be checked (...)", which wasn't what happened. The preflight now runs in its own try and only decides; the restore at once runs after it in a try of its own, and when it throws the line says "applying it at once failed (...), though the preflight says it fits now". What follows is unchanged: the paced restore of the same settings, whose own failure is logged as before ("its persisted settings can't be applied now"). Test (agent-runtime-settings): a reached budget the preflight says fits, with Agent.restoreRuntimeSettings throwing for the restore at once, boots converging toward it from the recipe's budget and logs the new line, not the "can't be checked" one. --- src/framework.ts | 14 +++++++++---- test/agent-runtime-settings.test.ts | 31 ++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index d97b1ffdd..5db3a65e3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4090,18 +4090,24 @@ export class AgentFramework { if (budget !== undefined && reached === budget) { const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; let why: string; + let fits = false; try { const preview = this.previewRuntimeSettingsFor(agent, patch); - if (preview.available && preview.effective?.fits) { - agent.restoreRuntimeSettings(patch, { provenBudget: budget }); - return; - } + fits = preview.available && preview.effective?.fits === true; why = preview.available ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`; } catch (error) { why = `whether it fits now can't be checked (${error instanceof Error ? error.message : String(error)})`; } + if (fits) { + try { + agent.restoreRuntimeSettings(patch, { provenBudget: budget }); + return; + } catch (error) { + why = `applying it at once failed (${error instanceof Error ? error.message : String(error)}), though the preflight says it fits now`; + } + } console.error( `[agent-settings] ${agentName}: its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}; ` + 'restoring it as though no compile had fit it (a budget below the boot\'s descends paced)', diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 2f3f8e1cc..51a00a78c 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { AutobiographicalStrategy, ContextManager } from '@animalabs/context-manager'; -import { AgentFramework, BudgetPreflightError } from '../src/index.js'; +import { Agent, AgentFramework, BudgetPreflightError } from '../src/index.js'; const membrane = {} as any; @@ -280,6 +280,35 @@ describe('a reached budget across a restart', () => { }); }); + it('a reached budget the preflight says fits but that fails to apply at once restores paced, with a line naming that step', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(framework()); + const proto = Agent.prototype as unknown as { + restoreRuntimeSettings: (settings: unknown, opts?: { provenBudget?: number }) => unknown; + }; + const original = proto.restoreRuntimeSettings; + proto.restoreRuntimeSettings = function (this: unknown, settings: unknown, opts?: { provenBudget?: number }) { + if (opts?.provenBudget !== undefined) throw new Error('hot settings refused'); + return original.call(this, settings, opts); + }; + const log = quietErrors(); + let restarted: AgentFramework; + try { + restarted = await restart(); + } finally { + proto.restoreRuntimeSettings = original; + log.restore(); + } + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 100_000, transition: 'converging' }); + assert.ok( + log.lines.some((line) => line.includes('applying it at once failed (hot settings refused), though the preflight says it fits now')), + log.lines.join('\n'), + ); + assert.ok(!log.lines.some((line) => line.includes("can't be checked")), log.lines.join('\n')); + }); + }); + it('a record without the field, or with one for another budget, restores paced', async () => { await run(async ({ framework, restart }) => { // An earlier release's record; then one an older release carried From 9a50c8f024e16e6b03e0f58c8adff73ca30e382a Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 09:59:17 -0400 Subject: [PATCH 08/16] fix(settings): a boot restore keeps what it can't apply, and its fallback, the proof and the preview each follow what the next compile does From an internal review of 05e0741..4208d0c. The boot. Without the proof, each persisted setting is now restored on its own: the think-text policy first, then the tail, the pace, and the budget last. One that can't be applied now (a budget no longer above maxTokens, a lower one on a strategy that can't prepare a smaller window, a tail or pace on one without live settings) is kept set but unapplied (Agent.keepRuntimeSettings). Before, one throw dropped the whole restore, so a valid `private` policy beside such a budget booted `public`, and the next persisting write rewrote the record without the rest. A kept policy governs, since requests read it from the override map; a kept budget is reported as the snapshot's contextBudgetTokens, which is now the budget that's set (the override, where there is one), beside effectiveContextBudgetTokens, the one compiles plan at. The boot logs one line per outcome, written after the restore: why the budget wasn't applied at once, what couldn't be applied, and the budget set beside the one compiles plan at. That replaces a garbled line, and the two contradictory lines a boot gave when the paced restore failed too. The boot's read of the record is strict: a record it can't read is logged, and the agent boots at the recipe's. An invalid contextBudgetReachedTokens names an array, an object and null as such. The fallback for a budget the boot applied at once. It's armed only for a budget below the live one, where the proof is what took the restore off the paced path; at or above it the restore is at once either way, so a refusal is rethrown, with no paced claim. It re-applies only the budget, paced from the recipe's, so a tail, pace or policy change since the boot is no longer overwritten, and it ends when the live budget or the descent changes (a budget change, paced or at once, or a budget reset). It moves to the recipe's budget without deleting the override, so when the paced re-apply can't be made, the agent compiles at the recipe's budget with the persisted one kept set, and the line says both. The proof. A refusal withdraws the record's marker for the budget it planned at even when the live budget changed while it ran; it is noted at the epoch it planned at, so it no longer replaces the in-memory proof of a newer live budget. Every arm of a descent counts as a descent change, the same target included. The preview. planRuntimeSettings models the reach the next compile plans at: a descent's pace while a window is prepared for it (one the patch arms, or one in flight that it leaves alone; the patch's pace, else the live one), and the configured reach otherwise. Before, a pace with no descent previewed at the pace, and a pace patch during a descent, or a paced decrease without a pace, at the configured reach. Config is a preview's only lever, so a preview taken during a descent still plans under that descent's prepared window, and at its live pace when the strategy carries one; the doc says so. Tests (agent-runtime-settings, 45; 39 before). New: a refusal after the budget changed under it; at or above the recipe's budget, no fallback; a fallback that can't pace keeps the budget; a tail change keeps the fallback and a paced change ends it; a budget maxTokens now exceeds stays set; each setting restored on its own, with a private policy governing, a tail applied and the budget surviving a later write; a record the boot can't read; the preview's reach against its compile's, for five paths. Extended: the boot-refusal test reads the record before the compile goes on, the mid-compile change test reads the proof, a same-target re-arm, the invalid field's kinds, and the boot lines in full. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 10 +- src/agent.ts | 161 ++++++--- src/framework.ts | 158 +++++---- src/types/agent.ts | 29 +- test/agent-runtime-settings.test.ts | 312 +++++++++++++++--- 5 files changed, 498 insertions(+), 172 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 800275ce5..1cdd63623 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,2 +1,8 @@ -- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. The persisted runtime settings now carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget. A compile counts only if the live budget didn't change while it ran, and a settle only for a compile that began under that descent, so of two compiles in flight, one that planned before a change proves nothing. What a compile showed stands only until the live budget changes: after any change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. At boot, a record whose two values match is applied through the immediate path, but only if the preflight says the budget fits now. The preflight plans the stored context as the context manager will, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. Otherwise, and for a record without the field (a descent still running at the stop, or a record from an earlier release) or with a value for another budget, it restores as before, paced when it's below the recipe's budget, with a log line when a reached budget no longer fits. If the first compile at a budget applied at once is refused, the agent restores it paced then and compiles again. A boot restore never refuses a persisted budget: settings that can't be applied at all (a budget no longer above `maxTokens`, or a strategy that can't prepare a smaller window) are logged, and the agent boots at the recipe's. A preview of an immediate change now plans at the configured reach, as its compile will, not at the transition pace. A patch can't set the field, an invalid value is ignored with a log line naming its kind, and a record holding nothing else restores nothing. A record that can't be read, or written, when a compile tells it is logged and told again at the next compile. -- The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now. While a lower budget converges, it is still the budget the descent started from; `contextBudgetTokens` stays the budget the agent is set to. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. +- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. + - **At boot**, a record whose two values match is applied at once, but only if the preflight says the budget fits now. The preflight plans the stored context as the context manager will, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. If the first compile at a budget applied at once below the recipe's is refused, the agent restores it paced from the recipe's budget and compiles again, or, if it can't be restored paced, compiles at the recipe's budget with the budget kept set. A tail, pace or think-text policy change after the boot leaves that fallback in place; a change of the budget or the descent ends it. + - **Otherwise** each persisted setting is restored on its own, the think-text policy first and the budget last, a lower budget paced as before. That covers a record without the field (a descent still running at the stop, or a record from an earlier release) and one with a value for another budget. + - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A setting that can't be applied now (a budget no longer above `maxTokens`, a lower one on a strategy that can't prepare a smaller window, a tail or pace on one without live settings) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a budget still governs. One log line says why the budget wasn't applied at once, what couldn't be applied, and the budget that's set beside the one compiles plan at. + - **Smaller:** a patch can't set the field; an invalid value is ignored with a log line naming its kind; a record holding nothing else restores nothing. A record the boot can't read is logged, and the agent boots at the recipe's. A record that can't be read or written when a compile tells it is logged, and told again at the next compile. +- `previewAgentRuntimeSettings` and the budget preflight plan a patch at the reach its next compile plans at: a descent's pace while a window is prepared for it (one the patch arms, or one in flight that it leaves alone), and the configured reach otherwise, so an immediate change, and a pace with no descent, preview at the configured reach. A preview taken during a descent still plans under that descent's prepared window, and at its live pace when the strategy carries one. +- The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now, beside `contextBudgetTokens`, the budget that's set. They differ while a lower budget converges, when compiles still plan at the budget the descent started from, and while a budget the agent couldn't apply is kept, when they plan at the budget it runs at. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. diff --git a/src/agent.ts b/src/agent.ts index ced51e630..19ab3e91b 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -150,9 +150,9 @@ export class Agent { */ private liveBudgetEpoch = 0; /** - * Counts changes of the descent target (a paced update, a reset, a cancel, - * a settle). A settle proves its target only to a compile that began under - * that same descent. + * Counts changes of the descent (each arm of one, the same target included, + * a reset, a cancel, a settle). A settle proves its target only to a + * compile that began under that same descent. */ private descentEpoch = 0; /** The live budget the last compile planned at, whether it fit, and the live budget's epoch then. */ @@ -160,11 +160,11 @@ export class Agent { /** The last outcome the listener recorded; a failed record is told again, and so is any after a change. */ private recordedCompiledBudget?: { budget: number; fit: boolean; epoch: number }; /** - * A boot restore that applied a proven budget at once: the settings to - * restore paced instead, if the first compile at that budget is refused, - * and the live budget's epoch it was applied at. + * A boot restore that applied a proven budget below the live one at once: + * the live budget's and the descent's epochs then. While neither has moved, + * a refusal of the first compile at that budget restores it paced instead. */ - private restoredAtOnce?: { settings: AgentRuntimeSettingsPatch; epoch: number }; + private restoredAtOnce?: { epoch: number; descent: number }; /** Told when that changes (the framework records it beside the persisted budget). */ private compiledBudgetListener?: (budget: number, fit: boolean) => boolean; private contextManager: ContextManager; @@ -305,7 +305,11 @@ export class Agent { getRuntimeSettings(): AgentRuntimeSettingsSnapshot { const hot = this.getHotContextSettings(); return { + // The budget it's set to: the override, where there is one. That is + // the target of a descent toward it, or else the live budget, except + // while it's kept unapplied (keepRuntimeSettings). contextBudgetTokens: + this.runtimeSettingsOverrides.contextBudgetTokens ?? this.contextBudgetTargetTokens ?? this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS, @@ -352,17 +356,27 @@ export class Agent { return last?.fit && last.epoch === this.liveBudgetEpoch ? last.budget : undefined; } - /** Set the live budget and the descent target, counting a change of each. */ - private setContextBudget(budget: number | undefined, target: number | undefined): void { + /** + * Set the live budget and the descent target, counting a change of each. + * `armed` counts a descent armed here as a change even when its target is + * the one already set: a compile that began under the earlier arm isn't + * credited with this one's settle. + */ + private setContextBudget(budget: number | undefined, target: number | undefined, armed = false): void { if (budget !== this.contextBudgetTokens) this.liveBudgetEpoch++; - if (target !== this.contextBudgetTargetTokens) this.descentEpoch++; + if (armed || target !== this.contextBudgetTargetTokens) this.descentEpoch++; this.contextBudgetTokens = budget; this.contextBudgetTargetTokens = target; } - private noteCompiledBudget(budget: number, fit: boolean): void { - const epoch = this.liveBudgetEpoch; - this.lastCompiledBudget = { budget, fit, epoch }; + /** + * Note what a compile at `budget`, planned at the live budget's `epoch`, + * showed. It speaks for the live budget only if that hasn't changed since; + * the listener is told either way, so a refusal withdraws the record's + * marker for the budget it planned at whatever changed meanwhile. + */ + private noteCompiledBudget(budget: number, fit: boolean, epoch: number): void { + if (epoch === this.liveBudgetEpoch) this.lastCompiledBudget = { budget, fit, epoch }; const recorded = this.recordedCompiledBudget; if (recorded?.budget === budget && recorded.fit === fit && recorded.epoch === epoch) return; // A listener that couldn't record it returns false: nothing is assumed @@ -381,24 +395,32 @@ export class Agent { try { result = await compile(); } catch (error) { - if ((error as { name?: unknown } | null)?.name !== 'OverBudgetError' || this.liveBudgetEpoch !== epoch) throw error; - this.noteCompiledBudget(live, false); - // A budget a boot restore applied at once, refused at its first test: - // restore it paced instead, as the boot would have without its proof, - // and compile again from the recipe's budget. + if ((error as { name?: unknown } | null)?.name !== 'OverBudgetError') throw error; + // A refusal shows the budget it planned at doesn't fit, whatever + // changed while it ran: the record's marker for it is withdrawn, and + // nothing is credited. + this.noteCompiledBudget(live, false, epoch); + // A budget a boot restore applied at once, refused at its first test + // with nothing about the budget or the descent changed since: restore + // it paced instead, as the boot would have without its proof, and + // compile again from the recipe's budget. const restored = this.restoredAtOnce; - if (restored?.epoch !== epoch) throw error; + if (restored?.epoch !== epoch || this.liveBudgetEpoch !== epoch || this.descentEpoch !== restored.descent) throw error; this.restoredAtOnce = undefined; + // The persisted budget stays in the overrides, so if it can't be + // restored paced, no later write erases it. + this.setContextBudget(this.configuredContextBudgetTokens, undefined); + let outcome: string; try { - this.resetRuntimeSettings(['contextBudgetTokens']); - this.updateRuntimeSettings(restored.settings); + this.updateRuntimeSettings({ contextBudgetTokens: live }); + outcome = 'restored it as a paced descent from the recipe\'s budget'; } catch (fallback) { - console.error(`[agent-settings] ${this.name}: couldn't restore contextBudgetTokens=${live} paced after its refusal:`, fallback); - throw error; + outcome = `couldn't restore it paced (${fallback instanceof Error ? fallback.message : String(fallback)}), ` + + `so it compiles at the recipe's budget, with contextBudgetTokens=${live} kept set`; } console.error( `[agent-settings] ${this.name}: the context manager refused contextBudgetTokens=${live}, which the boot ` + - 'applied at once; restored it as a paced descent from the recipe\'s budget', + `applied at once; ${outcome}`, ); return this.compileAtLiveBudget(compile); } @@ -411,8 +433,8 @@ export class Agent { const sameLive = this.liveBudgetEpoch === epoch; const sameDescent = this.descentEpoch === descent; const settled = this.settleRuntimeSettingsTransition(); - if (sameLive && settled && sameDescent) this.noteCompiledBudget(this.contextBudgetTokens!, true); - else if (sameLive && !settled) this.noteCompiledBudget(live, true); + if (sameLive && settled && sameDescent) this.noteCompiledBudget(this.contextBudgetTokens!, true, this.liveBudgetEpoch); + else if (sameLive && !settled) this.noteCompiledBudget(live, true, epoch); return result; } @@ -432,11 +454,17 @@ export class Agent { * - `path: 'none'` — patch absent or budget untouched. * * `overrides` uses strategy-config names (`recentWindowTokens`, - * `kvStableReachTokens`) suitable for `previewContext`. `preparedWindowTokens` - * is deliberately never mapped: it is strategy instance state, not config, - * and cannot be simulated by a preview. A `kvStableReachTokens` override is - * omitted while a prepared window is in flight — the live runtime pace - * shadows the config value there, so the override would not be observed. + * `kvStableReachTokens`) suitable for `previewContext`. The reach is the one + * the next compile plans at (context-manager's kv-stable picker): a + * descent's pace while a window is prepared for it, one this patch arms (a + * paced decrease) or one in flight that it leaves alone (a patch without a + * budget), and the configured reach otherwise. Config is a preview's only + * lever: `preparedWindowTokens` and the live runtime pace are strategy + * instance state, which a preview can't set. So a preview taken during a + * descent plans under that descent's prepared window, and at its live pace + * whenever the strategy carries one, which the picker reads before the + * configured reach. A boot restore's preflight never meets this: a new + * strategy has no window. */ planRuntimeSettings(patch?: AgentRuntimeSettingsPatch): { effectiveBudgetTokens: number; @@ -453,34 +481,33 @@ export class Agent { } const hot = this.getHotContextSettings(); const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; + const budget = patch?.contextBudgetTokens; + const path = budget === undefined ? 'none' : budget >= live || patch?.immediate ? 'immediate' : 'paced'; const overrides: Record = {}; if (patch?.tailTokens !== undefined) { overrides.recentWindowTokens = patch.tailTokens; } - // The pace is a descent's reach, which a preview with no prepared window - // models as the reach itself. An immediate change starts no descent and - // clears any prepared window, so its next compile plans at the - // configured reach, and the pace is no part of its preview. - const paceAsReach = patch?.transitionPaceTokens !== undefined && hot?.preparedWindowTokens === undefined; - - if (patch?.contextBudgetTokens === undefined) { - if (paceAsReach) overrides.kvStableReachTokens = patch!.transitionPaceTokens; + // A window is prepared at the next compile when this patch arms a + // descent, or leaves one in flight alone; an immediate change clears it. + const prepared = hot !== null && (path === 'paced' || (path === 'none' && hot.preparedWindowTokens !== undefined)); + const reach = prepared + ? patch?.transitionPaceTokens ?? hot.transitionPaceTokens ?? DEFAULT_TRANSITION_PACE_TOKENS + : this.configuredTransitionPaceTokens; + if (reach !== undefined && reach !== this.configuredTransitionPaceTokens) { + overrides.kvStableReachTokens = reach; + } + + if (budget === undefined) { return { effectiveBudgetTokens: live, liveBudgetTokens: live, path: 'none', overrides }; } - if (patch.contextBudgetTokens >= live || patch.immediate) { - return { - effectiveBudgetTokens: patch.contextBudgetTokens, - liveBudgetTokens: live, - path: 'immediate', - overrides, - }; + if (path === 'immediate') { + return { effectiveBudgetTokens: budget, liveBudgetTokens: live, path, overrides }; } - if (paceAsReach) overrides.kvStableReachTokens = patch.transitionPaceTokens; return { effectiveBudgetTokens: live, liveBudgetTokens: live, - advisoryTargetTokens: patch.contextBudgetTokens, + advisoryTargetTokens: budget, path: 'paced', overrides, }; @@ -507,6 +534,8 @@ export class Agent { let nextContextBudget = this.contextBudgetTokens; let nextContextTarget = this.contextBudgetTargetTokens; let appliedDefaultPace = false; + // A paced decrease arms a descent, even to the target already set. + let armed = false; const hotPatch: { tailTokens?: number; transitionPaceTokens?: number | null; @@ -538,13 +567,14 @@ export class Agent { } hotPatch.preparedWindowTokens = patch.contextBudgetTokens - this.maxTokens; nextContextTarget = patch.contextBudgetTokens; + armed = true; } } if (Object.keys(hotPatch).length > 0) { this.updateHotContextSettings(hotPatch); } - this.setContextBudget(nextContextBudget, nextContextTarget); + this.setContextBudget(nextContextBudget, nextContextTarget, armed); if (appliedDefaultPace) { this.runtimeSettingsOverrides.transitionPaceTokens = DEFAULT_TRANSITION_PACE_TOKENS; } @@ -563,6 +593,8 @@ export class Agent { const reset = new Set(keys ?? ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens', 'sameRoundThinkTextPolicy']); let nextContextBudget = this.contextBudgetTokens; let nextContextTarget = this.contextBudgetTargetTokens; + // A reset to a recipe budget below the live one arms a descent to it. + let armed = false; const hotPatch: { tailTokens?: number; transitionPaceTokens?: number | null; @@ -580,6 +612,7 @@ export class Agent { hotPatch.transitionPaceTokens = DEFAULT_TRANSITION_PACE_TOKENS; } nextContextTarget = configured; + armed = true; } else { nextContextBudget = this.configuredContextBudgetTokens; nextContextTarget = undefined; @@ -596,7 +629,7 @@ export class Agent { : this.configuredTransitionPaceTokens ?? null; } if (Object.keys(hotPatch).length > 0) this.updateHotContextSettings(hotPatch); - this.setContextBudget(nextContextBudget, nextContextTarget); + this.setContextBudget(nextContextBudget, nextContextTarget, armed); if (reset.has('contextBudgetTokens')) delete this.runtimeSettingsOverrides.contextBudgetTokens; if (reset.has('tailTokens')) delete this.runtimeSettingsOverrides.tailTokens; if (reset.has('transitionPaceTokens')) { @@ -626,24 +659,44 @@ export class Agent { * Apply persisted settings at construction. `provenBudget` is a budget the * caller has shown fits (the framework, from the record and a preflight), * applied at once: it stands as the last compile's until a compile here - * says otherwise, and if the first compile at it is refused, the settings - * are restored paced instead. + * says otherwise. When it's below the live budget, where the proof is what + * took the restore off the paced path, a refusal of the first compile at it + * restores it paced instead, while neither the budget nor the descent has + * changed since; at or above the live budget, the restore is at once either + * way, and a refusal has nothing to fall back to. */ restoreRuntimeSettings( settings: AgentRuntimeSettingsPatch, opts?: { provenBudget?: number }, ): AgentRuntimeSettingsSnapshot { if (Object.keys(settings).length === 0) return this.getRuntimeSettings(); + const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; const snapshot = this.updateRuntimeSettings(settings); if (opts?.provenBudget !== undefined) { const epoch = this.liveBudgetEpoch; this.lastCompiledBudget = { budget: opts.provenBudget, fit: true, epoch }; - const { immediate: _atOnce, ...paced } = settings; - this.restoredAtOnce = { settings: paced, epoch }; + if (opts.provenBudget < live) this.restoredAtOnce = { epoch, descent: this.descentEpoch }; } return snapshot; } + /** + * Keep persisted settings that couldn't be applied at construction: they + * stay in the override map, unapplied, so a later persisting write keeps + * them in the record. A think-text policy governs from there; a budget is + * reported as the one set (`contextBudgetTokens`) beside the one compiles + * plan at (`effectiveContextBudgetTokens`), and a tail or pace stays the + * strategy's. + */ + keepRuntimeSettings(settings: AgentRuntimeSettingsPatch): void { + for (const key of ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens'] as const) { + if (settings[key] !== undefined) this.runtimeSettingsOverrides[key] = settings[key]; + } + if (settings.sameRoundThinkTextPolicy !== undefined) { + this.runtimeSettingsOverrides.sameRoundThinkTextPolicy = settings.sameRoundThinkTextPolicy; + } + } + private validateRuntimeSettingsPatch(patch: AgentRuntimeSettingsPatch): void { if (Object.keys(patch).length === 0) throw new Error('At least one setting is required'); if (patch.contextBudgetTokens !== undefined) { diff --git a/src/framework.ts b/src/framework.ts index 5db3a65e3..740944aa2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3981,30 +3981,30 @@ export class AgentFramework { } } - /** An agent's persisted record, or null when it has none. A store that can't be read counts as none, unless `strict`, which throws. */ - private readAgentRuntimeSettings(agentName: string, opts?: { strict?: boolean }): AgentRuntimeSettingsOverrides | null { - try { - const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as { - agentRuntimeSettings?: Record; - } | null; - const stored = data?.agentRuntimeSettings?.[agentName]; - return stored ? { ...stored } : null; - } catch (error) { - if (opts?.strict) throw error; - return null; - } + /** An agent's persisted record, or null when it has none; throws when the store can't be read. */ + private readAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsOverrides | null { + const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as { + agentRuntimeSettings?: Record; + } | null; + const stored = data?.agentRuntimeSettings?.[agentName]; + return stored ? { ...stored } : null; } private validatePersistedAgentRuntimeSettings( agentName: string, overrides: AgentRuntimeSettingsOverrides, ): AgentRuntimeSettingsOverrides { - const reached = overrides.contextBudgetReachedTokens; - if (reached !== undefined && !(Number.isSafeInteger(reached) && reached > 0)) { + const reached = overrides.contextBudgetReachedTokens as unknown; + if (reached !== undefined && !(Number.isSafeInteger(reached) && (reached as number) > 0)) { // Unreadable, so not trusted: the budget restores paced, as without it. + // The line names the value's kind, never its content. + const kind = typeof reached === 'number' ? 'a number that isn\'t a positive safe integer' + : reached === null ? 'null' + : Array.isArray(reached) ? 'an array' + : typeof reached === 'object' ? 'an object' + : `a ${typeof reached}`; console.error( - `[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "${agentName}": ` + - (typeof reached === 'number' ? 'a number that isn\'t a positive safe integer' : `a ${reached === null ? 'null' : typeof reached}`), + `[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "${agentName}": ${kind}`, ); const { contextBudgetReachedTokens: _ignored, ...rest } = overrides; overrides = rest; @@ -4060,66 +4060,96 @@ export class AgentFramework { } /** - * Apply an agent's persisted settings at boot. A lower budget restores - * paced, as a descent from the recipe's budget, and a boot restore is never - * refused: a persisted budget that no longer fits can't brick startup. - * When the record says the last compile before the stop fit at its budget - * (`contextBudgetReachedTokens`), that budget is applied at once instead, - * but only if the preflight says it fits now. The preflight plans the + * Apply an agent's persisted settings at boot. A persisted setting never + * stops the agent booting, except an invalid think-text policy, which fails + * closed before this (validatePersistedAgentRuntimeSettings). When the + * record says the last compile before the stop fit at its budget + * (`contextBudgetReachedTokens`), the settings are applied at once, but + * only if the preflight says that budget fits now. The preflight plans the * stored context as the context manager will, so it sees what a restart can * change there: a new maxTokens (the response reserve) or strategy * configuration. It also sees a context that grew while the agent ran at * another budget after the record was written, through a persist: false * change. The system prompt and tools are outside the context manager's - * budget, so they don't enter it (context-manager #162). Otherwise it - * restores as though no compile had fit it, with a line saying why. If the - * agent's first compile at a budget applied at once is refused, the agent - * restores it paced then (Agent.restoreRuntimeSettings). And settings that - * can't be applied at all (a budget no longer above maxTokens, or a - * strategy that can't prepare a smaller window) are logged and left out: - * the agent boots at the recipe's. + * budget, so they don't enter it (context-manager #162). If the agent's + * first compile at a budget applied at once below the recipe's is refused, + * the agent restores it paced then (Agent.restoreRuntimeSettings). + * + * Otherwise each setting is restored on its own, the think-text policy + * first and the budget last: a lower budget as a descent from the recipe's. + * A setting that can't be applied now (a budget no longer above maxTokens, a + * lower one on a strategy that can't prepare a smaller window, a tail or + * pace on one without live settings) is kept set but unapplied + * (Agent.keepRuntimeSettings): the policy governs whatever else fails, and + * a later persisting write keeps the rest in the record. One line says why + * the budget wasn't applied at once, what couldn't be applied, and the + * budget set beside the one compiles plan at. */ private restorePersistedRuntimeSettings( agentName: string, agent: Agent, overrides: AgentRuntimeSettingsOverrides, ): void { + const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); const { contextBudgetReachedTokens: reached, ...settings } = overrides; const budget = settings.contextBudgetTokens; - try { - if (budget !== undefined && reached === budget) { - const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; - let why: string; - let fits = false; + let atOnce: string | undefined; + if (budget !== undefined && reached === budget) { + const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; + let why: string; + let fits = false; + try { + const preview = this.previewRuntimeSettingsFor(agent, patch); + fits = preview.available && preview.effective?.fits === true; + why = preview.available + ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` + : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`; + } catch (error) { + why = `whether it fits now can't be checked (${messageOf(error)})`; + } + if (fits) { try { - const preview = this.previewRuntimeSettingsFor(agent, patch); - fits = preview.available && preview.effective?.fits === true; - why = preview.available - ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` - : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`; + agent.restoreRuntimeSettings(patch, { provenBudget: budget }); + return; } catch (error) { - why = `whether it fits now can't be checked (${error instanceof Error ? error.message : String(error)})`; + why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`; } - if (fits) { - try { - agent.restoreRuntimeSettings(patch, { provenBudget: budget }); - return; - } catch (error) { - why = `applying it at once failed (${error instanceof Error ? error.message : String(error)}), though the preflight says it fits now`; - } - } - console.error( - `[agent-settings] ${agentName}: its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}; ` + - 'restoring it as though no compile had fit it (a budget below the boot\'s descends paced)', - ); } - agent.restoreRuntimeSettings(settings); - } catch (error) { - console.error( - `[agent-settings] ${agentName}: its persisted settings can't be applied now (` + - `${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`, - ); + atOnce = `its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}`; + } + const unapplied: string[] = []; + let budgetKept = false; + const { sameRoundThinkTextPolicy, tailTokens, transitionPaceTokens } = settings; + const parts: Array<[string, unknown, AgentRuntimeSettingsPatch]> = [ + ['sameRoundThinkTextPolicy', sameRoundThinkTextPolicy, { sameRoundThinkTextPolicy }], + ['tailTokens', tailTokens, { tailTokens }], + ['transitionPaceTokens', transitionPaceTokens, { transitionPaceTokens }], + ['contextBudgetTokens', budget, { contextBudgetTokens: budget }], + ]; + for (const [key, value, part] of parts) { + if (value === undefined) continue; + try { + agent.restoreRuntimeSettings(part); + } catch (error) { + agent.keepRuntimeSettings(part); + unapplied.push(`${key}=${value} (${messageOf(error)})`); + if (key === 'contextBudgetTokens') budgetKept = true; + } + } + if (atOnce === undefined && unapplied.length === 0) return; + const now = agent.getRuntimeSettings(); + const said: string[] = []; + // A budget that can't be applied at all says why itself. + if (atOnce !== undefined && !budgetKept) { + said.push(`${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`); } + if (unapplied.length > 0) { + said.push(`${unapplied.join(', ')} can't be applied now, so ${unapplied.length === 1 ? 'it stays' : 'they stay'} set but unapplied`); + } + console.error( + `[agent-settings] ${agentName}: ${said.join('; ')}: contextBudgetTokens is ${now.contextBudgetTokens}, ` + + `and compiles plan at ${now.effectiveContextBudgetTokens}${now.transition === 'stable' ? '' : ` (${now.transition})`}`, + ); } /** @@ -4149,7 +4179,7 @@ export class AgentFramework { */ private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean { try { - const persisted = this.readAgentRuntimeSettings(agentName, { strict: true }); + const persisted = this.readAgentRuntimeSettings(agentName); if (!persisted || persisted.contextBudgetTokens !== budget) return true; if ((persisted.contextBudgetReachedTokens === budget) === fit) return true; const { contextBudgetReachedTokens: _previous, ...settings } = persisted; @@ -6592,7 +6622,15 @@ export class AgentFramework { this.restoreToolResultGuardSetting(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable this.sharedSlotAgents.add(agent); - const restoredSettings = this.readAgentRuntimeSettings(config.name); + let restoredSettings: AgentRuntimeSettingsOverrides | null = null; + try { + restoredSettings = this.readAgentRuntimeSettings(config.name); + } catch (error) { + console.error( + `[agent-settings] ${config.name}: its persisted settings can't be read ` + + `(${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`, + ); + } if (restoredSettings) { this.restorePersistedRuntimeSettings( config.name, diff --git a/src/types/agent.ts b/src/types/agent.ts index 004b6168d..73466db2b 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -223,13 +223,18 @@ export interface AgentRuntimeSettingsPatch { } export interface AgentRuntimeSettingsSnapshot { - /** The budget the agent is set to: while a lower one converges, its target. */ + /** + * The budget the agent is set to: while a lower one converges, its target, + * and while a persisted one the agent couldn't apply is kept, that one. + */ contextBudgetTokens: number; /** * The budget compiles plan at now: the live budget, or the default when the * recipe sets none (the context manager's own default is the same). It * equals `contextBudgetTokens` except while a lower budget converges, when - * it is still the budget the descent started from (context-manager #162). + * it is still the budget the descent started from (context-manager #162), + * and while a budget the agent couldn't apply is kept, when it is the + * budget the agent runs at instead. */ effectiveContextBudgetTokens: number; tailTokens?: number; @@ -246,15 +251,17 @@ export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { * The budget the agent's last compile planned at, written while that * compile fit: a compile that succeeds at the agent's live budget, with * nothing about the budget changed while it ran, proves the store fits it, - * and one the context manager refuses as over budget withdraws it. When it - * equals `contextBudgetTokens`, a restore applies that budget at once, - * rather than pacing a descent again from the recipe's budget - * (context-manager #162), if a preview at boot says it still fits; if not, - * or if the first compile at it is refused, the restore paces. Since the - * store fit it at the last compile, the planner can fold to it at once even - * if its carried frontier was lost. Any other value is stale (the budget - * changed since, or an earlier release carried the field forward) and the - * restore paces, as it does without the field. Never part of a patch. + * and one the context manager refuses as over budget withdraws it, whatever + * changed while it ran. When it equals `contextBudgetTokens`, a restore + * applies that budget at once, rather than pacing a descent again from the + * recipe's budget (context-manager #162), if a preview at boot says it + * still fits; if not, the restore paces, and if the first compile at a + * budget below the recipe's is refused, the agent restores it paced then. + * Since the store fit it at the last compile, the planner can fold to it at + * once even if its carried frontier was lost. Any other value is stale (the + * budget changed since, or an earlier release carried the field forward) + * and the restore paces, as it does without the field. Never part of a + * patch. */ contextBudgetReachedTokens?: number; }; diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 51a00a78c..9c7f0b450 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { AutobiographicalStrategy, ContextManager } from '@animalabs/context-manager'; -import { Agent, AgentFramework, BudgetPreflightError } from '../src/index.js'; +import { Agent, AgentFramework, BudgetPreflightError, type AgentRuntimeSettingsPatch } from '../src/index.js'; const membrane = {} as any; @@ -276,7 +276,11 @@ describe('a reached budget across a restart', () => { } finally { log.restore(); } - assert.ok(log.lines.some((line) => line.includes('folded floor renders 70000 tokens against it now')), log.lines.join('\n')); + // One line, written once the restore is made, saying why and what it made. + assert.deepEqual(log.lines.filter((line) => line.includes('[agent-settings]')), [ + '[agent-settings] agent: its last compile before the stop fit at contextBudgetTokens=60000, but the folded floor renders ' + + '70000 tokens against it now; restored it paced instead: contextBudgetTokens is 60000, and compiles plan at 100000 (converging)', + ]); }); }); @@ -322,10 +326,12 @@ describe('a reached budget across a restart', () => { it('an invalid field is ignored with a bounded line, and a record holding only it still boots', async () => { await run(async ({ framework, restart }) => { - writeRecord(framework(), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 'yes'.repeat(100) }); const log = quietErrors(); try { - assert.equal(budgets(await restart()).transition, 'converging'); + for (const invalid of ['yes'.repeat(100), [60_000], { budget: 60_000 }, null]) { + writeRecord(framework(), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: invalid }); + assert.equal(budgets(await restart()).transition, 'converging', JSON.stringify(invalid)); + } writeRecord(framework(), { contextBudgetReachedTokens: -5 }); assert.deepEqual(budgets(await restart()), { target: 100_000, effective: 100_000, transition: 'stable' }, 'the recipe budget'); } finally { @@ -333,9 +339,13 @@ describe('a reached budget across a restart', () => { } // The line names the kind of value, never its content. const lines = log.lines.filter((l) => l.includes('ignoring an invalid persisted contextBudgetReachedTokens')); + const line = (kind: string) => `[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "agent": ${kind}`; assert.deepEqual(lines, [ - '[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "agent": a string', - '[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "agent": a number that isn\'t a positive safe integer', + line('a string'), + line('an array'), + line('an object'), + line('null'), + line('a number that isn\'t a positive safe integer'), ], log.lines.join('\n')); }); }); @@ -424,11 +434,43 @@ describe('a reached budget across a restart', () => { return original.apply(this, args); }, () => compile(f)); assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'the 100k compile proves nothing about 60k'); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), undefined, 'and no proof stands for the live budget: that compile planned at 100k'); await compile(f); assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); }); }); + it('a compile refused after the budget changed under it withdraws its own budget\'s marker, and leaves the new budget\'s proof', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + assert.equal(persisted(f)?.contextBudgetReachedTokens, 60_000); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let calls = 0; + // The first compile plans at 60k and is refused only after the budget + // went to 80k, unpersisted, and a second compile fit there. + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + if (++calls === 1) { + await held; + throw refusal; + } + return original.apply(this, args); + }, async () => { + const first = compile(f); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }, { persist: false }); + await compile(f); + release(); + await assert.rejects(first, /over budget/); + }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'the record still holds 60k, now without its marker'); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), 80_000, 'the refusal at 60k says nothing about 80k'); + }); + }); + it('of two compiles in flight across a settle, the one that finishes later proves nothing it planned before it', async () => { await run(async ({ framework }) => { const f = framework(); @@ -503,6 +545,18 @@ describe('a reached budget across a restart', () => { }, () => compile(f)); assert.equal(persisted(f)?.contextBudgetReachedTokens, undefined, 'a descent armed again is another descent'); }); + // And armed again directly, to the same 60k, with nothing in between. + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + return original.apply(this, args); + }, () => compile(f)); + assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }, 'the re-armed descent settled'); + assert.equal(persisted(f)?.contextBudgetReachedTokens, undefined, 'a descent armed again to the same target is another descent'); + }); }); it('a compile without a budget through compileWithInjections proves and records it too', async () => { @@ -535,6 +589,32 @@ describe('a reached budget across a restart', () => { }); }); + it("a record the boot can't read is logged, and the agent boots at the recipe's", async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + // Only the boot's read of the record fails. + const proto = JsStore.prototype as unknown as { getStateJson: (...args: unknown[]) => unknown }; + const original = proto.getStateJson; + proto.getStateJson = function (this: unknown, ...args: unknown[]) { + if (new Error().stack?.includes('readAgentRuntimeSettings')) throw new Error('store unreadable'); + return original.apply(this, args); + }; + const log = quietErrors(); + let restarted: AgentFramework; + try { + restarted = await restart(); + } finally { + proto.getStateJson = original; + log.restore(); + } + assert.deepEqual(budgets(restarted), { target: 100_000, effective: 100_000, transition: 'stable' }); + assert.ok( + log.lines.some((l) => l.includes("agent: its persisted settings can't be read (store unreadable); it boots at the recipe's")), + log.lines.join('\n'), + ); + }); + }); + it('after a withdrawal that failed and a persisting write without the marker, the next fit writes it again', async () => { await run(async ({ framework }) => { const f = framework(); @@ -561,64 +641,206 @@ describe('a reached budget across a restart', () => { }); }); + /** Apply `budget` at once and compile, so it's proven, then restart: the boot applies it at once. */ + async function provenAtBoot(h: { framework: () => AgentFramework; restart: () => Promise }, budget: number) { + h.framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: budget, immediate: true }); + await compile(h.framework()); + const restarted = await h.restart(); + assert.deepEqual(budgets(restarted), { target: budget, effective: budget, transition: 'stable' }, 'applied at once at boot'); + return restarted; + } + /** A compile whose first plan the context manager refuses: the budget each plan was asked for, the record as the second began, the log, and what it threw. */ + async function refusedFirst(f: AgentFramework) { + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const planned: number[] = []; + let recordAtRetry: Record | undefined; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + const log = quietErrors(); + let thrown: unknown; + try { + await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }, ...rest: unknown[]) { + planned.push(budget?.maxTokens ?? -1); + if (planned.length === 1) throw refusal; + recordAtRetry = { ...persisted(f) }; + return original.apply(this, [budget, ...rest]); + }, () => compile(f)); + } catch (error) { + thrown = error; + } finally { + log.restore(); + } + return { planned, recordAtRetry, lines: log.lines, thrown }; + } + it('a budget applied at once at boot whose first compile is refused is restored paced, and the compile goes on', async () => { - await run(async ({ framework, restart }) => { - framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); - await compile(framework()); - const restarted = await restart(); - assert.deepEqual(budgets(restarted), { target: 60_000, effective: 60_000, transition: 'stable' }); - const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; - const planned: number[] = []; - const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); - const log = quietErrors(); - try { - await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }, ...rest: unknown[]) { - planned.push(budget?.maxTokens ?? -1); - if (planned.length === 1) throw refusal; - return original.apply(this, [budget, ...rest]); - }, () => compile(restarted)); - } finally { - log.restore(); - } + await run(async (h) => { + const restarted = await provenAtBoot(h, 60_000); + const { planned, recordAtRetry, lines, thrown } = await refusedFirst(restarted); + assert.equal(thrown, undefined); assert.deepEqual(planned, [60_000, 100_000], 'refused at the boot budget, then compiled from the recipe\'s'); - assert.ok(log.lines.some((l) => l.includes('restored it as a paced descent')), log.lines.join('\n')); + assert.deepEqual(recordAtRetry, { contextBudgetTokens: 60_000 }, 'the refusal withdrew the marker before the compile went on'); + assert.ok(lines.some((l) => l.includes('restored it as a paced descent from the recipe\'s budget')), lines.join('\n')); assert.equal(budgets(restarted).target, 60_000, 'the descent to it is armed (or settled)'); assert.equal(persisted(restarted)?.contextBudgetTokens, 60_000); }); }); - it("a persisted budget the agent can't take now (maxTokens raised above it) boots at the recipe's, with a line", async () => { + it('a budget at or above the recipe\'s, applied at once at boot, has no paced restore to fall back to: its refusal is the compile\'s', async () => { + await run(async (h) => { + const restarted = await provenAtBoot(h, 120_000); + const { planned, lines, thrown } = await refusedFirst(restarted); + assert.equal((thrown as Error | undefined)?.message, 'over budget'); + assert.deepEqual(planned, [120_000], 'no second compile'); + assert.ok(!lines.some((l) => l.includes('paced')), lines.join('\n')); + assert.deepEqual(budgets(restarted), { target: 120_000, effective: 120_000, transition: 'stable' }); + assert.deepEqual(persisted(restarted), { contextBudgetTokens: 120_000 }, 'the refusal withdrew the marker'); + }); + }); + + it("a refused budget the boot applied at once that can't be restored paced: the agent compiles at the recipe's, keeping the budget set", async () => { + await run(async (h) => { + const restarted = await provenAtBoot(h, 60_000); + // The strategy can't prepare a smaller window now. + const cm = restarted.getAgent('agent')!.getContextManager() as unknown as { + updateHotContextSettings: (update: { preparedWindowTokens?: number | null }) => unknown; + }; + const hot = cm.updateHotContextSettings.bind(cm); + cm.updateHotContextSettings = (update) => { + if (typeof update.preparedWindowTokens === 'number') throw new Error('no smaller window here'); + return hot(update); + }; + const { planned, lines, thrown } = await refusedFirst(restarted); + assert.equal(thrown, undefined); + assert.deepEqual(planned, [60_000, 100_000]); + assert.ok(lines.some((l) => l.includes( + "couldn't restore it paced (no smaller window here), so it compiles at the recipe's budget, with contextBudgetTokens=60000 kept set", + )), lines.join('\n')); + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 100_000, transition: 'stable' }, 'set to 60k, live at the recipe\'s'); + restarted.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + assert.deepEqual(persisted(restarted), { contextBudgetTokens: 60_000, tailTokens: 20_000 }, 'a later write keeps the budget'); + }); + }); + + it('after the boot, a tail change keeps the fallback, and a paced change ends it', async () => { + await run(async (h) => { + h.framework().updateAgentRuntimeSettings('agent', { tailTokens: 15_000 }); + const restarted = await provenAtBoot(h, 60_000); + restarted.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + const { planned, thrown } = await refusedFirst(restarted); + assert.equal(thrown, undefined); + assert.deepEqual(planned, [60_000, 100_000], 'restored paced'); + assert.equal(restarted.getAgentRuntimeSettings('agent').tailTokens, 20_000, 'the fallback re-applies only the budget, not the boot\'s tail'); + }); + await run(async (h) => { + const restarted = await provenAtBoot(h, 60_000); + restarted.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 50_000 }); + const { planned, thrown } = await refusedFirst(restarted); + assert.equal((thrown as Error | undefined)?.message, 'over budget'); + assert.deepEqual(planned, [60_000], 'no fallback over the descent armed since'); + assert.deepEqual(budgets(restarted), { target: 50_000, effective: 60_000, transition: 'converging' }); + }); + }); + + /** A writer at the usual maxTokens leaves `record`; the agent then boots with maxTokens raised to 70k, above a persisted 60k. */ + async function bootRaised(record: Record, fn: (booted: AgentFramework, lines: string[]) => Promise | void) { const { dir, config } = setup(); const raised = () => ({ ...config(), agents: config().agents.map((a) => ({ ...a, maxTokens: 70_000 })) }); try { - for (const record of [{ contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, { contextBudgetTokens: 60_000 }]) { - const writer = await AgentFramework.create(config()); - writeRecord(writer, record); - await writer.stop(); - const log = quietErrors(); - let booted: AgentFramework | undefined; - try { - booted = await AgentFramework.create(raised()); - assert.deepEqual(budgets(booted), { target: 100_000, effective: 100_000, transition: 'stable' }, JSON.stringify(record)); - } finally { - log.restore(); - await booted?.stop(); - } - assert.ok(log.lines.some((l) => l.includes("its persisted settings can't be applied now")), log.lines.join('\n')); + const writer = await AgentFramework.create(config()); + writeRecord(writer, record); + await writer.stop(); + const log = quietErrors(); + let booted: AgentFramework | undefined; + try { + booted = await AgentFramework.create(raised()); + } finally { + log.restore(); + } + try { + await fn(booted, log.lines); + } finally { + await booted.stop(); } } finally { rmSync(dir, { recursive: true, force: true }); } + } + + it("a persisted budget the agent can't take now (maxTokens raised above it) stays set but unapplied, and it runs at the recipe's, with a line", async () => { + for (const record of [{ contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, { contextBudgetTokens: 60_000 }]) { + await bootRaised(record, (booted, lines) => { + assert.deepEqual(budgets(booted), { target: 60_000, effective: 100_000, transition: 'stable' }, JSON.stringify(record)); + // One line, whether or not the record carried a reached budget. + assert.deepEqual(lines.filter((l) => l.includes('[agent-settings]')), [ + '[agent-settings] agent: contextBudgetTokens=60000 (contextBudgetTokens must be a safe integer greater than max response tokens (70000)) ' + + "can't be applied now, so it stays set but unapplied: contextBudgetTokens is 60000, and compiles plan at 100000", + ], JSON.stringify(record)); + }); + } }); - it("an immediate patch's preview plans at the configured reach, and a paced one's at the pace", async () => { - await run(async ({ framework }) => { - const agent = framework().getAgent('agent')!; - assert.equal(agent.planRuntimeSettings({ contextBudgetTokens: 60_000, transitionPaceTokens: 4_000, immediate: true }).overrides.kvStableReachTokens, undefined); - assert.equal(agent.planRuntimeSettings({ contextBudgetTokens: 60_000, transitionPaceTokens: 4_000 }).overrides.kvStableReachTokens, 4_000); + it("each persisted setting is restored on its own: a private policy governs and a tail applies when the budget can't, and a later write keeps the budget", async () => { + await bootRaised({ contextBudgetTokens: 60_000, tailTokens: 20_000, sameRoundThinkTextPolicy: 'private' }, (booted) => { + const { contextBudgetTokens, effectiveContextBudgetTokens, tailTokens, sameRoundThinkTextPolicy, sameRoundThinkTextPolicySource } = + booted.getAgentRuntimeSettings('agent'); + assert.deepEqual( + { contextBudgetTokens, effectiveContextBudgetTokens, tailTokens, sameRoundThinkTextPolicy, sameRoundThinkTextPolicySource }, + { contextBudgetTokens: 60_000, effectiveContextBudgetTokens: 100_000, tailTokens: 20_000, sameRoundThinkTextPolicy: 'private', sameRoundThinkTextPolicySource: 'runtime_override' }, + ); + // The snapshot each request takes, which routes its same-round text and describes its think tool. + const request = (booted as unknown as { captureInferenceToolSnapshot: (agent: Agent) => { sameRoundThinkTextPolicy: string } }) + .captureInferenceToolSnapshot(booted.getAgent('agent')!); + assert.equal(request.sameRoundThinkTextPolicy, 'private'); + booted.updateAgentRuntimeSettings('agent', { tailTokens: 25_000 }); + assert.deepEqual(persisted(booted), { contextBudgetTokens: 60_000, tailTokens: 25_000, sameRoundThinkTextPolicy: 'private' }); }); }); + it("a patch's preview plans at the reach its next compile plans at", async () => { + // The reach of every kv-stable plan, a preview's or a compile's, as the + // strategy hands it to the planner. + const proto = AutobiographicalStrategy.prototype as unknown as { buildPicker: (...args: unknown[]) => unknown }; + const original = proto.buildPicker; + let reaches: Array = []; + proto.buildPicker = function (this: { _lastKvStable?: { opts?: { reachTokens?: number } } }, ...args: unknown[]) { + const picker = original.apply(this, args); + reaches.push(this._lastKvStable?.opts?.reachTokens); + return picker; + }; + // Preview `patch` after `before`, then apply it and compile: the reach both planned at. + const both = async (before: (f: AgentFramework) => void, patch: AgentRuntimeSettingsPatch) => { + let reach: number | undefined; + await run(async ({ framework }) => { + const f = framework(); + before(f); + reaches = []; + f.previewAgentRuntimeSettings('agent', patch); + const previewed = reaches; + reaches = []; + f.updateAgentRuntimeSettings('agent', patch, { persist: false }); + reaches = []; + await compile(f); + assert.ok(previewed.length > 0 && reaches.length > 0, `${JSON.stringify(patch)}: planned ${JSON.stringify({ previewed, reaches })}`); + assert.deepEqual([...new Set(previewed)], [...new Set(reaches)], JSON.stringify(patch)); + reach = reaches[0]; + }); + return reach; + }; + const none = () => {}; + try { + // The recipe's reach is 8k; no descent is under way unless `before` arms one. + assert.equal(await both(none, { contextBudgetTokens: 60_000, transitionPaceTokens: 4_000, immediate: true }), 8_000, 'an immediate change: the configured reach'); + assert.equal(await both(none, { contextBudgetTokens: 60_000, transitionPaceTokens: 4_000 }), 4_000, 'a paced decrease: its pace'); + assert.equal(await both(none, { transitionPaceTokens: 4_000 }), 8_000, 'a pace with no descent: the configured reach'); + const descending = (f: AgentFramework) => { f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); }; + assert.equal(await both(descending, { transitionPaceTokens: 4_000 }), 4_000, 'a pace during a descent: that pace'); + const paced = (f: AgentFramework) => { f.updateAgentRuntimeSettings('agent', { transitionPaceTokens: 4_000 }); }; + assert.equal(await both(paced, { contextBudgetTokens: 60_000 }), 4_000, 'a paced decrease without a pace: the live one'); + } finally { + proto.buildPicker = original; + } + }); + it('a descent ended by an immediate change to its own target is not credited to the compile that ran meanwhile', async () => { await run(async ({ framework }) => { const f = framework(); From abeafb13de461b69f3c0a634024d579545abbfad Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 10:36:59 -0400 Subject: [PATCH 09/16] fix(settings): the boot restores the budget alone, last; a write of the budget ends the at-once fallback; a newer fit outweighs a stale refusal From an internal review of 4208d0c..9a50c8f. The boot. The at-once path restored the reached budget bundled with the persisted tail and pace, so a tail or pace the strategy couldn't take sent a proven budget down the paced path, where a strategy without live settings kept it unapplied. Each setting is now restored on its own in both paths: the think-text policy first, outside the keep path, so it still fails closed; then the tail and the pace; and the budget last and alone, at once with its proof if the preflight of the budget alone (with the restored tail and pace live) says it fits, otherwise paced. The boot's line keeps why a reached budget wasn't applied at once when the budget is also kept. A malformed persisted budget, tail or pace is dropped with a line naming its kind, as the marker is, so what's kept is always a well-formed number, and the line prints no persisted content. keepRuntimeSettings takes only a budget, tail or pace. The fallback for a budget the boot applied at once. It stayed armed through an operator's write of the same budget, which moves no epoch, so a refused first compile could replace an explicit budget with a paced descent from the recipe's. It now ends at any update that carries a budget, whatever its value, at a reset of the budget, and at a compile that fits; a tail, pace or policy change leaves it. While it holds, the live budget is the one the boot applied, so it's a flag rather than two epochs. The proof. A refusal planned before the live budget came back to the same budget withdrew the marker a newer compile had written there. A stale refusal now tells the listener nothing when a proof of that same budget stands at the live budget's epoch. Text. The marker is the record's own budget, written when a compile fits at it, not the last compile's budget. The preview's reach is the next compile's outside a descent; during one it plans under the descent's window and live pace. A budget at or above the recipe's is applied at once either way. A store that can't be read at all fails agent creation at an earlier read of the same state, as before. Tests (agent-runtime-settings, 49): a reached budget applied at once on its own beside a tail the strategy refuses; a tail and a lower budget kept on a strategy without live settings, through a later write and a tail reset; a refusal planned before a change back to the same budget; the fallback ended by a same-value write at once and unpersisted, a budget reset, an increase during the first compile, and a fitting compile; a re-arm by a reset; a malformed budget, tail and pace; and the kept-budget line with its at-once reason. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 12 +- src/agent.ts | 71 +++--- src/framework.ts | 141 ++++++------ src/types/agent.ts | 19 +- test/agent-runtime-settings.test.ts | 202 ++++++++++++++++-- 5 files changed, 316 insertions(+), 129 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 1cdd63623..05351fd6b 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,8 +1,8 @@ - A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. - - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`: the budget the agent's last compile planned at, written when a compile fits at the record's budget (a paced descent's settle included) and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. - - **At boot**, a record whose two values match is applied at once, but only if the preflight says the budget fits now. The preflight plans the stored context as the context manager will, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. If the first compile at a budget applied at once below the recipe's is refused, the agent restores it paced from the recipe's budget and compiles again, or, if it can't be restored paced, compiles at the recipe's budget with the budget kept set. A tail, pace or think-text policy change after the boot leaves that fallback in place; a change of the budget or the descent ends it. - - **Otherwise** each persisted setting is restored on its own, the think-text policy first and the budget last, a lower budget paced as before. That covers a record without the field (a descent still running at the stop, or a record from an earlier release) and one with a value for another budget. - - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A setting that can't be applied now (a budget no longer above `maxTokens`, a lower one on a strategy that can't prepare a smaller window, a tail or pace on one without live settings) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a budget still governs. One log line says why the budget wasn't applied at once, what couldn't be applied, and the budget that's set beside the one compiles plan at. - - **Smaller:** a patch can't set the field; an invalid value is ignored with a log line naming its kind; a record holding nothing else restores nothing. A record the boot can't read is logged, and the agent boots at the recipe's. A record that can't be read or written when a compile tells it is logged, and told again at the next compile. -- `previewAgentRuntimeSettings` and the budget preflight plan a patch at the reach its next compile plans at: a descent's pace while a window is prepared for it (one the patch arms, or one in flight that it leaves alone), and the configured reach otherwise, so an immediate change, and a pace with no descent, preview at the configured reach. A preview taken during a descent still plans under that descent's prepared window, and at its live pace when the strategy carries one. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a newer compile has fit at that same budget since. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. + - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. A budget at or above the recipe's is applied at once either way, as an increase always is. + - **If a compile at a budget the boot applied at once is refused,** the agent restores it paced from the recipe's budget and compiles again, or, if it can't be restored paced, compiles at the recipe's budget with the budget kept set. That fallback lasts until the budget is next written (an update that carries a budget, whatever its value, or a reset of it) or a compile at it fits; a tail, pace or think-text policy change leaves it in place. + - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. One log line says why a reached budget wasn't applied at once, what couldn't be applied, and the budget that's set beside the one compiles plan at. + - **Smaller:** a patch can't set the field; a malformed persisted number (the marker, a budget, a tail or a pace) is ignored with a log line naming its kind; a record holding nothing else restores nothing. If reading the record fails at boot, the agent logs it and boots at the recipe's; a store that can't be read at all still fails agent creation at an earlier read of the same state, as before. A record that can't be read or written when a compile tells it is logged, and told again at the next compile. +- Outside a descent, `previewAgentRuntimeSettings` and the budget preflight plan a patch at the reach its next compile plans at: the pace for a paced decrease, and the configured reach otherwise, so an immediate change, and a pace with no descent, preview at the configured reach. A preview taken during a descent plans under that descent's prepared window, and at its live pace when the strategy carries one, which a preview can't change. - The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now, beside `contextBudgetTokens`, the budget that's set. They differ while a lower budget converges, when compiles still plan at the budget the descent started from, and while a budget the agent couldn't apply is kept, when they plan at the budget it runs at. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. diff --git a/src/agent.ts b/src/agent.ts index 19ab3e91b..8ea686da1 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -160,11 +160,12 @@ export class Agent { /** The last outcome the listener recorded; a failed record is told again, and so is any after a change. */ private recordedCompiledBudget?: { budget: number; fit: boolean; epoch: number }; /** - * A boot restore that applied a proven budget below the live one at once: - * the live budget's and the descent's epochs then. While neither has moved, - * a refusal of the first compile at that budget restores it paced instead. + * A boot restore applied a proven budget below the live one at once. Until + * the budget is next written (an update that carries one, or a reset of + * it) or a compile at it fits, a refusal of a compile at it restores it + * paced instead. */ - private restoredAtOnce?: { epoch: number; descent: number }; + private restoredAtOnce = false; /** Told when that changes (the framework records it beside the persisted budget). */ private compiledBudgetListener?: (budget: number, fit: boolean) => boolean; private contextManager: ContextManager; @@ -307,7 +308,8 @@ export class Agent { return { // The budget it's set to: the override, where there is one. That is // the target of a descent toward it, or else the live budget, except - // while it's kept unapplied (keepRuntimeSettings). + // while it's kept unapplied: at boot (keepRuntimeSettings), or after a + // refused at-once budget couldn't be restored paced (compileAtLiveBudget). contextBudgetTokens: this.runtimeSettingsOverrides.contextBudgetTokens ?? this.contextBudgetTargetTokens ?? @@ -398,15 +400,19 @@ export class Agent { if ((error as { name?: unknown } | null)?.name !== 'OverBudgetError') throw error; // A refusal shows the budget it planned at doesn't fit, whatever // changed while it ran: the record's marker for it is withdrawn, and - // nothing is credited. - this.noteCompiledBudget(live, false, epoch); - // A budget a boot restore applied at once, refused at its first test - // with nothing about the budget or the descent changed since: restore - // it paced instead, as the boot would have without its proof, and - // compile again from the recipe's budget. - const restored = this.restoredAtOnce; - if (restored?.epoch !== epoch || this.liveBudgetEpoch !== epoch || this.descentEpoch !== restored.descent) throw error; - this.restoredAtOnce = undefined; + // nothing is credited. Only a newer compile that fit at that same + // budget, proven at the live budget's epoch since, outweighs it. + if (epoch === this.liveBudgetEpoch || this.getProvenContextBudgetTokens() !== live) { + this.noteCompiledBudget(live, false, epoch); + } + // A budget a boot restore applied at once, refused at its first test, + // before anything wrote the budget: restore it paced instead, as the + // boot would have without its proof, and compile again from the + // recipe's budget. (While restoredAtOnce holds, the live budget is the + // one the boot applied: every write of the budget clears it, and no + // compile runs before the boot's restore.) + if (!this.restoredAtOnce) throw error; + this.restoredAtOnce = false; // The persisted budget stays in the overrides, so if it can't be // restored paced, no later write erases it. this.setContextBudget(this.configuredContextBudgetTokens, undefined); @@ -424,7 +430,7 @@ export class Agent { ); return this.compileAtLiveBudget(compile); } - if (this.restoredAtOnce?.epoch === epoch) this.restoredAtOnce = undefined; + this.restoredAtOnce = false; // A settle, here, of the descent this compile began under proves its // target: the frontier fits the window prepared for it. Without a settle, // the compile proves the budget it planned at. Either only if the live @@ -575,6 +581,9 @@ export class Agent { this.updateHotContextSettings(hotPatch); } this.setContextBudget(nextContextBudget, nextContextTarget, armed); + // A budget written here is a decision about it, whatever its value: the + // boot's at-once fallback (restoredAtOnce) no longer speaks for it. + if (patch.contextBudgetTokens !== undefined) this.restoredAtOnce = false; if (appliedDefaultPace) { this.runtimeSettingsOverrides.transitionPaceTokens = DEFAULT_TRANSITION_PACE_TOKENS; } @@ -630,7 +639,10 @@ export class Agent { } if (Object.keys(hotPatch).length > 0) this.updateHotContextSettings(hotPatch); this.setContextBudget(nextContextBudget, nextContextTarget, armed); - if (reset.has('contextBudgetTokens')) delete this.runtimeSettingsOverrides.contextBudgetTokens; + if (reset.has('contextBudgetTokens')) { + delete this.runtimeSettingsOverrides.contextBudgetTokens; + this.restoredAtOnce = false; + } if (reset.has('tailTokens')) delete this.runtimeSettingsOverrides.tailTokens; if (reset.has('transitionPaceTokens')) { delete this.runtimeSettingsOverrides.transitionPaceTokens; @@ -660,9 +672,9 @@ export class Agent { * caller has shown fits (the framework, from the record and a preflight), * applied at once: it stands as the last compile's until a compile here * says otherwise. When it's below the live budget, where the proof is what - * took the restore off the paced path, a refusal of the first compile at it - * restores it paced instead, while neither the budget nor the descent has - * changed since; at or above the live budget, the restore is at once either + * took the restore off the paced path, a refusal of a compile at it + * restores it paced instead, until the budget is next written or a compile + * at it fits. At or above the live budget, the restore is at once either * way, and a refusal has nothing to fall back to. */ restoreRuntimeSettings( @@ -673,28 +685,23 @@ export class Agent { const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; const snapshot = this.updateRuntimeSettings(settings); if (opts?.provenBudget !== undefined) { - const epoch = this.liveBudgetEpoch; - this.lastCompiledBudget = { budget: opts.provenBudget, fit: true, epoch }; - if (opts.provenBudget < live) this.restoredAtOnce = { epoch, descent: this.descentEpoch }; + this.lastCompiledBudget = { budget: opts.provenBudget, fit: true, epoch: this.liveBudgetEpoch }; + this.restoredAtOnce = opts.provenBudget < live; } return snapshot; } /** - * Keep persisted settings that couldn't be applied at construction: they - * stay in the override map, unapplied, so a later persisting write keeps - * them in the record. A think-text policy governs from there; a budget is - * reported as the one set (`contextBudgetTokens`) beside the one compiles - * plan at (`effectiveContextBudgetTokens`), and a tail or pace stays the - * strategy's. + * Keep a persisted budget, tail or pace that couldn't be applied at + * construction: it stays in the override map, unapplied, so a later + * persisting write keeps it in the record. A budget is reported as the one + * set (`contextBudgetTokens`) beside the one compiles plan at + * (`effectiveContextBudgetTokens`); a tail or pace stays the strategy's. */ - keepRuntimeSettings(settings: AgentRuntimeSettingsPatch): void { + keepRuntimeSettings(settings: Pick): void { for (const key of ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens'] as const) { if (settings[key] !== undefined) this.runtimeSettingsOverrides[key] = settings[key]; } - if (settings.sameRoundThinkTextPolicy !== undefined) { - this.runtimeSettingsOverrides.sameRoundThinkTextPolicy = settings.sameRoundThinkTextPolicy; - } } private validateRuntimeSettingsPatch(patch: AgentRuntimeSettingsPatch): void { diff --git a/src/framework.ts b/src/framework.ts index 740944aa2..214360d40 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3994,19 +3994,26 @@ export class AgentFramework { agentName: string, overrides: AgentRuntimeSettingsOverrides, ): AgentRuntimeSettingsOverrides { - const reached = overrides.contextBudgetReachedTokens as unknown; - if (reached !== undefined && !(Number.isSafeInteger(reached) && (reached as number) > 0)) { - // Unreadable, so not trusted: the budget restores paced, as without it. - // The line names the value's kind, never its content. - const kind = typeof reached === 'number' ? 'a number that isn\'t a positive safe integer' - : reached === null ? 'null' - : Array.isArray(reached) ? 'an array' - : typeof reached === 'object' ? 'an object' - : `a ${typeof reached}`; - console.error( - `[agent-settings] ignoring an invalid persisted contextBudgetReachedTokens for agent "${agentName}": ${kind}`, - ); - const { contextBudgetReachedTokens: _ignored, ...rest } = overrides; + // A malformed number is dropped, with a line that names the value's kind, + // never its content. It could never apply: a budget, tail or pace kept + // unapplied would be reported and written back as it is, and a marker + // that can't be read isn't trusted, so the budget restores paced. + const numbers = [ + ['contextBudgetReachedTokens', 1], + ['contextBudgetTokens', 1], + ['tailTokens', 0], + ['transitionPaceTokens', 1], + ] as const; + for (const [key, least] of numbers) { + const value = overrides[key] as unknown; + if (value === undefined || (Number.isSafeInteger(value) && (value as number) >= least)) continue; + const kind = typeof value === 'number' ? `a number that isn't a ${least > 0 ? 'positive' : 'non-negative'} safe integer` + : value === null ? 'null' + : Array.isArray(value) ? 'an array' + : typeof value === 'object' ? 'an object' + : `a ${typeof value}`; + console.error(`[agent-settings] ignoring an invalid persisted ${key} for agent "${agentName}": ${kind}`); + const { [key]: _ignored, ...rest } = overrides; overrides = rest; } if ( @@ -4060,30 +4067,32 @@ export class AgentFramework { } /** - * Apply an agent's persisted settings at boot. A persisted setting never - * stops the agent booting, except an invalid think-text policy, which fails - * closed before this (validatePersistedAgentRuntimeSettings). When the - * record says the last compile before the stop fit at its budget - * (`contextBudgetReachedTokens`), the settings are applied at once, but - * only if the preflight says that budget fits now. The preflight plans the - * stored context as the context manager will, so it sees what a restart can - * change there: a new maxTokens (the response reserve) or strategy - * configuration. It also sees a context that grew while the agent ran at - * another budget after the record was written, through a persist: false - * change. The system prompt and tools are outside the context manager's - * budget, so they don't enter it (context-manager #162). If the agent's - * first compile at a budget applied at once below the recipe's is refused, - * the agent restores it paced then (Agent.restoreRuntimeSettings). + * Apply an agent's persisted settings at boot, each on its own, the budget + * last. A persisted setting never stops the agent booting, except a + * think-text policy, which fails closed: an invalid one is refused before + * this (validatePersistedAgentRuntimeSettings), and a valid one always + * applies. A tail, pace or budget that can't be applied now (a tail or pace + * on a strategy without live settings, a budget no longer above maxTokens, + * a lower one on a strategy that can't prepare a smaller window) is kept + * set but unapplied (Agent.keepRuntimeSettings), so a later persisting + * write keeps it in the record. + * + * A budget below the recipe's restores as a paced descent from it, unless + * the record says the last compile before the stop fit at it + * (`contextBudgetReachedTokens`) and the preflight says it fits now: then + * it is applied at once. The preflight plans the stored context as the + * context manager will, with the restored tail and pace, so it sees what a + * restart can change there: a new maxTokens (the response reserve) or + * strategy configuration. It also sees a context that grew while the agent + * ran at another budget after the record was written, through a persist: + * false change. The system prompt and tools are outside the context + * manager's budget, so they don't enter it (context-manager #162). If a + * compile at a budget applied at once is refused, the agent restores it + * paced then (Agent.restoreRuntimeSettings). A budget at or above the + * recipe's is applied at once either way, as an increase always is. * - * Otherwise each setting is restored on its own, the think-text policy - * first and the budget last: a lower budget as a descent from the recipe's. - * A setting that can't be applied now (a budget no longer above maxTokens, a - * lower one on a strategy that can't prepare a smaller window, a tail or - * pace on one without live settings) is kept set but unapplied - * (Agent.keepRuntimeSettings): the policy governs whatever else fails, and - * a later persisting write keeps the rest in the record. One line says why - * the budget wasn't applied at once, what couldn't be applied, and the - * budget set beside the one compiles plan at. + * One line says why a reached budget wasn't applied at once, what couldn't + * be applied, and the budget set beside the one compiles plan at. */ private restorePersistedRuntimeSettings( agentName: string, @@ -4091,57 +4100,51 @@ export class AgentFramework { overrides: AgentRuntimeSettingsOverrides, ): void { const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); - const { contextBudgetReachedTokens: reached, ...settings } = overrides; + const { contextBudgetReachedTokens: reached, sameRoundThinkTextPolicy, ...settings } = overrides; + if (sameRoundThinkTextPolicy !== undefined) agent.restoreRuntimeSettings({ sameRoundThinkTextPolicy }); + const unapplied: string[] = []; + /** Restore one setting, or keep it set but unapplied; true if it applied. */ + const restore = (part: Pick) => { + try { + agent.restoreRuntimeSettings(part); + return true; + } catch (error) { + agent.keepRuntimeSettings(part); + unapplied.push(Object.entries(part).map(([key, value]) => `${key}=${value}`).join(', ') + ` (${messageOf(error)})`); + return false; + } + }; + if (settings.tailTokens !== undefined) restore({ tailTokens: settings.tailTokens }); + if (settings.transitionPaceTokens !== undefined) restore({ transitionPaceTokens: settings.transitionPaceTokens }); const budget = settings.contextBudgetTokens; let atOnce: string | undefined; + let appliedAtOnce = false; if (budget !== undefined && reached === budget) { - const patch: AgentRuntimeSettingsPatch = { ...settings, immediate: true }; - let why: string; - let fits = false; + const patch: AgentRuntimeSettingsPatch = { contextBudgetTokens: budget, immediate: true }; + let why: string | undefined; try { const preview = this.previewRuntimeSettingsFor(agent, patch); - fits = preview.available && preview.effective?.fits === true; - why = preview.available - ? `the folded floor renders ${preview.effective?.finalTokens} tokens against it now` - : `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`; + if (!preview.available) why = `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`; + else if (preview.effective?.fits !== true) why = `the folded floor renders ${preview.effective?.finalTokens} tokens against it now`; } catch (error) { why = `whether it fits now can't be checked (${messageOf(error)})`; } - if (fits) { + if (why === undefined) { try { agent.restoreRuntimeSettings(patch, { provenBudget: budget }); - return; + appliedAtOnce = true; } catch (error) { why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`; } } - atOnce = `its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}`; - } - const unapplied: string[] = []; - let budgetKept = false; - const { sameRoundThinkTextPolicy, tailTokens, transitionPaceTokens } = settings; - const parts: Array<[string, unknown, AgentRuntimeSettingsPatch]> = [ - ['sameRoundThinkTextPolicy', sameRoundThinkTextPolicy, { sameRoundThinkTextPolicy }], - ['tailTokens', tailTokens, { tailTokens }], - ['transitionPaceTokens', transitionPaceTokens, { transitionPaceTokens }], - ['contextBudgetTokens', budget, { contextBudgetTokens: budget }], - ]; - for (const [key, value, part] of parts) { - if (value === undefined) continue; - try { - agent.restoreRuntimeSettings(part); - } catch (error) { - agent.keepRuntimeSettings(part); - unapplied.push(`${key}=${value} (${messageOf(error)})`); - if (key === 'contextBudgetTokens') budgetKept = true; - } + if (why !== undefined) atOnce = `its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}`; } + const budgetKept = budget !== undefined && !appliedAtOnce && !restore({ contextBudgetTokens: budget }); if (atOnce === undefined && unapplied.length === 0) return; const now = agent.getRuntimeSettings(); const said: string[] = []; - // A budget that can't be applied at all says why itself. - if (atOnce !== undefined && !budgetKept) { - said.push(`${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`); + if (atOnce !== undefined) { + said.push(budgetKept ? atOnce : `${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`); } if (unapplied.length > 0) { said.push(`${unapplied.join(', ')} can't be applied now, so ${unapplied.length === 1 ? 'it stays' : 'they stay'} set but unapplied`); diff --git a/src/types/agent.ts b/src/types/agent.ts index 73466db2b..5367a75fa 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -248,15 +248,16 @@ export interface AgentRuntimeSettingsSnapshot { /** Persisted values differ from recipe defaults; omitted keys inherit config. */ export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { /** - * The budget the agent's last compile planned at, written while that - * compile fit: a compile that succeeds at the agent's live budget, with - * nothing about the budget changed while it ran, proves the store fits it, - * and one the context manager refuses as over budget withdraws it, whatever - * changed while it ran. When it equals `contextBudgetTokens`, a restore - * applies that budget at once, rather than pacing a descent again from the - * recipe's budget (context-manager #162), if a preview at boot says it - * still fits; if not, the restore paces, and if the first compile at a - * budget below the recipe's is refused, the agent restores it paced then. + * The record's own `contextBudgetTokens`, written when a compile at the + * agent's live budget fits at it, with nothing about the budget changed + * while it ran (a paced descent's settle included): the store fit that + * budget. A compile the context manager refuses at it as over budget + * withdraws it, unless a newer compile has fit at that same budget since. + * When it equals `contextBudgetTokens`, a restore applies that budget at + * once, rather than pacing a descent again from the recipe's budget + * (context-manager #162), if a preview at boot says it still fits; if not, + * the restore paces, and if a compile at a budget below the recipe's that + * the boot applied at once is refused, the agent restores it paced then. * Since the store fit it at the last compile, the planner can fold to it at * once even if its carried frontier was lost. Any other value is stale (the * budget changed since, or an earlier release carried the field forward) diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 9c7f0b450..887255843 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -350,6 +350,30 @@ describe('a reached budget across a restart', () => { }); }); + it('a malformed persisted budget, tail or pace is ignored with a line naming its kind, and the rest restores', async () => { + await run(async ({ framework, restart }) => { + writeRecord(framework(), { contextBudgetTokens: 'x'.repeat(100), tailTokens: -1, transitionPaceTokens: 4_000 }); + const log = quietErrors(); + let restarted: AgentFramework; + try { + restarted = await restart(); + } finally { + log.restore(); + } + const { contextBudgetTokens, effectiveContextBudgetTokens, tailTokens, transitionPaceTokens } = restarted.getAgentRuntimeSettings('agent'); + assert.deepEqual( + { contextBudgetTokens, effectiveContextBudgetTokens, tailTokens, transitionPaceTokens }, + { contextBudgetTokens: 100_000, effectiveContextBudgetTokens: 100_000, tailTokens: 30_000, transitionPaceTokens: 4_000 }, + ); + assert.deepEqual(log.lines.filter((l) => l.includes('[agent-settings]')), [ + '[agent-settings] ignoring an invalid persisted contextBudgetTokens for agent "agent": a string', + '[agent-settings] ignoring an invalid persisted tailTokens for agent "agent": a number that isn\'t a non-negative safe integer', + ]); + restarted.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + assert.deepEqual(persisted(restarted), { transitionPaceTokens: 4_000, tailTokens: 20_000 }, 'a later write leaves them out'); + }); + }); + it('a patch cannot set the field', async () => { await run(async ({ framework }) => { framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 } as never); @@ -471,6 +495,37 @@ describe('a reached budget across a restart', () => { }); }); + it('a refusal planned before the budget came back to it leaves the marker a newer compile wrote there', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let calls = 0; + // The first compile plans at 60k and is refused only after the budget + // went to 80k and came back to 60k, and a second compile fit at 60k. + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + if (++calls === 1) { + await held; + throw refusal; + } + return original.apply(this, args); + }, async () => { + const first = compile(f); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the second compile fit at 60k'); + release(); + await assert.rejects(first, /over budget/); + }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the newer fit outweighs the older refusal'); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), 60_000); + }); + }); + it('of two compiles in flight across a settle, the one that finishes later proves nothing it planned before it', async () => { await run(async ({ framework }) => { const f = framework(); @@ -557,6 +612,22 @@ describe('a reached budget across a restart', () => { assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }, 'the re-armed descent settled'); assert.equal(persisted(f)?.contextBudgetReachedTokens, undefined, 'a descent armed again to the same target is another descent'); }); + // And armed again by a reset to the recipe's budget, below the live one. + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 120_000 }); + f.resetAgentRuntimeSettings('agent', ['contextBudgetTokens']); + assert.deepEqual(budgets(f), { target: 100_000, effective: 120_000, transition: 'converging' }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + f.resetAgentRuntimeSettings('agent', ['contextBudgetTokens']); + return original.apply(this, args); + }, () => compile(f)); + assert.deepEqual(budgets(f), { target: 100_000, effective: 100_000, transition: 'stable' }, 'the re-armed descent settled'); + // Setting the budget it now runs at keeps any proof the settle gave it. + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 100_000, immediate: true }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 100_000 }, 'a descent armed again by a reset is another descent'); + }); }); it('a compile without a budget through compileWithInjections proves and records it too', async () => { @@ -649,8 +720,12 @@ describe('a reached budget across a restart', () => { assert.deepEqual(budgets(restarted), { target: budget, effective: budget, transition: 'stable' }, 'applied at once at boot'); return restarted; } - /** A compile whose first plan the context manager refuses: the budget each plan was asked for, the record as the second began, the log, and what it threw. */ - async function refusedFirst(f: AgentFramework) { + /** + * A compile whose first plan the context manager refuses, after `meanwhile` + * ran while that plan was under way: the budget each plan was asked for, + * the record as the second began, the log, and what it threw. + */ + async function refusedFirst(f: AgentFramework, meanwhile?: () => void) { const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; const planned: number[] = []; let recordAtRetry: Record | undefined; @@ -660,7 +735,10 @@ describe('a reached budget across a restart', () => { try { await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }, ...rest: unknown[]) { planned.push(budget?.maxTokens ?? -1); - if (planned.length === 1) throw refusal; + if (planned.length === 1) { + meanwhile?.(); + throw refusal; + } recordAtRetry = { ...persisted(f) }; return original.apply(this, [budget, ...rest]); }, () => compile(f)); @@ -721,7 +799,7 @@ describe('a reached budget across a restart', () => { }); }); - it('after the boot, a tail change keeps the fallback, and a paced change ends it', async () => { + it('after the boot, a tail change keeps the fallback, and any write of the budget ends it', async () => { await run(async (h) => { h.framework().updateAgentRuntimeSettings('agent', { tailTokens: 15_000 }); const restarted = await provenAtBoot(h, 60_000); @@ -731,13 +809,37 @@ describe('a reached budget across a restart', () => { assert.deepEqual(planned, [60_000, 100_000], 'restored paced'); assert.equal(restarted.getAgentRuntimeSettings('agent').tailTokens, 20_000, 'the fallback re-applies only the budget, not the boot\'s tail'); }); + // Each write of the budget, before the first compile or while it runs, + // is the operator's decision: the refusal is that compile's own. + const writes: Array<[string, (f: AgentFramework) => void, boolean, number, ReturnType]> = [ + ['a paced decrease', (f) => { f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 50_000 }); }, false, 60_000, + { target: 50_000, effective: 60_000, transition: 'converging' }], + ['the same budget at once', (f) => { f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); }, false, 60_000, + { target: 60_000, effective: 60_000, transition: 'stable' }], + ['the same budget, unpersisted', (f) => { f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }, { persist: false }); }, false, 60_000, + { target: 60_000, effective: 60_000, transition: 'stable' }], + ['a reset of the budget', (f) => { f.resetAgentRuntimeSettings('agent', ['contextBudgetTokens']); }, false, 100_000, + { target: 100_000, effective: 100_000, transition: 'stable' }], + ['an increase while the first compile runs', (f) => { f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); }, true, 60_000, + { target: 80_000, effective: 80_000, transition: 'stable' }], + ]; + for (const [what, write, meanwhile, refusedAt, after] of writes) { + await run(async (h) => { + const restarted = await provenAtBoot(h, 60_000); + if (!meanwhile) write(restarted); + const { planned, thrown } = await refusedFirst(restarted, meanwhile ? () => write(restarted) : undefined); + assert.equal((thrown as Error | undefined)?.message, 'over budget', what); + assert.deepEqual(planned, [refusedAt], `${what}: no fallback`); + assert.deepEqual(budgets(restarted), after, what); + }); + } + // And a compile that fits at it ends the fallback too. await run(async (h) => { const restarted = await provenAtBoot(h, 60_000); - restarted.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 50_000 }); + await compile(restarted); const { planned, thrown } = await refusedFirst(restarted); assert.equal((thrown as Error | undefined)?.message, 'over budget'); - assert.deepEqual(planned, [60_000], 'no fallback over the descent armed since'); - assert.deepEqual(budgets(restarted), { target: 50_000, effective: 60_000, transition: 'converging' }); + assert.deepEqual(planned, [60_000], 'once a compile has fit at it, a refusal is the compile\'s own'); }); }); @@ -767,15 +869,89 @@ describe('a reached budget across a restart', () => { } it("a persisted budget the agent can't take now (maxTokens raised above it) stays set but unapplied, and it runs at the recipe's, with a line", async () => { - for (const record of [{ contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, { contextBudgetTokens: 60_000 }]) { + const refused = 'contextBudgetTokens must be a safe integer greater than max response tokens (70000)'; + const kept = `contextBudgetTokens=60000 (${refused}) can't be applied now, so it stays set but unapplied: ` + + 'contextBudgetTokens is 60000, and compiles plan at 100000'; + const cases: Array<[Record, string]> = [ + // One line, which keeps why a reached budget wasn't applied at once. + [{ contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, + `[agent-settings] agent: its last compile before the stop fit at contextBudgetTokens=60000, but whether it fits now can't be checked (${refused}); ${kept}`], + [{ contextBudgetTokens: 60_000 }, `[agent-settings] agent: ${kept}`], + ]; + for (const [record, line] of cases) { await bootRaised(record, (booted, lines) => { assert.deepEqual(budgets(booted), { target: 60_000, effective: 100_000, transition: 'stable' }, JSON.stringify(record)); - // One line, whether or not the record carried a reached budget. - assert.deepEqual(lines.filter((l) => l.includes('[agent-settings]')), [ - '[agent-settings] agent: contextBudgetTokens=60000 (contextBudgetTokens must be a safe integer greater than max response tokens (70000)) ' + - "can't be applied now, so it stays set but unapplied: contextBudgetTokens is 60000, and compiles plan at 100000", - ], JSON.stringify(record)); + assert.deepEqual(lines.filter((l) => l.includes('[agent-settings]')), [line], JSON.stringify(record)); + }); + } + }); + + it('a reached budget applies at once on its own, though a tail beside it can\'t be applied', async () => { + await run(async ({ framework, restart }) => { + framework().updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); + framework().updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(framework()); + assert.deepEqual(persisted(framework()), { tailTokens: 20_000, contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + // The strategy refuses a live tail now; it takes a budget as before. + const proto = ContextManager.prototype as unknown as { updateHotContextSettings: (update: { tailTokens?: number }) => unknown }; + const original = proto.updateHotContextSettings; + proto.updateHotContextSettings = function (this: unknown, update: { tailTokens?: number }) { + if (update.tailTokens !== undefined) throw new Error('no live tail here'); + return original.call(this, update); + }; + const log = quietErrors(); + let restarted: AgentFramework; + try { + restarted = await restart(); + } finally { + proto.updateHotContextSettings = original; + log.restore(); + } + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 60_000, transition: 'stable' }, 'the budget applied at once'); + assert.deepEqual(log.lines.filter((l) => l.includes('[agent-settings]')), [ + "[agent-settings] agent: tailTokens=20000 (no live tail here) can't be applied now, so it stays set but unapplied: " + + 'contextBudgetTokens is 60000, and compiles plan at 60000', + ]); + }); + }); + + it("on a strategy without live settings, a tail and a lower budget stay set but unapplied, a later write keeps them, and a reset clears the tail", async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-settings-no-hot-')); + // The context manager's default strategy has no live settings. + const config = () => ({ + storePath: join(dir, 'store'), membrane, + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', contextBudgetTokens: 100_000, maxTokens: 10_000 }], + modules: [], + }); + let framework = await AgentFramework.create(config()); + try { + writeRecord(framework, { contextBudgetTokens: 60_000, tailTokens: 20_000 }); + await framework.stop(); + const log = quietErrors(); + try { + framework = await AgentFramework.create(config()); + } finally { + log.restore(); + } + assert.deepEqual(framework.getAgentRuntimeSettings('agent'), { + contextBudgetTokens: 60_000, + effectiveContextBudgetTokens: 100_000, + sameRoundThinkTextPolicy: 'public', + sameRoundThinkTextPolicySource: 'compatibility_default', + transition: 'stable', }); + assert.deepEqual(log.lines.filter((l) => l.includes('[agent-settings]')), [ + '[agent-settings] agent: tailTokens=20000 (The active context strategy does not support live tail/transition settings), ' + + 'contextBudgetTokens=60000 (The active context strategy cannot prepare a smaller window live) ' + + "can't be applied now, so they stay set but unapplied: contextBudgetTokens is 60000, and compiles plan at 100000", + ]); + framework.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'private' }); + assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000, tailTokens: 20_000, sameRoundThinkTextPolicy: 'private' }); + framework.resetAgentRuntimeSettings('agent', ['tailTokens']); + assert.deepEqual(persisted(framework), { contextBudgetTokens: 60_000, sameRoundThinkTextPolicy: 'private' }, 'the reset clears the kept tail'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); } }); From 94089912e03ab4e921aba3979dd349c49381c9b3 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 11:08:47 -0400 Subject: [PATCH 10/16] fix(settings): of two compiles at one budget, the later-started one decides From an internal review of 9a50c8f..abeafb1. The proof. A refusal skipped the listener only when a proof of the same budget stood at the live budget's epoch, so it still withdrew a newer fit's marker in two states: once the live budget had moved away after that fit (an unpersisted change), and within one epoch, where epochs can't tell which compile started first. Each compile at the live budget now takes a start number, and the agent keeps, per budget, the start of the latest-started compile whose outcome it noted there. A compile that started before it planned an older context, so its outcome is ignored, for the record and the agent's proof alike, whichever finishes last. This replaces the live-epoch guard. Text. The boot line says the record says a compile fit at the budget, not that the last compile before the stop did: a marker survives a run that compiled only at other budgets. The fallback paragraph is scoped to a budget below the recipe's, and says its paced decrease is the one a boot without the marker makes, the default pace included. A budget at or above the recipe's applies at once with or without the marker; a lower one is kept on a strategy that can't prepare a smaller window only when it's restored paced; the boot writes its line only when there's something to report; and a failed record is told again at the next compile at that budget. Tests (agent-runtime-settings, 49): the start order, with an earlier refusal after a later fit in one epoch, after the budget went away and came back, and after it then moved away unpersisted, and with an earlier fit after a later refusal; a malformed persisted pace beside a valid tail; and the boot line's new wording. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 10 +- src/agent.ts | 36 ++++--- src/framework.ts | 41 +++---- src/types/agent.ts | 3 +- test/agent-runtime-settings.test.ts | 100 ++++++++++++++---- 5 files changed, 129 insertions(+), 61 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 05351fd6b..47d905710 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,8 +1,8 @@ - A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. - - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a newer compile has fit at that same budget since. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. - - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. A budget at or above the recipe's is applied at once either way, as an increase always is. - - **If a compile at a budget the boot applied at once is refused,** the agent restores it paced from the recipe's budget and compiles again, or, if it can't be restored paced, compiles at the recipe's budget with the budget kept set. That fallback lasts until the budget is next written (an update that carries a budget, whatever its value, or a reset of it) or a compile at it fits; a tail, pace or think-text policy change leaves it in place. - - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. One log line says why a reached budget wasn't applied at once, what couldn't be applied, and the budget that's set beside the one compiles plan at. - - **Smaller:** a patch can't set the field; a malformed persisted number (the marker, a budget, a tail or a pace) is ignored with a log line naming its kind; a record holding nothing else restores nothing. If reading the record fails at boot, the agent logs it and boots at the recipe's; a store that can't be read at all still fails agent creation at an earlier read of the same state, as before. A record that can't be read or written when a compile tells it is logged, and told again at the next compile. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already shown whether it fits: of two compiles at one budget, the one that started later planned the newer context, so its outcome stands. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. + - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. A budget at or above the recipe's is applied at once with or without the marker, as an increase always is. + - **If a compile at a budget below the recipe's that the boot applied at once is refused,** the agent makes the paced decrease a boot without the marker makes, from the recipe's budget: the budget alone, with the default pace when the strategy has none. Then it compiles again. If the budget can't be restored paced, the agent compiles at the recipe's budget with the budget kept set. That fallback lasts until the budget is next written (an update that carries a budget, whatever its value, or a reset of it) or a compile at it fits; a tail, pace or think-text policy change leaves it in place. + - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one restored paced on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. When a marked budget isn't applied at once or a setting can't be applied, one log line says why, what couldn't be applied, and the budget that's set beside the one compiles plan at. + - **Smaller:** a patch can't set the field; a malformed persisted number (the marker, a budget, a tail or a pace) is ignored with a log line naming its kind; a record holding nothing else restores nothing. If reading the record fails at boot, the agent logs it and boots at the recipe's; a store that can't be read at all still fails agent creation at an earlier read of the same state, as before. A record that can't be read or written when a compile tells it is logged, and told again at the next compile at that budget. - Outside a descent, `previewAgentRuntimeSettings` and the budget preflight plan a patch at the reach its next compile plans at: the pace for a paced decrease, and the configured reach otherwise, so an immediate change, and a pace with no descent, preview at the configured reach. A preview taken during a descent plans under that descent's prepared window, and at its live pace when the strategy carries one, which a preview can't change. - The runtime settings snapshot (`getAgentRuntimeSettings`, `agent_settings` `get`) reports `effectiveContextBudgetTokens`, the budget compiles plan at now, beside `contextBudgetTokens`, the budget that's set. They differ while a lower budget converges, when compiles still plan at the budget the descent started from, and while a budget the agent couldn't apply is kept, when they plan at the budget it runs at. Code that builds an `AgentRuntimeSettingsSnapshot` itself, such as a test double, adds the field. diff --git a/src/agent.ts b/src/agent.ts index 8ea686da1..be4b812a7 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -157,6 +157,14 @@ export class Agent { private descentEpoch = 0; /** The live budget the last compile planned at, whether it fit, and the live budget's epoch then. */ private lastCompiledBudget?: { budget: number; fit: boolean; epoch: number }; + /** Counts compiles at the live budget as they start. */ + private compilesStarted = 0; + /** + * For each budget, when the latest-started compile whose outcome there is + * noted started. Of two compiles at one budget, the later-started one + * planned the newer context, so its outcome stands, whichever finishes last. + */ + private readonly latestNotedStart = new Map(); /** The last outcome the listener recorded; a failed record is told again, and so is any after a change. */ private recordedCompiledBudget?: { budget: number; fit: boolean; epoch: number }; /** @@ -372,12 +380,17 @@ export class Agent { } /** - * Note what a compile at `budget`, planned at the live budget's `epoch`, - * showed. It speaks for the live budget only if that hasn't changed since; - * the listener is told either way, so a refusal withdraws the record's - * marker for the budget it planned at whatever changed meanwhile. + * Note what a compile at `budget` showed, planned at the live budget's + * `epoch` and started as the `started`th. A compile that started before + * one already noted at that budget planned an older context, and is + * ignored. Otherwise it speaks for the live budget only if that hasn't + * changed since, and the listener is told either way, so a refusal + * withdraws the record's marker for the budget it planned at whatever + * changed meanwhile. */ - private noteCompiledBudget(budget: number, fit: boolean, epoch: number): void { + private noteCompiledBudget(budget: number, fit: boolean, epoch: number, started: number): void { + if (started < (this.latestNotedStart.get(budget) ?? 0)) return; + this.latestNotedStart.set(budget, started); if (epoch === this.liveBudgetEpoch) this.lastCompiledBudget = { budget, fit, epoch }; const recorded = this.recordedCompiledBudget; if (recorded?.budget === budget && recorded.fit === fit && recorded.epoch === epoch) return; @@ -393,6 +406,7 @@ export class Agent { const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; const epoch = this.liveBudgetEpoch; const descent = this.descentEpoch; + const started = ++this.compilesStarted; let result: CompileResult; try { result = await compile(); @@ -400,11 +414,9 @@ export class Agent { if ((error as { name?: unknown } | null)?.name !== 'OverBudgetError') throw error; // A refusal shows the budget it planned at doesn't fit, whatever // changed while it ran: the record's marker for it is withdrawn, and - // nothing is credited. Only a newer compile that fit at that same - // budget, proven at the live budget's epoch since, outweighs it. - if (epoch === this.liveBudgetEpoch || this.getProvenContextBudgetTokens() !== live) { - this.noteCompiledBudget(live, false, epoch); - } + // nothing is credited, unless a compile that started later has shown + // what that budget does. + this.noteCompiledBudget(live, false, epoch, started); // A budget a boot restore applied at once, refused at its first test, // before anything wrote the budget: restore it paced instead, as the // boot would have without its proof, and compile again from the @@ -439,8 +451,8 @@ export class Agent { const sameLive = this.liveBudgetEpoch === epoch; const sameDescent = this.descentEpoch === descent; const settled = this.settleRuntimeSettingsTransition(); - if (sameLive && settled && sameDescent) this.noteCompiledBudget(this.contextBudgetTokens!, true, this.liveBudgetEpoch); - else if (sameLive && !settled) this.noteCompiledBudget(live, true, epoch); + if (sameLive && settled && sameDescent) this.noteCompiledBudget(this.contextBudgetTokens!, true, this.liveBudgetEpoch, started); + else if (sameLive && !settled) this.noteCompiledBudget(live, true, epoch, started); return result; } diff --git a/src/framework.ts b/src/framework.ts index 214360d40..5a5df1ad4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4073,26 +4073,27 @@ export class AgentFramework { * this (validatePersistedAgentRuntimeSettings), and a valid one always * applies. A tail, pace or budget that can't be applied now (a tail or pace * on a strategy without live settings, a budget no longer above maxTokens, - * a lower one on a strategy that can't prepare a smaller window) is kept - * set but unapplied (Agent.keepRuntimeSettings), so a later persisting - * write keeps it in the record. + * a lower one restored paced on a strategy that can't prepare a smaller + * window) is kept set but unapplied (Agent.keepRuntimeSettings), so a later + * persisting write keeps it in the record. * * A budget below the recipe's restores as a paced descent from it, unless - * the record says the last compile before the stop fit at it - * (`contextBudgetReachedTokens`) and the preflight says it fits now: then - * it is applied at once. The preflight plans the stored context as the - * context manager will, with the restored tail and pace, so it sees what a - * restart can change there: a new maxTokens (the response reserve) or - * strategy configuration. It also sees a context that grew while the agent - * ran at another budget after the record was written, through a persist: - * false change. The system prompt and tools are outside the context - * manager's budget, so they don't enter it (context-manager #162). If a - * compile at a budget applied at once is refused, the agent restores it - * paced then (Agent.restoreRuntimeSettings). A budget at or above the - * recipe's is applied at once either way, as an increase always is. + * the record carries its marker (`contextBudgetReachedTokens`, written when + * a compile fit at it) and the preflight says it fits now: then it is + * applied at once. The preflight plans the stored context as the context + * manager will, with the restored tail and pace, so it sees what a restart + * can change there: a new maxTokens (the response reserve) or strategy + * configuration. It also sees a context that grew while the agent ran at + * another budget after the record was written, through a persist: false + * change. The system prompt and tools are outside the context manager's + * budget, so they don't enter it (context-manager #162). If a compile at a + * budget applied at once this way is refused, the agent restores it paced + * then (Agent.restoreRuntimeSettings). A budget at or above the recipe's + * is applied at once with or without the marker, as an increase always is. * - * One line says why a reached budget wasn't applied at once, what couldn't - * be applied, and the budget set beside the one compiles plan at. + * When a marked budget isn't applied at once or a setting can't be + * applied, one line says why, what couldn't be applied, and the budget set + * beside the one compiles plan at. */ private restorePersistedRuntimeSettings( agentName: string, @@ -4137,7 +4138,7 @@ export class AgentFramework { why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`; } } - if (why !== undefined) atOnce = `its last compile before the stop fit at contextBudgetTokens=${budget}, but ${why}`; + if (why !== undefined) atOnce = `its record says a compile fit at contextBudgetTokens=${budget}, but ${why}`; } const budgetKept = budget !== undefined && !appliedAtOnce && !restore({ contextBudgetTokens: budget }); if (atOnce === undefined && unapplied.length === 0) return; @@ -4178,7 +4179,7 @@ export class AgentFramework { * budget. Nothing else in the record changes, and a budget other than the * record's (one applied with `persist: false`, or a descent's starting * budget) writes nothing. A record that can't be read, or written, is - * logged, and the agent tells it again at its next compile. + * logged, and the next compile at that budget tells it again. */ private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean { try { @@ -4189,7 +4190,7 @@ export class AgentFramework { this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings); return true; } catch (error) { - // The agent tells it again at its next compile. + // The next compile at this budget tells it again. console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error); return false; } diff --git a/src/types/agent.ts b/src/types/agent.ts index 5367a75fa..3dadc8207 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -252,7 +252,8 @@ export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { * agent's live budget fits at it, with nothing about the budget changed * while it ran (a paced descent's settle included): the store fit that * budget. A compile the context manager refuses at it as over budget - * withdraws it, unless a newer compile has fit at that same budget since. + * withdraws it, unless a compile at it that started later has shown + * whether it fits. * When it equals `contextBudgetTokens`, a restore applies that budget at * once, rather than pacing a descent again from the recipe's budget * (context-manager #162), if a preview at boot says it still fits; if not, diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 887255843..fa0cfe6b1 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -278,7 +278,7 @@ describe('a reached budget across a restart', () => { } // One line, written once the restore is made, saying why and what it made. assert.deepEqual(log.lines.filter((line) => line.includes('[agent-settings]')), [ - '[agent-settings] agent: its last compile before the stop fit at contextBudgetTokens=60000, but the folded floor renders ' + + '[agent-settings] agent: its record says a compile fit at contextBudgetTokens=60000, but the folded floor renders ' + '70000 tokens against it now; restored it paced instead: contextBudgetTokens is 60000, and compiles plan at 100000 (converging)', ]); }); @@ -372,6 +372,22 @@ describe('a reached budget across a restart', () => { restarted.updateAgentRuntimeSettings('agent', { tailTokens: 20_000 }); assert.deepEqual(persisted(restarted), { transitionPaceTokens: 4_000, tailTokens: 20_000 }, 'a later write leaves them out'); }); + await run(async ({ framework, restart }) => { + writeRecord(framework(), { transitionPaceTokens: 0, tailTokens: 20_000 }); + const log = quietErrors(); + let restarted: AgentFramework; + try { + restarted = await restart(); + } finally { + log.restore(); + } + assert.equal(restarted.getAgentRuntimeSettings('agent').tailTokens, 20_000); + assert.deepEqual(log.lines.filter((l) => l.includes('[agent-settings]')), [ + '[agent-settings] ignoring an invalid persisted transitionPaceTokens for agent "agent": a number that isn\'t a positive safe integer', + ]); + restarted.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'private' }); + assert.deepEqual(persisted(restarted), { tailTokens: 20_000, sameRoundThinkTextPolicy: 'private' }, 'a later write leaves the pace out'); + }); }); it('a patch cannot set the field', async () => { @@ -495,35 +511,73 @@ describe('a reached budget across a restart', () => { }); }); - it('a refusal planned before the budget came back to it leaves the marker a newer compile wrote there', async () => { + /** + * Two compiles at 60k, the first held until `between` has run: `firstFits` + * says how the first ends, and the second, started after it, ends the + * other way. + */ + async function twoCompilesAt60k(f: AgentFramework, firstFits: boolean, between: () => Promise) { + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let calls = 0; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + const isFirst = ++calls === 1; + if (isFirst) await held; + if (isFirst !== firstFits) throw refusal; + return original.apply(this, args); + }, async () => { + const first = compile(f); + await between(); + release(); + if (firstFits) await first; + else await assert.rejects(first, /over budget/); + }); + } + + it('of two compiles at one budget, the one that started later decides, whichever finishes last', async () => { + // A refusal planned before a fit at the same budget, with nothing changed between them. await run(async ({ framework }) => { const f = framework(); f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); - const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; - const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); - let release!: () => void; - const held = new Promise((resolve) => { release = resolve; }); - let calls = 0; - // The first compile plans at 60k and is refused only after the budget - // went to 80k and came back to 60k, and a second compile fit at 60k. - await withPatched('compile', async function (this: unknown, ...args: unknown[]) { - if (++calls === 1) { - await held; - throw refusal; - } - return original.apply(this, args); - }, async () => { - const first = compile(f); - f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); - f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await twoCompilesAt60k(f, false, async () => { await compile(f); assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the second compile fit at 60k'); - release(); - await assert.rejects(first, /over budget/); }); - assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the newer fit outweighs the older refusal'); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the later-started fit stands'); assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), 60_000); }); + // A refusal planned before the budget went to 80k and came back to 60k, + // where a second compile fit; then, in a second run, the budget moves to + // 70k unpersisted before the refusal arrives. + for (const moveAway of [false, true]) { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await twoCompilesAt60k(f, false, async () => { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the second compile fit at 60k'); + if (moveAway) f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 70_000 }, { persist: false }); + }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the later-started fit stands'); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), moveAway ? undefined : 60_000); + }); + } + // A fit planned before a refusal at the same budget. + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + await twoCompilesAt60k(f, true, async () => { + await assert.rejects(compile(f), /over budget/); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'the second compile\'s refusal withdrew the marker'); + }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'the later-started refusal stands'); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), undefined); + }); }); it('of two compiles in flight across a settle, the one that finishes later proves nothing it planned before it', async () => { @@ -875,7 +929,7 @@ describe('a reached budget across a restart', () => { const cases: Array<[Record, string]> = [ // One line, which keeps why a reached budget wasn't applied at once. [{ contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, - `[agent-settings] agent: its last compile before the stop fit at contextBudgetTokens=60000, but whether it fits now can't be checked (${refused}); ${kept}`], + `[agent-settings] agent: its record says a compile fit at contextBudgetTokens=60000, but whether it fits now can't be checked (${refused}); ${kept}`], [{ contextBudgetTokens: 60_000 }, `[agent-settings] agent: ${kept}`], ]; for (const [record, line] of cases) { From adbf6fd66bf0d90f2591a84cf5283b9048218836 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 11:40:43 -0400 Subject: [PATCH 11/16] fix(settings): every compile that ends takes its place in the start order, which keeps nothing once no compile runs From an internal review of abeafb1..9408991, and Ada-1017's bound on the start order (room-203 #86928). The start order saw only the outcomes it noted, so a later-started compile whose fit isn't credited, because the live budget changed while it ran, didn't outrank an earlier-started refusal at the same budget, which then withdrew the marker. Every compile at the live budget that ends now takes its place in the start order at the budget it planned at, credited or not (endsLatestAt), and only what's credited is noted. A settle's credit takes its place at the target too, so a compile at the target still running from before the descent can't withdraw it. The map grew by one entry per distinct budget the agent ever compiled at. An entry can only turn away a compile that started before it and is still running, so compileAtLiveBudget now holds each compile's start while it runs (compilesRunning) and, as each ends, drops the entries older than every running compile: the map is empty whenever no compile runs. The old body is compileAtLiveBudgetNoting, unchanged but for the order. Text. The later-started compile planned at the newer store, and a compile with injections counts like one without: the marker records whether the agent's compiles fit as it runs them, and the boot's preflight checks the stored context alone. The restore's doc names where the fallback is armed and run; the marker's doc scopes "the restore paces" to a budget below the recipe's; persistedRuntimeSettingsOf's doc keys the marker to the proof, which arming or cancelling a descent leaves as it was. Tests (agent-runtime-settings, 52): a later-started fit that nothing credits outranks an earlier-started refusal; a settle's credit outranks an earlier-started refusal at its target; the start order keeps nothing through three descents, and keeps one start only while an earlier-started compile runs. The two-compile helper releases its held compile in a finally. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 2 +- src/agent.ts | 70 ++++++++++---- src/framework.ts | 11 ++- src/types/agent.ts | 16 ++-- test/agent-runtime-settings.test.ts | 94 ++++++++++++++++++- 5 files changed, 157 insertions(+), 36 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 47d905710..9448d644a 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,5 +1,5 @@ - A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. - - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already shown whether it fits: of two compiles at one budget, the one that started later planned the newer context, so its outcome stands. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. A compile with injections counts like one without, so the marker records whether the agent's compiles fit as it runs them, and the boot's preflight checks the stored context alone. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. A budget at or above the recipe's is applied at once with or without the marker, as an increase always is. - **If a compile at a budget below the recipe's that the boot applied at once is refused,** the agent makes the paced decrease a boot without the marker makes, from the recipe's budget: the budget alone, with the default pace when the strategy has none. Then it compiles again. If the budget can't be restored paced, the agent compiles at the recipe's budget with the budget kept set. That fallback lasts until the budget is next written (an update that carries a budget, whatever its value, or a reset of it) or a compile at it fits; a tail, pace or think-text policy change leaves it in place. - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one restored paced on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. When a marked budget isn't applied at once or a setting can't be applied, one log line says why, what couldn't be applied, and the budget that's set beside the one compiles plan at. diff --git a/src/agent.ts b/src/agent.ts index be4b812a7..c8271ed9b 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -159,12 +159,18 @@ export class Agent { private lastCompiledBudget?: { budget: number; fit: boolean; epoch: number }; /** Counts compiles at the live budget as they start. */ private compilesStarted = 0; + /** The start numbers of the compiles at the live budget still running. */ + private readonly compilesRunning = new Set(); /** - * For each budget, when the latest-started compile whose outcome there is - * noted started. Of two compiles at one budget, the later-started one - * planned the newer context, so its outcome stands, whichever finishes last. + * For each budget, the start of the latest-started compile that has ended + * there, whether or not what it showed was credited. Of two compiles at one + * budget, the later-started one planned at the newer store, so an + * earlier-started one that ends after it is ignored, with or without + * injections. An entry is kept only while a compile that started before it + * is still running, the only kind it can turn away, so the map is empty + * whenever no compile runs. */ - private readonly latestNotedStart = new Map(); + private readonly latestEndedStart = new Map(); /** The last outcome the listener recorded; a failed record is told again, and so is any after a change. */ private recordedCompiledBudget?: { budget: number; fit: boolean; epoch: number }; /** @@ -380,17 +386,23 @@ export class Agent { } /** - * Note what a compile at `budget` showed, planned at the live budget's - * `epoch` and started as the `started`th. A compile that started before - * one already noted at that budget planned an older context, and is - * ignored. Otherwise it speaks for the live budget only if that hasn't - * changed since, and the listener is told either way, so a refusal - * withdraws the record's marker for the budget it planned at whatever - * changed meanwhile. + * Whether the compile that started as the `started`th is the latest to end + * at `budget`, taking that place if so: one that started before a compile + * that has already ended there is ignored, whichever finishes last. */ - private noteCompiledBudget(budget: number, fit: boolean, epoch: number, started: number): void { - if (started < (this.latestNotedStart.get(budget) ?? 0)) return; - this.latestNotedStart.set(budget, started); + private endsLatestAt(budget: number, started: number): boolean { + if (started < (this.latestEndedStart.get(budget) ?? 0)) return false; + this.latestEndedStart.set(budget, started); + return true; + } + + /** + * Note what a compile at `budget`, planned at the live budget's `epoch`, + * showed. It speaks for the live budget only if that hasn't changed since; + * the listener is told either way, so a refusal withdraws the record's + * marker for the budget it planned at whatever changed meanwhile. + */ + private noteCompiledBudget(budget: number, fit: boolean, epoch: number): void { if (epoch === this.liveBudgetEpoch) this.lastCompiledBudget = { budget, fit, epoch }; const recorded = this.recordedCompiledBudget; if (recorded?.budget === budget && recorded.fit === fit && recorded.epoch === epoch) return; @@ -401,12 +413,26 @@ export class Agent { /** Compile at the live budget (no explicit one), noting whether it fit. */ private async compileAtLiveBudget(compile: () => Promise): Promise { + const started = ++this.compilesStarted; + this.compilesRunning.add(started); + try { + return await this.compileAtLiveBudgetNoting(compile, started); + } finally { + this.compilesRunning.delete(started); + const earliest = Math.min(...this.compilesRunning); + for (const [budget, start] of this.latestEndedStart) { + if (start < earliest) this.latestEndedStart.delete(budget); + } + } + } + + /** The compile at the live budget that started as the `started`th, and what it showed. */ + private async compileAtLiveBudgetNoting(compile: () => Promise, started: number): Promise { // The budget this compile plans at, and the descent under way when it // started: a change of either while it runs is no part of what it shows. const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS; const epoch = this.liveBudgetEpoch; const descent = this.descentEpoch; - const started = ++this.compilesStarted; let result: CompileResult; try { result = await compile(); @@ -414,9 +440,9 @@ export class Agent { if ((error as { name?: unknown } | null)?.name !== 'OverBudgetError') throw error; // A refusal shows the budget it planned at doesn't fit, whatever // changed while it ran: the record's marker for it is withdrawn, and - // nothing is credited, unless a compile that started later has shown - // what that budget does. - this.noteCompiledBudget(live, false, epoch, started); + // nothing is credited, unless a compile that started later has already + // ended at that budget. + if (this.endsLatestAt(live, started)) this.noteCompiledBudget(live, false, epoch); // A budget a boot restore applied at once, refused at its first test, // before anything wrote the budget: restore it paced instead, as the // boot would have without its proof, and compile again from the @@ -448,11 +474,15 @@ export class Agent { // the compile proves the budget it planned at. Either only if the live // budget didn't change while it ran; a settle of a descent armed while it // ran proves nothing (the next compile at the target does). + // A compile that ends here takes its place in the start order at the + // budget it planned at, credited or not. + const latest = this.endsLatestAt(live, started); const sameLive = this.liveBudgetEpoch === epoch; const sameDescent = this.descentEpoch === descent; const settled = this.settleRuntimeSettingsTransition(); - if (sameLive && settled && sameDescent) this.noteCompiledBudget(this.contextBudgetTokens!, true, this.liveBudgetEpoch, started); - else if (sameLive && !settled) this.noteCompiledBudget(live, true, epoch, started); + if (sameLive && settled && sameDescent && this.endsLatestAt(this.contextBudgetTokens!, started)) { + this.noteCompiledBudget(this.contextBudgetTokens!, true, this.liveBudgetEpoch); + } else if (latest && sameLive && !settled) this.noteCompiledBudget(live, true, epoch); return result; } diff --git a/src/framework.ts b/src/framework.ts index 5a5df1ad4..2ffd7bf32 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4088,8 +4088,9 @@ export class AgentFramework { * change. The system prompt and tools are outside the context manager's * budget, so they don't enter it (context-manager #162). If a compile at a * budget applied at once this way is refused, the agent restores it paced - * then (Agent.restoreRuntimeSettings). A budget at or above the recipe's - * is applied at once with or without the marker, as an increase always is. + * then (armed by Agent.restoreRuntimeSettings, run in + * Agent.compileAtLiveBudget). A budget at or above the recipe's is applied + * at once with or without the marker, as an increase always is. * * When a marked budget isn't applied at once or a setting can't be * applied, one line says why, what couldn't be applied, and the budget set @@ -4158,10 +4159,10 @@ export class AgentFramework { /** * What persists for an agent: its overrides, and `contextBudgetReachedTokens` - * when the agent's last compile fit at the overridden budget and neither - * the budget nor the descent has changed since (context-manager #162). A + * when the agent's proof stands for the overridden budget: a compile fit at + * it, and the live budget hasn't changed since (context-manager #162). A * change of budget, immediate or not, carries none until a compile at it - * fits. + * fits; arming or cancelling a descent leaves the proof as it was. */ private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides { const overrides = agent.getRuntimeSettingsOverrides(); diff --git a/src/types/agent.ts b/src/types/agent.ts index 3dadc8207..dce7b9a81 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -252,18 +252,18 @@ export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { * agent's live budget fits at it, with nothing about the budget changed * while it ran (a paced descent's settle included): the store fit that * budget. A compile the context manager refuses at it as over budget - * withdraws it, unless a compile at it that started later has shown - * whether it fits. + * withdraws it, unless a compile at it that started later has already + * ended. * When it equals `contextBudgetTokens`, a restore applies that budget at * once, rather than pacing a descent again from the recipe's budget * (context-manager #162), if a preview at boot says it still fits; if not, - * the restore paces, and if a compile at a budget below the recipe's that + * a budget below the recipe's restores paced, and if a compile at one that * the boot applied at once is refused, the agent restores it paced then. - * Since the store fit it at the last compile, the planner can fold to it at - * once even if its carried frontier was lost. Any other value is stale (the - * budget changed since, or an earlier release carried the field forward) - * and the restore paces, as it does without the field. Never part of a - * patch. + * Since the store fit it when the field was written, the planner can fold + * to it at once even if its carried frontier was lost. Any other value is + * stale (the budget changed since, or an earlier release carried the field + * forward), and a budget below the recipe's restores paced, as it does + * without the field. Never part of a patch. */ contextBudgetReachedTokens?: number; }; diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index fa0cfe6b1..59b046015 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -529,8 +529,11 @@ describe('a reached budget across a restart', () => { return original.apply(this, args); }, async () => { const first = compile(f); - await between(); - release(); + try { + await between(); + } finally { + release(); + } if (firstFits) await first; else await assert.rejects(first, /over budget/); }); @@ -580,6 +583,93 @@ describe('a reached budget across a restart', () => { }); }); + it("a later-started compile whose fit isn't credited still outranks an earlier-started refusal at its budget", async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + let releaseFirst!: () => void; + let releaseSecond!: () => void; + const held = [ + new Promise((resolve) => { releaseFirst = resolve; }), + new Promise((resolve) => { releaseSecond = resolve; }), + ]; + let calls = 0; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + const n = calls++; + await held[n]; + if (n === 0) throw refusal; + return original.apply(this, args); + }, async () => { + // Both plan at 60k. The second fits while the budget moves to 70k + // unpersisted, so nothing credits it; the first is refused after it. + const first = compile(f); + const second = compile(f); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 70_000 }, { persist: false }); + releaseSecond(); + await second; + releaseFirst(); + await assert.rejects(first, /over budget/); + }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the earlier-started refusal is ignored'); + }); + }); + + it("a settle's credit outranks an earlier-started refusal at its target", async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000, immediate: true }); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let calls = 0; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + if (++calls === 1) { + await held; + throw refusal; + } + return original.apply(this, args); + }, async () => { + // The first plans at 60k and is refused only after the budget went to + // 80k and paced back down, and a compile at 80k settled that descent. + const first = compile(f); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.equal(budgets(f).transition, 'converging'); + await compile(f); + assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the settle credited 60k'); + release(); + await assert.rejects(first, /over budget/); + }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the settle outranks the earlier-started refusal'); + }); + }); + + it('the start order keeps nothing once no compile runs, through descents and compiles at once', async () => { + await run(async ({ framework }) => { + const f = framework(); + const noted = () => (f.getAgent('agent') as unknown as { latestEndedStart: Map }).latestEndedStart.size; + // Descents to three budgets, each settled by a compile. + for (const budget of [90_000, 75_000, 60_000]) { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: budget }); + await compile(f); + assert.deepEqual(budgets(f), { target: budget, effective: budget, transition: 'stable' }); + assert.equal(noted(), 0, `after the descent to ${budget}`); + } + // Two at once: the later-started one's start is kept while the earlier one runs. + await twoCompilesAt60k(f, false, async () => { + await compile(f); + assert.equal(noted(), 1, 'kept while an earlier-started compile runs'); + }); + assert.equal(noted(), 0, 'and dropped once it ends'); + }); + }); + it('of two compiles in flight across a settle, the one that finishes later proves nothing it planned before it', async () => { await run(async ({ framework }) => { const f = framework(); From a7aad2cf87f4eedf451792390525c15aa15035b9 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 11:58:10 -0400 Subject: [PATCH 12/16] fix(settings): a settle credits its target only from the latest compile at its budget; the fallback runs once, and says so From an internal review of 9408991..adbf6fd. A settle credited its target from its compile's fit even when a later-started compile at the budget it planned at had already ended. A refusal there, at a larger budget on a newer store, says the target doesn't fit either, so the settle now credits only from the latest compile to end at the budget it planned at. The settle itself still happens: that's the strategy's. endsLatestAt is claimLatestAt: it claims the place and says whether it got it. Only a compile that ends with a fit or a refusal takes a place; one that throws anything else shows nothing about the budget. The fallback for a budget the boot applied at once runs once: the first refusal, before a compile at the budget has fit and before the budget is written. A compile refused after it reports its own refusal, including the fallback's own compile at the recipe's budget, which is what clearing the flag before compiling again keeps from falling back forever. The changelog says so, and a test pins it. Text. The restore's doc names compileAtLiveBudgetNoting, where the fallback runs; persistedRuntimeSettingsOf's doc names the boot's proof beside a compile's fit; the changelog's "every restore of a lower budget paced" is scoped to strategies that can prepare a smaller window. Tests (agent-runtime-settings, 54): a settle credits nothing when a later-started compile was refused at the budget it planned at; when the recipe's budget is refused too, the fallback's compile reports it; the start order's test names the budget it keeps. The two-compile helper and the tests that hold compiles leave no refusal unhandled when an assertion fails. Co-Authored-By: Claude Opus 5.5 --- .../budget-reached-across-restart.fixed.md | 6 +- src/agent.ts | 23 +++-- src/framework.ts | 7 +- test/agent-runtime-settings.test.ts | 97 +++++++++++++++---- 4 files changed, 100 insertions(+), 33 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 9448d644a..2012dfc3c 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,7 +1,7 @@ -- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. - - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. A compile with injections counts like one without, so the marker records whether the agent's compiles fit as it runs them, and the boot's preflight checks the stored context alone. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. +- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, on a strategy that can prepare a smaller window, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended with a fit or a refusal: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. A compile with injections counts like one without, so the marker records whether the agent's compiles fit as it runs them, and the boot's preflight checks the stored context alone. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. A budget at or above the recipe's is applied at once with or without the marker, as an increase always is. - - **If a compile at a budget below the recipe's that the boot applied at once is refused,** the agent makes the paced decrease a boot without the marker makes, from the recipe's budget: the budget alone, with the default pace when the strategy has none. Then it compiles again. If the budget can't be restored paced, the agent compiles at the recipe's budget with the budget kept set. That fallback lasts until the budget is next written (an update that carries a budget, whatever its value, or a reset of it) or a compile at it fits; a tail, pace or think-text policy change leaves it in place. + - **If a compile at a budget below the recipe's that the boot applied at once is refused** before any compile at it has fit and before the budget is next written (an update that carries a budget, whatever its value, or a reset of it), the refusal withdraws the marker, and the agent makes the paced decrease a boot without the marker makes, from the recipe's budget: the budget alone, with the default pace when the strategy has none. Then it compiles again. If the budget can't be restored paced, the agent compiles at the recipe's budget with the budget kept set. This fallback runs once: a compile refused after it, including one already in flight at the boot's budget or the fallback's own compile at the recipe's, reports its own refusal, as does one the start order ignores. A tail, pace or think-text policy change leaves it in place. - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one restored paced on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. When a marked budget isn't applied at once or a setting can't be applied, one log line says why, what couldn't be applied, and the budget that's set beside the one compiles plan at. - **Smaller:** a patch can't set the field; a malformed persisted number (the marker, a budget, a tail or a pace) is ignored with a log line naming its kind; a record holding nothing else restores nothing. If reading the record fails at boot, the agent logs it and boots at the recipe's; a store that can't be read at all still fails agent creation at an earlier read of the same state, as before. A record that can't be read or written when a compile tells it is logged, and told again at the next compile at that budget. - Outside a descent, `previewAgentRuntimeSettings` and the budget preflight plan a patch at the reach its next compile plans at: the pace for a paced decrease, and the configured reach otherwise, so an immediate change, and a pace with no descent, preview at the configured reach. A preview taken during a descent plans under that descent's prepared window, and at its live pace when the strategy carries one, which a preview can't change. diff --git a/src/agent.ts b/src/agent.ts index c8271ed9b..149214095 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -163,7 +163,8 @@ export class Agent { private readonly compilesRunning = new Set(); /** * For each budget, the start of the latest-started compile that has ended - * there, whether or not what it showed was credited. Of two compiles at one + * there with a fit or a refusal, whether or not what it showed was + * credited. Of two compiles at one * budget, the later-started one planned at the newer store, so an * earlier-started one that ends after it is ignored, with or without * injections. An entry is kept only while a compile that started before it @@ -386,11 +387,12 @@ export class Agent { } /** - * Whether the compile that started as the `started`th is the latest to end - * at `budget`, taking that place if so: one that started before a compile - * that has already ended there is ignored, whichever finishes last. + * Claim the place of the latest compile to end at `budget` for the one that + * started as the `started`th, and say whether it got it: a compile that + * started before one that has already ended there is ignored, whichever + * finishes last. */ - private endsLatestAt(budget: number, started: number): boolean { + private claimLatestAt(budget: number, started: number): boolean { if (started < (this.latestEndedStart.get(budget) ?? 0)) return false; this.latestEndedStart.set(budget, started); return true; @@ -442,7 +444,7 @@ export class Agent { // changed while it ran: the record's marker for it is withdrawn, and // nothing is credited, unless a compile that started later has already // ended at that budget. - if (this.endsLatestAt(live, started)) this.noteCompiledBudget(live, false, epoch); + if (this.claimLatestAt(live, started)) this.noteCompiledBudget(live, false, epoch); // A budget a boot restore applied at once, refused at its first test, // before anything wrote the budget: restore it paced instead, as the // boot would have without its proof, and compile again from the @@ -474,13 +476,14 @@ export class Agent { // the compile proves the budget it planned at. Either only if the live // budget didn't change while it ran; a settle of a descent armed while it // ran proves nothing (the next compile at the target does). - // A compile that ends here takes its place in the start order at the - // budget it planned at, credited or not. - const latest = this.endsLatestAt(live, started); + // A compile that ends here with a fit takes its place in the start order + // at the budget it planned at, credited or not; a settle credits its + // target only from the latest compile to end there. + const latest = this.claimLatestAt(live, started); const sameLive = this.liveBudgetEpoch === epoch; const sameDescent = this.descentEpoch === descent; const settled = this.settleRuntimeSettingsTransition(); - if (sameLive && settled && sameDescent && this.endsLatestAt(this.contextBudgetTokens!, started)) { + if (latest && sameLive && settled && sameDescent && this.claimLatestAt(this.contextBudgetTokens!, started)) { this.noteCompiledBudget(this.contextBudgetTokens!, true, this.liveBudgetEpoch); } else if (latest && sameLive && !settled) this.noteCompiledBudget(live, true, epoch); return result; diff --git a/src/framework.ts b/src/framework.ts index 2ffd7bf32..c1c7b5857 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4089,8 +4089,8 @@ export class AgentFramework { * budget, so they don't enter it (context-manager #162). If a compile at a * budget applied at once this way is refused, the agent restores it paced * then (armed by Agent.restoreRuntimeSettings, run in - * Agent.compileAtLiveBudget). A budget at or above the recipe's is applied - * at once with or without the marker, as an increase always is. + * Agent.compileAtLiveBudgetNoting). A budget at or above the recipe's is + * applied at once with or without the marker, as an increase always is. * * When a marked budget isn't applied at once or a setting can't be * applied, one line says why, what couldn't be applied, and the budget set @@ -4160,7 +4160,8 @@ export class AgentFramework { /** * What persists for an agent: its overrides, and `contextBudgetReachedTokens` * when the agent's proof stands for the overridden budget: a compile fit at - * it, and the live budget hasn't changed since (context-manager #162). A + * it, or the boot applied it at once on the record's proof, and the live + * budget hasn't changed since (context-manager #162). A * change of budget, immediate or not, carries none until a compile at it * fits; arming or cancelling a descent leaves the proof as it was. */ diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 59b046015..94613ca9f 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -529,6 +529,7 @@ describe('a reached budget across a restart', () => { return original.apply(this, args); }, async () => { const first = compile(f); + first.catch(() => {}); // asserted below; if between() fails, its refusal is not left unhandled try { await between(); } finally { @@ -607,11 +608,16 @@ describe('a reached budget across a restart', () => { // Both plan at 60k. The second fits while the budget moves to 70k // unpersisted, so nothing credits it; the first is refused after it. const first = compile(f); - const second = compile(f); - f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 70_000 }, { persist: false }); - releaseSecond(); - await second; - releaseFirst(); + first.catch(() => {}); // asserted below + try { + const second = compile(f); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 70_000 }, { persist: false }); + releaseSecond(); + await second; + } finally { + releaseFirst(); + releaseSecond(); + } await assert.rejects(first, /over budget/); }); assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the earlier-started refusal is ignored'); @@ -637,36 +643,72 @@ describe('a reached budget across a restart', () => { // The first plans at 60k and is refused only after the budget went to // 80k and paced back down, and a compile at 80k settled that descent. const first = compile(f); - f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); - f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); - assert.equal(budgets(f).transition, 'converging'); - await compile(f); - assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }); - assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the settle credited 60k'); - release(); + first.catch(() => {}); // asserted below + try { + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 80_000 }); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.equal(budgets(f).transition, 'converging'); + await compile(f); + assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the settle credited 60k'); + } finally { + release(); + } await assert.rejects(first, /over budget/); }); assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the settle outranks the earlier-started refusal'); }); }); + it('a settle credits its target only from the latest compile to end at the budget it planned at', async () => { + await run(async ({ framework }) => { + const f = framework(); + f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }); + assert.equal(budgets(f).transition, 'converging'); + const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let calls = 0; + await withPatched('compile', async function (this: unknown, ...args: unknown[]) { + if (++calls > 1) throw refusal; + await held; + return original.apply(this, args); + }, async () => { + // Both plan at 100k. The second is refused there first; the first + // then fits and settles the descent to 60k. + const first = compile(f); + try { + await assert.rejects(compile(f), /over budget/); + } finally { + release(); + } + await first; + }); + assert.deepEqual(budgets(f), { target: 60_000, effective: 60_000, transition: 'stable' }, 'the first compile settled the descent'); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000 }, 'and credits nothing: a later-started compile was refused at 100k'); + assert.equal(f.getAgent('agent')!.getProvenContextBudgetTokens(), undefined); + }); + }); + it('the start order keeps nothing once no compile runs, through descents and compiles at once', async () => { await run(async ({ framework }) => { const f = framework(); - const noted = () => (f.getAgent('agent') as unknown as { latestEndedStart: Map }).latestEndedStart.size; + // What it keeps is visible only inside the agent. + const kept = () => [...(f.getAgent('agent') as unknown as { latestEndedStart: Map }).latestEndedStart.keys()]; // Descents to three budgets, each settled by a compile. for (const budget of [90_000, 75_000, 60_000]) { f.updateAgentRuntimeSettings('agent', { contextBudgetTokens: budget }); await compile(f); assert.deepEqual(budgets(f), { target: budget, effective: budget, transition: 'stable' }); - assert.equal(noted(), 0, `after the descent to ${budget}`); + assert.deepEqual(kept(), [], `after the descent to ${budget}`); } - // Two at once: the later-started one's start is kept while the earlier one runs. + // Two at once: the later-started one's place at 60k is kept while the earlier one runs. await twoCompilesAt60k(f, false, async () => { await compile(f); - assert.equal(noted(), 1, 'kept while an earlier-started compile runs'); + assert.deepEqual(kept(), [60_000], 'kept while an earlier-started compile runs'); }); - assert.equal(noted(), 0, 'and dropped once it ends'); + assert.deepEqual(kept(), [], 'and dropped once it ends'); }); }); @@ -907,6 +949,27 @@ describe('a reached budget across a restart', () => { }); }); + it("the fallback runs once: if the recipe's budget is refused too, its compile reports that refusal", { timeout: 20_000 }, async () => { + await run(async (h) => { + const restarted = await provenAtBoot(h, 60_000); + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + const planned: number[] = []; + const log = quietErrors(); + try { + await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }) { + planned.push(budget?.maxTokens ?? -1); + throw refusal; + }, async () => { + await assert.rejects(compile(restarted), /over budget/); + }); + } finally { + log.restore(); + } + assert.deepEqual(planned, [60_000, 100_000], 'one fallback, then the refusal at the recipe\'s budget'); + assert.equal(budgets(restarted).target, 60_000, 'the descent to the boot budget stays armed'); + }); + }); + it('a budget at or above the recipe\'s, applied at once at boot, has no paced restore to fall back to: its refusal is the compile\'s', async () => { await run(async (h) => { const restarted = await provenAtBoot(h, 120_000); From 704a0aa9fd4c6a803f48bfc9e3b356c721bcdd45 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 12:07:03 -0400 Subject: [PATCH 13/16] test(settings): the fallback runs once where the strategy can't pace, too The removal of the fallback's own clear of restoredAtOnce passed the suite: where the paced restore succeeds, that update clears the flag anyway. Where the strategy can't prepare a smaller window, the update throws first, and without the clear a refusal at the recipe's budget would run the fallback a second time, with a line claiming the boot had applied the recipe's budget at once. The test now runs both, and counts the fallback's lines. No source changes. Co-Authored-By: Claude Opus 5.5 --- test/agent-runtime-settings.test.ts | 50 ++++++++++++++++++----------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 94613ca9f..1cbffe8b4 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -950,24 +950,38 @@ describe('a reached budget across a restart', () => { }); it("the fallback runs once: if the recipe's budget is refused too, its compile reports that refusal", { timeout: 20_000 }, async () => { - await run(async (h) => { - const restarted = await provenAtBoot(h, 60_000); - const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); - const planned: number[] = []; - const log = quietErrors(); - try { - await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }) { - planned.push(budget?.maxTokens ?? -1); - throw refusal; - }, async () => { - await assert.rejects(compile(restarted), /over budget/); - }); - } finally { - log.restore(); - } - assert.deepEqual(planned, [60_000, 100_000], 'one fallback, then the refusal at the recipe\'s budget'); - assert.equal(budgets(restarted).target, 60_000, 'the descent to the boot budget stays armed'); - }); + // Restored paced, and kept set where the strategy can't prepare a smaller window. + for (const canPace of [true, false]) { + await run(async (h) => { + const restarted = await provenAtBoot(h, 60_000); + if (!canPace) { + const cm = restarted.getAgent('agent')!.getContextManager() as unknown as { + updateHotContextSettings: (update: { preparedWindowTokens?: number | null }) => unknown; + }; + const hot = cm.updateHotContextSettings.bind(cm); + cm.updateHotContextSettings = (update) => { + if (typeof update.preparedWindowTokens === 'number') throw new Error('no smaller window here'); + return hot(update); + }; + } + const refusal = Object.assign(new Error('over budget'), { name: 'OverBudgetError' }); + const planned: number[] = []; + const log = quietErrors(); + try { + await withPatched('compile', async function (this: unknown, budget?: { maxTokens: number }) { + planned.push(budget?.maxTokens ?? -1); + throw refusal; + }, async () => { + await assert.rejects(compile(restarted), /over budget/); + }); + } finally { + log.restore(); + } + assert.deepEqual(planned, [60_000, 100_000], `one fallback, then the refusal at the recipe's budget (canPace ${canPace})`); + assert.equal(log.lines.filter((l) => l.includes('the context manager refused')).length, 1, `one fallback line (canPace ${canPace})`); + assert.equal(budgets(restarted).target, 60_000, 'the boot budget stays set'); + }); + } }); it('a budget at or above the recipe\'s, applied at once at boot, has no paced restore to fall back to: its refusal is the compile\'s', async () => { From 9c860ddd61103127d7f3f34b9128b3fa5bd1d036 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 12:57:27 -0400 Subject: [PATCH 14/16] docs(settings): injections are outside the budget, like the system prompt and tools From Lydia-2381's read of 4208d0c..704a0aa (room-203 #88007). ContextManager.compile selects against the budget before it adds injections, so a compile with injections plans like one without, and the marker says the stored context fits the budget, not that a request with its injections, system prompt and tools does. The start order's doc, the restore's doc and the changelog said a compile with injections "counts like one without" as if it planned a larger context; they now say where the budget's boundary is. Comments and the changelog only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/budget-reached-across-restart.fixed.md | 4 ++-- src/agent.ts | 13 +++++++------ src/framework.ts | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 2012dfc3c..4b508183d 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,6 +1,6 @@ - A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, on a strategy that can prepare a smaller window, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. - - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended with a fit or a refusal: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. A compile with injections counts like one without, so the marker records whether the agent's compiles fit as it runs them, and the boot's preflight checks the stored context alone. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. - - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt and tools are outside the context manager's budget. A budget at or above the recipe's is applied at once with or without the marker, as an increase always is. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended with a fit or a refusal: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. The context manager selects against the budget before it adds injections, so a compile with injections plans like one without, and the marker says the stored context fits the budget, not that a request with its injections, system prompt and tools does. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. + - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt, tools and injections are outside the context manager's budget. A budget at or above the recipe's is applied at once with or without the marker, as an increase always is. - **If a compile at a budget below the recipe's that the boot applied at once is refused** before any compile at it has fit and before the budget is next written (an update that carries a budget, whatever its value, or a reset of it), the refusal withdraws the marker, and the agent makes the paced decrease a boot without the marker makes, from the recipe's budget: the budget alone, with the default pace when the strategy has none. Then it compiles again. If the budget can't be restored paced, the agent compiles at the recipe's budget with the budget kept set. This fallback runs once: a compile refused after it, including one already in flight at the boot's budget or the fallback's own compile at the recipe's, reports its own refusal, as does one the start order ignores. A tail, pace or think-text policy change leaves it in place. - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one restored paced on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. When a marked budget isn't applied at once or a setting can't be applied, one log line says why, what couldn't be applied, and the budget that's set beside the one compiles plan at. - **Smaller:** a patch can't set the field; a malformed persisted number (the marker, a budget, a tail or a pace) is ignored with a log line naming its kind; a record holding nothing else restores nothing. If reading the record fails at boot, the agent logs it and boots at the recipe's; a store that can't be read at all still fails agent creation at an earlier read of the same state, as before. A record that can't be read or written when a compile tells it is logged, and told again at the next compile at that budget. diff --git a/src/agent.ts b/src/agent.ts index 149214095..cba0af55f 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -164,12 +164,13 @@ export class Agent { /** * For each budget, the start of the latest-started compile that has ended * there with a fit or a refusal, whether or not what it showed was - * credited. Of two compiles at one - * budget, the later-started one planned at the newer store, so an - * earlier-started one that ends after it is ignored, with or without - * injections. An entry is kept only while a compile that started before it - * is still running, the only kind it can turn away, so the map is empty - * whenever no compile runs. + * credited. Of two compiles at one budget, the later-started one planned + * at the newer store, so an earlier-started one that ends after it is + * ignored. (Injections are added after the context manager selects against + * the budget, so compiles with and without them plan alike.) An entry is + * kept only while a compile that started before it is still running, the + * only kind it can turn away, so the map is empty whenever no compile + * runs. */ private readonly latestEndedStart = new Map(); /** The last outcome the listener recorded; a failed record is told again, and so is any after a change. */ diff --git a/src/framework.ts b/src/framework.ts index c1c7b5857..3dcfa7b12 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4085,10 +4085,10 @@ export class AgentFramework { * can change there: a new maxTokens (the response reserve) or strategy * configuration. It also sees a context that grew while the agent ran at * another budget after the record was written, through a persist: false - * change. The system prompt and tools are outside the context manager's - * budget, so they don't enter it (context-manager #162). If a compile at a - * budget applied at once this way is refused, the agent restores it paced - * then (armed by Agent.restoreRuntimeSettings, run in + * change. The system prompt, tools and injections are outside the context + * manager's budget, so they don't enter it (context-manager #162). If a + * compile at a budget applied at once this way is refused, the agent + * restores it paced then (armed by Agent.restoreRuntimeSettings, run in * Agent.compileAtLiveBudgetNoting). A budget at or above the recipe's is * applied at once with or without the marker, as an increase always is. * From fbea08ee776225648bf60446726eb98b3669a34f Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 13:07:25 -0400 Subject: [PATCH 15/16] test(settings): the doubles select at their start, as the context manager does; the start order names its premise From Oscar-1005's read of 4208d0c..9c860dd, with Ada-1017's agreement (room-203 #88130, #88132). ContextManager.compile (0.13.0, 0.14.0) selects, and throws any OverBudgetError, before anything it awaits, so start order is selection order; the start order rests on that, and a context manager that awaits before its select would break it. latestEndedStart's comment now says so. The held doubles in the start-order tests held before calling the real compile, so an earlier-started compile selected after a later-started one, an order the context manager can't produce. Each now selects at its start (a fit calls the real compile then, a refusal is decided then) and holds only its outcome, as the settle test's double already did. The settles still happen, and the start-order removals still fail their tests. Co-Authored-By: Claude Opus 5.5 --- src/agent.ts | 7 +++++-- test/agent-runtime-settings.test.ts | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index cba0af55f..1d8e9e3c5 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -166,8 +166,11 @@ export class Agent { * there with a fit or a refusal, whether or not what it showed was * credited. Of two compiles at one budget, the later-started one planned * at the newer store, so an earlier-started one that ends after it is - * ignored. (Injections are added after the context manager selects against - * the budget, so compiles with and without them plan alike.) An entry is + * ignored. That rests on the context manager selecting at the start of + * `compile`, before anything it awaits, so start order is selection order; + * a context manager that awaits before its select breaks it. (Injections + * are added after the select, so compiles with and without them plan + * alike.) An entry is * kept only while a compile that started before it is still running, the * only kind it can turn away, so the map is empty whenever no compile * runs. diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 1cbffe8b4..77f85f76a 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -514,7 +514,9 @@ describe('a reached budget across a restart', () => { /** * Two compiles at 60k, the first held until `between` has run: `firstFits` * says how the first ends, and the second, started after it, ends the - * other way. + * other way. Each selects at its start, as the context manager does (a fit + * calls the real compile then, a refusal is decided then); only the + * first's outcome is held. */ async function twoCompilesAt60k(f: AgentFramework, firstFits: boolean, between: () => Promise) { const original = (ContextManager.prototype as unknown as { compile: (...args: unknown[]) => Promise }).compile; @@ -524,9 +526,10 @@ describe('a reached budget across a restart', () => { let calls = 0; await withPatched('compile', async function (this: unknown, ...args: unknown[]) { const isFirst = ++calls === 1; + const outcome = isFirst !== firstFits ? Promise.reject(refusal) : original.apply(this, args); + outcome.catch(() => {}); // returned below if (isFirst) await held; - if (isFirst !== firstFits) throw refusal; - return original.apply(this, args); + return outcome; }, async () => { const first = compile(f); first.catch(() => {}); // asserted below; if between() fails, its refusal is not left unhandled @@ -601,9 +604,11 @@ describe('a reached budget across a restart', () => { let calls = 0; await withPatched('compile', async function (this: unknown, ...args: unknown[]) { const n = calls++; + // Each selects at its start; only its outcome is held. + const outcome = n === 0 ? Promise.reject(refusal) : original.apply(this, args); + outcome.catch(() => {}); // returned below await held[n]; - if (n === 0) throw refusal; - return original.apply(this, args); + return outcome; }, async () => { // Both plan at 60k. The second fits while the budget moves to 70k // unpersisted, so nothing credits it; the first is refused after it. @@ -672,8 +677,10 @@ describe('a reached budget across a restart', () => { let calls = 0; await withPatched('compile', async function (this: unknown, ...args: unknown[]) { if (++calls > 1) throw refusal; + // The first selects at its start; only its outcome is held. + const outcome = original.apply(this, args); await held; - return original.apply(this, args); + return outcome; }, async () => { // Both plan at 100k. The second is refused there first; the first // then fits and settles the descent to 60k. From dd29307c8d18cfee67a433b5ba7cd169ae0e8251 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Tue, 13 Oct 2026 21:48:05 -0400 Subject: [PATCH 16/16] docs(settings): the changelog's lead names the marker and the preflight, and the upgrade case From Lydia-2381's read of the final text (room-203 #88851, #88868). The lead said a budget comes back at once "when it still fits"; it does only when a compile recorded it as reached and the boot's preflight says it still fits. And a compile must record a fit before an unmarked record, such as one an earlier release wrote, can take the proof-based immediate path. The changelog says both. Changelog only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/budget-reached-across-restart.fixed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/budget-reached-across-restart.fixed.md b/changelog.d/budget-reached-across-restart.fixed.md index 4b508183d..f105ce4e4 100644 --- a/changelog.d/budget-reached-across-restart.fixed.md +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -1,5 +1,5 @@ -- A context budget the agent was running at comes back at once after a restart, when it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, on a strategy that can prepare a smaller window, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. - - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended with a fit or a refusal: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. The context manager selects against the budget before it adds injections, so a compile with injections plans like one without, and the marker says the stored context fits the budget, not that a request with its injections, system prompt and tools does. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. +- A lower context budget the agent was running at comes back at once after a restart, when a compile recorded it as reached and the boot's preflight says it still fits (context-manager #162). Before, every restore of a lower budget paced a new descent from the recipe's budget, on a strategy that can prepare a smaller window, so until the first compile settled it the agent reported `converging` and planned at the recipe's budget. + - **The marker.** The persisted runtime settings carry `contextBudgetReachedTokens` beside `contextBudgetTokens`. It is the record's own budget, written when a compile fits at it (a paced descent's settle included), and withdrawn when the context manager refuses that budget as over budget, even if the budget changed while that compile ran, unless a compile at that budget that started later has already ended with a fit or a refusal: of two compiles at one budget, the one that started later planned at the newer store, so an earlier-started one that ends after it is ignored. The context manager selects against the budget before it adds injections, so a compile with injections plans like one without, and the marker says the stored context fits the budget, not that a request with its injections, system prompt and tools does. A fit counts only if the live budget didn't change while the compile ran, and a settle only for a compile that began under that descent; each arm of a descent is a new one, to the same target included. What a compile showed stands only until the live budget changes: after a persisted change, immediate or paced, back to an earlier budget included, the record carries none until a compile fits at the new budget. A `persist: false` change leaves the record as it was, marker included. A compile must record a fit before an unmarked record can take the proof-based immediate path. - **At boot**, each persisted setting is restored on its own: the think-text policy, then the tail and the pace, and the budget last. A budget below the recipe's whose record carries the marker is applied at once if the preflight says it fits now, and otherwise paced from the recipe's budget, as before. The preflight plans the stored context as the context manager will, with the restored tail and pace, so it sees a new `maxTokens` or strategy configuration a restart brings, and a context that grew while the agent ran at another budget; the system prompt, tools and injections are outside the context manager's budget. A budget at or above the recipe's is applied at once with or without the marker, as an increase always is. - **If a compile at a budget below the recipe's that the boot applied at once is refused** before any compile at it has fit and before the budget is next written (an update that carries a budget, whatever its value, or a reset of it), the refusal withdraws the marker, and the agent makes the paced decrease a boot without the marker makes, from the recipe's budget: the budget alone, with the default pace when the strategy has none. Then it compiles again. If the budget can't be restored paced, the agent compiles at the recipe's budget with the budget kept set. This fallback runs once: a compile refused after it, including one already in flight at the boot's budget or the fallback's own compile at the recipe's, reports its own refusal, as does one the start order ignores. A tail, pace or think-text policy change leaves it in place. - **A persisted setting never stops the agent booting,** except an invalid think-text policy, which still fails closed. A tail, pace or budget that can't be applied now (a tail or pace on a strategy without live settings, a budget no longer above `maxTokens`, a lower one restored paced on a strategy that can't prepare a smaller window) is kept set but unapplied, so a later persisting write keeps it in the record, and the agent runs at the recipe's for it. A `private` think-text policy beside such a setting still governs. When a marked budget isn't applied at once or a setting can't be applied, one log line says why, what couldn't be applied, and the budget that's set beside the one compiles plan at.