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..f105ce4e4 --- /dev/null +++ b/changelog.d/budget-reached-across-restart.fixed.md @@ -0,0 +1,8 @@ +- 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. + - **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 412707a33..1d8e9e3c5 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -141,7 +141,52 @@ export class Agent { private readonly configuredTransitionPaceTokens?: number; private readonly configuredSameRoundThinkTextPolicy?: SameRoundThinkTextPolicy; private contextBudgetTargetTokens?: number; - private runtimeSettingsOverrides: AgentRuntimeSettingsOverrides = {}; + private runtimeSettingsOverrides: AgentRuntimeSettingsPatch = {}; + /** + * 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 (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. */ + 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, 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. 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. + */ + 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 }; + /** + * 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 = false; + /** Told when that changes (the framework records it beside the persisted budget). */ + private compiledBudgetListener?: (budget: number, fit: boolean) => boolean; private contextManager: ContextManager; private membrane: Membrane; @@ -280,10 +325,16 @@ 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: at boot (keepRuntimeSettings), or after a + // refused at-once budget couldn't be restored paced (compileAtLiveBudget). contextBudgetTokens: + this.runtimeSettingsOverrides.contextBudgetTokens ?? 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 +358,141 @@ export class Agent { return { ...this.runtimeSettingsOverrides }; } + /** + * 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) => boolean) | undefined): void { + this.compiledBudgetListener = listener; + } + + /** + * The live budget the last compile planned at and fit, if it fit and the + * live budget hasn't changed since. + */ + getProvenContextBudgetTokens(): number | 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. + * `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 (armed || target !== this.contextBudgetTargetTokens) this.descentEpoch++; + this.contextBudgetTokens = budget; + this.contextBudgetTargetTokens = target; + } + + /** + * 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 claimLatestAt(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; + // 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 { + 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; + let result: CompileResult; + try { + result = await compile(); + } catch (error) { + 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 already + // ended at that budget. + 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 + // 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); + let outcome: string; + try { + this.updateRuntimeSettings({ contextBudgetTokens: live }); + outcome = 'restored it as a paced descent from the recipe\'s budget'; + } catch (fallback) { + 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; ${outcome}`, + ); + return this.compileAtLiveBudget(compile); + } + 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 + // 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 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 (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; + } + /** * Read-only mirror of `updateRuntimeSettings`' budget semantics: derive what * the next compile would actually plan at if `patch` were applied, WITHOUT @@ -323,11 +509,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; @@ -344,33 +536,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; } - if ( - patch?.transitionPaceTokens !== undefined && - hot?.preparedWindowTokens === undefined - ) { - 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 (patch?.contextBudgetTokens === undefined) { + 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 }; } return { effectiveBudgetTokens: live, liveBudgetTokens: live, - advisoryTargetTokens: patch.contextBudgetTokens, + advisoryTargetTokens: budget, path: 'paced', overrides, }; @@ -397,6 +589,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; @@ -428,21 +622,24 @@ export class Agent { } hotPatch.preparedWindowTokens = patch.contextBudgetTokens - this.maxTokens; nextContextTarget = patch.contextBudgetTokens; + armed = true; } } if (Object.keys(hotPatch).length > 0) { this.updateHotContextSettings(hotPatch); } - this.contextBudgetTokens = nextContextBudget; - this.contextBudgetTargetTokens = nextContextTarget; + 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; } - 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; @@ -454,6 +651,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; @@ -471,6 +670,7 @@ export class Agent { hotPatch.transitionPaceTokens = DEFAULT_TRANSITION_PACE_TOKENS; } nextContextTarget = configured; + armed = true; } else { nextContextBudget = this.configuredContextBudgetTokens; nextContextTarget = undefined; @@ -487,9 +687,11 @@ export class Agent { : this.configuredTransitionPaceTokens ?? null; } if (Object.keys(hotPatch).length > 0) this.updateHotContextSettings(hotPatch); - this.contextBudgetTokens = nextContextBudget; - this.contextBudgetTargetTokens = nextContextTarget; - if (reset.has('contextBudgetTokens')) delete this.runtimeSettingsOverrides.contextBudgetTokens; + this.setContextBudget(nextContextBudget, nextContextTarget, armed); + 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; @@ -503,7 +705,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; @@ -514,8 +716,41 @@ export class Agent { return this.getRuntimeSettings(); } - restoreRuntimeSettings(overrides: AgentRuntimeSettingsOverrides): AgentRuntimeSettingsSnapshot { - return this.updateRuntimeSettings(overrides); + /** + * 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. When it's below the live budget, where the proof is what + * 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( + 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) { + this.lastCompiledBudget = { budget: opts.provenBudget, fit: true, epoch: this.liveBudgetEpoch }; + this.restoredAtOnce = opts.provenBudget < live; + } + return snapshot; + } + + /** + * 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: Pick): void { + for (const key of ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens'] as const) { + if (settings[key] !== undefined) this.runtimeSettingsOverrides[key] = settings[key]; + } } private validateRuntimeSettingsPatch(patch: AgentRuntimeSettingsPatch): void { @@ -544,13 +779,14 @@ 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; - this.contextBudgetTokens = this.contextBudgetTargetTokens; - this.contextBudgetTargetTokens = undefined; + if (!hot?.prepared) return false; + this.setContextBudget(this.contextBudgetTargetTokens, undefined); this.updateHotContextSettings({ preparedWindowTokens: null }); + return true; } /** @@ -595,9 +831,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; } @@ -612,11 +848,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 349bdd693..3dcfa7b12 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, @@ -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, ); @@ -3964,22 +3981,41 @@ export class AgentFramework { } } + /** 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 { - try { - const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as { - agentRuntimeSettings?: Record; - } | null; - const stored = data?.agentRuntimeSettings?.[agentName]; - return stored ? { ...stored } : null; - } catch { - return 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 { + // 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 ( overrides.sameRoundThinkTextPolicy !== undefined && overrides.sameRoundThinkTextPolicy !== 'public' && @@ -4030,6 +4066,138 @@ export class AgentFramework { this.store.setStateJson(FRAMEWORK_STATE_ID, state); } + /** + * 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 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 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, 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. + * + * 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, + agent: Agent, + overrides: AgentRuntimeSettingsOverrides, + ): void { + const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); + 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 = { contextBudgetTokens: budget, immediate: true }; + let why: string | undefined; + try { + const preview = this.previewRuntimeSettingsFor(agent, patch); + 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 (why === undefined) { + try { + agent.restoreRuntimeSettings(patch, { provenBudget: budget }); + appliedAtOnce = true; + } catch (error) { + why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`; + } + } + 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; + const now = agent.getRuntimeSettings(); + const said: string[] = []; + 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`); + } + console.error( + `[agent-settings] ${agentName}: ${said.join('; ')}: contextBudgetTokens is ${now.contextBudgetTokens}, ` + + `and compiles plan at ${now.effectiveContextBudgetTokens}${now.transition === 'stable' ? '' : ` (${now.transition})`}`, + ); + } + + /** + * What persists for an agent: its overrides, and `contextBudgetReachedTokens` + * when the agent's proof stands for the overridden budget: a compile fit at + * 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. + */ + private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides { + const overrides = agent.getRuntimeSettingsOverrides(); + const proven = agent.getProvenContextBudgetTokens(); + return overrides.contextBudgetTokens !== undefined && proven === overrides.contextBudgetTokens + ? { ...overrides, contextBudgetReachedTokens: proven } + : overrides; + } + + /** + * 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 record that can't be read, or written, is + * logged, and the next compile at that budget tells it again. + */ + private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean { + try { + const persisted = this.readAgentRuntimeSettings(agentName); + 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 next compile at this budget tells it again. + console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error); + return false; + } + } + private persistAgentRuntimeSettings( agentName: string, overrides: AgentRuntimeSettingsOverrides, @@ -6460,12 +6628,23 @@ 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) { - agent.restoreRuntimeSettings( + this.restorePersistedRuntimeSettings( + config.name, + agent, this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings), ); } + agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit)); 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..dce7b9a81 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -223,7 +223,20 @@ export interface AgentRuntimeSettingsPatch { } export interface AgentRuntimeSettingsSnapshot { + /** + * 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), + * 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; transitionPaceTokens?: number; sameRoundThinkTextPolicy: SameRoundThinkTextPolicy; @@ -233,7 +246,27 @@ export interface AgentRuntimeSettingsSnapshot { } /** Persisted values differ from recipe defaults; omitted keys inherit config. */ -export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch; +export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch & { + /** + * 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 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, + * 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 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; +}; /** * Result of running inference. diff --git a/test/agent-runtime-settings.test.ts b/test/agent-runtime-settings.test.ts index 90c482f84..77f85f76a 100644 --- a/test/agent-runtime-settings.test.ts +++ b/test/agent-runtime-settings.test.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; -import { AutobiographicalStrategy } from '@animalabs/context-manager'; -import { AgentFramework, BudgetPreflightError } from '../src/index.js'; +import { AutobiographicalStrategy, ContextManager } from '@animalabs/context-manager'; +import { Agent, AgentFramework, BudgetPreflightError, type AgentRuntimeSettingsPatch } from '../src/index.js'; const membrane = {} as any; @@ -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,1129 @@ 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?: { [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 config = () => ({ + storePath: join(dir, 'store'), + membrane, + agents: [{ + name: 'agent', model: 'test-model', systemPrompt: 'test', + strategy: strategy(), contextBudgetTokens: 100_000, maxTokens: 10_000, + }], + modules: [], + }); + return { dir, config }; + }; + const budgets = (framework: AgentFramework) => { + const s = framework.getAgentRuntimeSettings('agent'); + return { target: s.contextBudgetTokens, effective: s.effectiveContextBudgetTokens, transition: s.transition }; + }; + 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 { + 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('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('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 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 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); + assert.deepEqual(budgets(restarted), { target: 60_000, effective: 100_000, transition: 'converging' }); + } finally { + log.restore(); + } + // 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 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)', + ]); + }); + }); + + 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 + // 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 }) => { + const log = quietErrors(); + try { + 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 { + log.restore(); + } + // 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, [ + 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')); + }); + }); + + 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'); + }); + 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 () => { + 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 () => { + 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 () => { + 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 }); + // 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'); + 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'); + }); + }); + + /** + * 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. 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; + 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; + const outcome = isFirst !== firstFits ? Promise.reject(refusal) : original.apply(this, args); + outcome.catch(() => {}); // returned below + if (isFirst) await held; + return outcome; + }, async () => { + const first = compile(f); + first.catch(() => {}); // asserted below; if between() fails, its refusal is not left unhandled + try { + await between(); + } finally { + 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 }); + await twoCompilesAt60k(f, false, async () => { + await compile(f); + assert.deepEqual(persisted(f), { contextBudgetTokens: 60_000, contextBudgetReachedTokens: 60_000 }, 'the second compile fit at 60k'); + }); + 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("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++; + // 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]; + 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. + const first = compile(f); + 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'); + }); + }); + + 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); + 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; + // The first selects at its start; only its outcome is held. + const outcome = original.apply(this, args); + await held; + return outcome; + }, 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(); + // 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.deepEqual(kept(), [], `after the descent to ${budget}`); + } + // 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.deepEqual(kept(), [60_000], 'kept while an earlier-started compile runs'); + }); + assert.deepEqual(kept(), [], '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(); + 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'); + }); + // 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'); + }); + // 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 () => { + 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("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(); + 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'); + }); + }); + + /** 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, 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; + 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) { + meanwhile?.(); + 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 (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.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("the fallback runs once: if the recipe's budget is refused too, its compile reports that refusal", { timeout: 20_000 }, async () => { + // 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 () => { + 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 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); + 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'); + }); + // 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); + await compile(restarted); + const { planned, thrown } = await refusedFirst(restarted); + assert.equal((thrown as Error | undefined)?.message, 'over budget'); + assert.deepEqual(planned, [60_000], 'once a compile has fit at it, a refusal is the compile\'s own'); + }); + }); + + /** 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 { + 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 () => { + 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 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) { + await bootRaised(record, (booted, lines) => { + assert.deepEqual(budgets(booted), { target: 60_000, effective: 100_000, transition: 'stable' }, 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 }); + } + }); + + 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(); + 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 () => { const dir = mkdtempSync(join(tmpdir(), 'agent-settings-basic-')); const framework = await AgentFramework.create({ @@ -145,6 +1274,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 +1301,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 +1311,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 +1322,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', @@ -467,7 +1600,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 = () => ({