diff --git a/packages/core/src/goals/goal-protocol.ts b/packages/core/src/goals/goal-protocol.ts index 703364104f1..3cd7738c369 100644 --- a/packages/core/src/goals/goal-protocol.ts +++ b/packages/core/src/goals/goal-protocol.ts @@ -24,18 +24,62 @@ export const GOAL_CHECKPOINT_STALL_LIMIT = 3; export const GOAL_CHECKPOINT_STALLED_REASON = 'The current Goal revision ran three consecutive evidence checkpoints without relief: the evidence window overflowed every time, and each check either came back with a full claim list or a result that could not be folded into claims at all, so every turn paid a checkpoint call and lost uncatalogued evidence. Automatic retries cannot recover. Edit or replace the Goal with a narrower objective before resuming it.'; +/** + * Default autonomous spend window armed on a newly created Goal, in model + * tokens on the `tokensUsed` metric (`totalTokenCount` summed per model call, + * so a call's full input context counts every time it is sent). + * + * The meter bills Goal-turn model calls only -- per-turn side queries and + * checkpoint-verifier calls are unmetered -- so real provider spend at a + * stop runs above this window. + * + * This is an authorization quantum, not a cost estimate: it bounds how much + * autonomous continuation one explicit user action (create, or a later + * resume) pays for before the Goal stops and asks again. Sized to a few hours + * of continuous turn cadence -- the runaway session this bound exists for + * burned ~8.6M tokens in half an hour before a human killed it, so a healthy + * long run reaches this ceiling late and a stuck loop reaches it unattended. + */ +export const GOAL_DEFAULT_TOKEN_BUDGET = 30_000_000; + +/** The `lastReason` a Goal stops with when `tokensUsed` reaches its budget. */ +export function goalTokenBudgetReason(tokenBudget: number): string { + return `The Goal spent its autonomous token budget (${tokenBudget.toLocaleString('en-US')} tokens). Resume the Goal to authorize another budget window, or clear it.`; +} + +/** + * Whether `tokensUsed` has reached the armed ceiling. One predicate serves + * both the runtime's stop condition and the reducer's re-arm condition, so + * the stop/re-arm cycle cannot desynchronize. + */ +export function isGoalTokenBudgetSpent( + goal: Pick, +): goal is Pick & { + tokenBudget: number; +} { + return goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget; +} + /** * Which bound a `usage_limited` Goal ran into. * - * Only the evidence bounds are enumerated: they are the ones a caller has to - * branch on, because they are the ones a plain resume cannot clear. Every other - * route to `usage_limited` is an operational failure that carries prose in - * `lastReason` and nothing to key off. + * Only the enumerated bounds are typed: they are the ones a caller has to + * branch on. The evidence kinds mark a window a plain resume cannot simply + * re-enter; `token_budget` marks a spent authorization that a resume re-arms. + * Every other route to `usage_limited` is an operational failure that carries + * prose in `lastReason` and nothing to key off. */ -export type GoalLimitKind = 'evidence_catalog' | 'checkpoint_request'; +export type GoalLimitKind = + | 'evidence_catalog' + | 'checkpoint_request' + | 'token_budget'; export function isGoalLimitKind(value: unknown): value is GoalLimitKind { - return value === 'evidence_catalog' || value === 'checkpoint_request'; + return ( + value === 'evidence_catalog' || + value === 'checkpoint_request' || + value === 'token_budget' + ); } /** The limit a `usage_limited` reason denotes, for reasons that denote one. */ @@ -120,6 +164,14 @@ export interface GoalRecord { * Zero on Goals recovered from a transcript written before the field existed. */ tokensUsed: number; + /** + * The ceiling `tokensUsed` may reach before autonomous continuation stops + * and the Goal waits for the user. Armed at creation from the runtime's + * grant; a resume or edit of a Goal whose ceiling is spent moves it forward + * (`tokensUsed + grant`) -- the spent meter itself is never reset. Absent + * on Goals persisted before budgets existed: those stay unbounded. + */ + tokenBudget?: number; createdAt: number; updatedAt: number; evidenceCheckpoint?: GoalEvidenceCheckpoint; diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts index d00993cf220..7c76eb6c88c 100644 --- a/packages/core/src/goals/goal-reducer.test.ts +++ b/packages/core/src/goals/goal-reducer.test.ts @@ -9,6 +9,7 @@ import { GOAL_CHECKPOINT_REQUEST_TOO_LARGE_REASON, GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, goalLimitKindForReason, + goalTokenBudgetReason, goalRequiresExactPermit, type GoalControlRequest, type GoalRecord, @@ -1106,3 +1107,228 @@ describe('goal reducer', () => { }, ); }); + +describe('token budget transitions', () => { + const control = (request: GoalControlRequest, tokenBudgetGrant?: number) => ({ + request, + now: 200, + nextGoalId: 'g-next', + cursor: { recordId: 'r-200' }, + ...(tokenBudgetGrant === undefined ? {} : { tokenBudgetGrant }), + }); + + const budgetStopped = (overrides: Partial = {}): GoalRecord => + goalRecord({ + status: 'usage_limited', + tokensUsed: 1_200, + tokenBudget: 1_000, + lastReason: goalTokenBudgetReason(1_000), + limitKind: 'token_budget', + ...overrides, + }); + + it('stamps the armed grant on create and replace', () => { + const created = reduceGoalControl( + null, + control({ action: 'create', objective: 'ship' }, 1_000), + ); + expect(created).toMatchObject({ tokenBudget: 1_000, tokensUsed: 0 }); + + const replaced = reduceGoalControl( + goalRecord({ tokensUsed: 900, tokenBudget: 1_000 }), + control( + { + action: 'replace', + objective: 'ship again', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + 2_000, + ), + ); + expect(replaced).toMatchObject({ tokenBudget: 2_000, tokensUsed: 0 }); + }); + + it('creates an unbounded Goal when no grant is armed', () => { + const created = reduceGoalControl( + null, + control({ action: 'create', objective: 'ship' }), + ); + expect(created).not.toHaveProperty('tokenBudget'); + }); + + it('re-arms a budget-stopped Goal on resume: the ceiling moves ahead of the meter it never resets', () => { + const resumed = reduceGoalControl( + budgetStopped(), + control( + { action: 'resume', expectedGoalId: 'g-1', expectedRevision: 1 }, + 1_000, + ), + ); + expect(resumed).toMatchObject({ + status: 'active', + tokensUsed: 1_200, + tokenBudget: 2_200, + revision: 1, + evidenceCursor: { recordId: 'r-100' }, + }); + expect(resumed?.lastReason).toBeUndefined(); + expect(resumed?.limitKind).toBeUndefined(); + }); + + it('leaves an unspent ceiling alone on resume', () => { + const resumed = reduceGoalControl( + goalRecord({ status: 'paused', tokensUsed: 300, tokenBudget: 1_000 }), + control( + { action: 'resume', expectedGoalId: 'g-1', expectedRevision: 1 }, + 1_000, + ), + ); + expect(resumed).toMatchObject({ status: 'active', tokenBudget: 1_000 }); + }); + + it('re-arms when the spend lands exactly on the ceiling', () => { + const resumed = reduceGoalControl( + budgetStopped({ tokensUsed: 1_000, tokenBudget: 1_000 }), + control( + { action: 'resume', expectedGoalId: 'g-1', expectedRevision: 1 }, + 1_000, + ), + ); + expect(resumed).toMatchObject({ + status: 'active', + tokensUsed: 1_000, + tokenBudget: 2_000, + }); + }); + + it.each(['paused', 'blocked'] as const)( + 're-arms a spent ceiling when resuming a %s Goal', + (status) => { + const resumed = reduceGoalControl( + goalRecord({ status, tokensUsed: 1_200, tokenBudget: 1_000 }), + control( + { action: 'resume', expectedGoalId: 'g-1', expectedRevision: 1 }, + 1_000, + ), + ); + expect(resumed).toMatchObject({ + status: 'active', + tokensUsed: 1_200, + tokenBudget: 2_200, + }); + }, + ); + + it('clears a spent ceiling on resume or edit when the runtime opts out', () => { + const resumed = reduceGoalControl( + budgetStopped(), + control( + { action: 'resume', expectedGoalId: 'g-1', expectedRevision: 1 }, + Number.POSITIVE_INFINITY, + ), + ); + expect(resumed).toMatchObject({ status: 'active', tokensUsed: 1_200 }); + expect(resumed).not.toHaveProperty('tokenBudget'); + + const edited = reduceGoalControl( + budgetStopped(), + control( + { + action: 'edit', + objective: 'ship without a budget', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + Number.POSITIVE_INFINITY, + ), + ); + expect(edited).toMatchObject({ + status: 'usage_limited', + objective: 'ship without a budget', + tokensUsed: 1_200, + }); + expect(edited).not.toHaveProperty('tokenBudget'); + }); + + it('re-arms a spent ceiling on edit, so the edited Goal can actually run', () => { + const edited = reduceGoalControl( + budgetStopped(), + control( + { + action: 'edit', + objective: 'ship the rest', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + 1_000, + ), + ); + expect(edited).toMatchObject({ + status: 'usage_limited', + revision: 2, + tokensUsed: 1_200, + tokenBudget: 2_200, + }); + }); + + it('never retrofits a budget onto an unbounded Goal', () => { + const edited = reduceGoalControl( + goalRecord({ tokensUsed: 5_000_000 }), + control( + { + action: 'edit', + objective: 'keep going', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + 1_000, + ), + ); + expect(edited).not.toHaveProperty('tokenBudget'); + }); + + it('resumes an evidence-limited Goal through the fresh window, re-arming a spent budget on the way', () => { + const resumed = reduceGoalControl( + goalRecord({ + status: 'usage_limited', + tokensUsed: 1_200, + tokenBudget: 1_000, + lastReason: GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, + limitKind: 'evidence_catalog', + }), + control( + { action: 'resume', expectedGoalId: 'g-1', expectedRevision: 1 }, + 1_000, + ), + ); + expect(resumed).toMatchObject({ + status: 'active', + tokensUsed: 1_200, + tokenBudget: 2_200, + evidenceCursor: { recordId: 'r-200' }, + }); + expect(resumed?.lastReason).toBeUndefined(); + expect(resumed?.limitKind).toBeUndefined(); + }); + + it('restores a persisted budget and rejects a malformed one', () => { + const stored = snapshot( + goalRecord({ + status: 'usage_limited', + tokensUsed: 1_200, + tokenBudget: 1_000, + lastReason: goalTokenBudgetReason(1_000), + limitKind: 'token_budget', + }), + ); + expect(parseGoalSnapshotV2(stored)).toEqual(stored); + expect( + parseGoalSnapshotV2(snapshot(goalRecord({ tokenBudget: -1 }))), + ).toBeUndefined(); + // A Goal from before budgets existed restores unbounded, not defaulted. + expect(parseGoalSnapshotV2(snapshot(goalRecord()))).toEqual( + snapshot(goalRecord()), + ); + }); +}); diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts index eb301cd0db9..250e61ac665 100644 --- a/packages/core/src/goals/goal-reducer.ts +++ b/packages/core/src/goals/goal-reducer.ts @@ -13,6 +13,7 @@ import { goalLimitKindForReason, isGoalEvidenceProofKind, isGoalLimitKind, + isGoalTokenBudgetSpent, type GoalControlRequest, type GoalEvidenceCheckpoint, type GoalRecord, @@ -30,6 +31,14 @@ export interface GoalControlTransition { now: number; nextGoalId: string; cursor: TranscriptCursor; + /** + * The autonomous spend window the caller is arming, in `tokensUsed` tokens. + * A create or replace stamps it as the new Goal's `tokenBudget`; a resume + * or edit of a Goal whose ceiling is spent re-arms `tokensUsed + grant`. + * Absent means the transition arms nothing -- a created Goal is then + * unbounded. + */ + tokenBudgetGrant?: number; } export interface GoalTurnFinishedTransition { @@ -75,6 +84,7 @@ export function reduceGoalControl( normalizeObjective(request.objective, snapshotOf(null)), transition.now, transition.cursor, + transition.tokenBudgetGrant, ); } @@ -92,6 +102,7 @@ export function reduceGoalControl( normalizeObjective(request.objective, snapshotOf(current)), transition.now, transition.cursor, + transition.tokenBudgetGrant, ); } @@ -108,6 +119,7 @@ export function reduceGoalControl( evidenceCursor: copyCursor(transition.cursor), evidenceCheckpoint: undefined, checkpointStalls: undefined, + ...rearmedTokenBudget(current, transition.tokenBudgetGrant), lastReason: undefined, limitKind: undefined, }); @@ -135,6 +147,22 @@ export function reduceGoalControl( snapshotOf(current), ); } + if ( + request.action === 'resume' && + current.status === 'usage_limited' && + current.limitKind === 'token_budget' + ) { + // A budget stop is a spent authorization, not a fault: resuming IS the + // user paying for another window, so re-arm the ceiling ahead of the + // meter rather than resetting the meter -- `tokensUsed` stays honest + // accounting across the Goal's whole life. + return transitionGoal(current, transition.now, { + status: 'active', + ...rearmedTokenBudget(current, transition.tokenBudgetGrant), + lastReason: undefined, + limitKind: undefined, + }); + } if (request.action !== 'resume') { return assertNever(request, snapshotOf(current)); } @@ -158,12 +186,14 @@ export function reduceGoalControl( // a different one, so carrying it over would spend the new window's // allowance on the old window's failures. checkpointStalls: undefined, + ...rearmedTokenBudget(current, transition.tokenBudgetGrant), lastReason: undefined, limitKind: undefined, }); } return transitionGoal(current, transition.now, { status: 'active', + ...rearmedTokenBudget(current, transition.tokenBudgetGrant), }); } @@ -343,6 +373,7 @@ function createGoal( objective: string, now: number, cursor: TranscriptCursor, + tokenBudget: number | undefined, ): GoalRecord { return { goalId, @@ -353,6 +384,11 @@ function createGoal( turnCount: 0, activeTimeMs: 0, tokensUsed: 0, + // A non-finite grant (a host opting out) arms nothing: `Infinity` would + // not survive the JSON journal, so "unbounded" is spelled as no field. + ...(tokenBudget !== undefined && Number.isFinite(tokenBudget) + ? { tokenBudget } + : {}), createdAt: now, updatedAt: now, }; @@ -389,29 +425,55 @@ function normalizeObjective( /** * Whether a stopped Goal was stopped by one of the evidence bounds. * - * `limitKind` is the field of record. The `lastReason` comparison behind it - * reads Goals persisted before `limitKind` existed, where the sentinel prose - * was the only marker a transition could key off. + * `limitKind` is the field of record, matched by kind rather than presence: + * `token_budget` is also a `limitKind`, and a budget-stopped Goal is exactly + * the one resume must accept. The `lastReason` comparison behind it reads + * Goals persisted before `limitKind` existed, where the sentinel prose was + * the only marker a transition could key off. */ function isEvidenceLimited(goal: GoalRecord): boolean { return ( - goal.limitKind !== undefined || + goal.limitKind === 'evidence_catalog' || + goal.limitKind === 'checkpoint_request' || (goal.lastReason !== undefined && goalLimitKindForReason(goal.lastReason) !== undefined) ); } +/** + * The budget change an explicit user action (edit, or a resume of a Goal + * whose ceiling is spent) arms: a finite grant moves a spent ceiling to + * `tokensUsed + grant`, while a non-finite opt-out clears it. An unspent + * ceiling is left alone, and a Goal with no ceiling stays unbounded -- budgets + * are armed at creation, never retrofitted. + */ +function rearmedTokenBudget( + current: GoalRecord, + grant: number | undefined, +): Partial { + if (grant === undefined || !isGoalTokenBudgetSpent(current)) { + return {}; + } + return Number.isFinite(grant) + ? { tokenBudget: current.tokensUsed + grant } + : { tokenBudget: undefined }; +} + function transitionGoal( goal: GoalRecord, now: number, changes: Partial, ): GoalRecord { - return { + const transitioned = { ...goal, ...changes, activeTimeMs: elapsedActiveTime(goal, now), updatedAt: now, }; + if ('tokenBudget' in changes && changes.tokenBudget === undefined) { + delete transitioned.tokenBudget; + } + return transitioned; } function snapshotOf(goal: GoalRecord | null): GoalSnapshotV2 { @@ -454,6 +516,7 @@ function parseGoalRecord(value: unknown): GoalRecord | undefined { 'turnCount', 'activeTimeMs', 'tokensUsed', + 'tokenBudget', 'createdAt', 'updatedAt', 'evidenceCheckpoint', @@ -473,6 +536,8 @@ function parseGoalRecord(value: unknown): GoalRecord | undefined { !isNonNegativeNumber(value['activeTimeMs']) || (value['tokensUsed'] !== undefined && !isNonNegativeNumber(value['tokensUsed'])) || + (value['tokenBudget'] !== undefined && + !isNonNegativeNumber(value['tokenBudget'])) || !isFiniteNumber(value['createdAt']) || !isFiniteNumber(value['updatedAt']) || !isGoalEvidenceCheckpoint(value['evidenceCheckpoint']) || @@ -503,6 +568,10 @@ function parseGoalRecord(value: unknown): GoalRecord | undefined { activeTimeMs: value['activeTimeMs'], // Goals persisted before `tokensUsed` existed carry no spend to restore. tokensUsed: value['tokensUsed'] ?? 0, + // And no budget: a Goal from before budgets existed stays unbounded. + ...(value['tokenBudget'] === undefined + ? {} + : { tokenBudget: value['tokenBudget'] }), createdAt: value['createdAt'], updatedAt: value['updatedAt'], ...(value['evidenceCheckpoint'] === undefined diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index 309ba248990..05117de4040 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -12,6 +12,7 @@ import { GOAL_CHECKPOINT_REQUEST_TOO_LARGE_REASON, GOAL_CHECKPOINT_STALL_LIMIT, GOAL_CHECKPOINT_STALLED_REASON, + GOAL_DEFAULT_TOKEN_BUDGET, GOAL_PROPOSAL_REASON_MAX_BYTES, type GoalSnapshotV2, type GoalStateCause, @@ -329,6 +330,168 @@ describe('goal runtime', () => { expect(asked).toEqual([permit.turnId]); }); + it('arms the default token budget when no grant is supplied', async () => { + const runtime = createGoalRuntime({ journal: fakeGoalJournal() }); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + + expect(runtime.getSnapshot().goal?.tokenBudget).toBe( + GOAL_DEFAULT_TOKEN_BUDGET, + ); + // The wiring assertion above moves with the constant, so only this + // literal pin catches a silent rescale of the production default. + expect(GOAL_DEFAULT_TOKEN_BUDGET).toBe(30_000_000); + }); + + it('stops autonomous continuation when the budget is spent, and resume re-arms it', async () => { + const journal = fakeGoalJournal(); + const host = fakeGoalTurnHost(); + const spend = new Map(); + const runtime = createGoalRuntime({ + journal, + tokenLedger: { + takeGoalTurnTokens: (turnId: string) => { + const tokens = spend.get(turnId) ?? 0; + spend.delete(turnId); + return tokens; + }, + }, + tokenBudgetGrant: 1_000, + }); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + const created = runtime.getSnapshot().goal!; + expect(created).toMatchObject({ tokenBudget: 1_000, tokensUsed: 0 }); + + spend.set(host.started[0]!.turnId, 1_500); + await runtime.finishTurn(host.started[0]!); + + // The stop settles on the dispatch tail, where the refused continuation + // queued it. + await vi.waitFor(() => { + expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'); + }); + expect(runtime.getSnapshot().goal).toMatchObject({ + limitKind: 'token_budget', + tokensUsed: 1_500, + tokenBudget: 1_000, + lastReason: expect.stringContaining('autonomous token budget'), + }); + // The spent budget refused the continuation itself: no second turn ran. + expect(host.started).toHaveLength(1); + expect(journal.appended.map((payload) => payload.cause)).toEqual([ + 'create', + 'turn_finished', + 'usage_limited', + ]); + + // Resuming IS the user paying for another window: the ceiling moves ahead + // of the meter, and the re-armed window admits a real continuation again. + const resumed = await runtime.dispatch({ + action: 'resume', + expectedGoalId: created.goalId, + expectedRevision: created.revision, + }); + expect(resumed.snapshot.goal).toMatchObject({ + status: 'active', + tokensUsed: 1_500, + tokenBudget: 2_500, + }); + expect(resumed.snapshot.goal?.limitKind).toBeUndefined(); + expect(host.started).toHaveLength(2); + }); + + it('stops at an exact-ceiling spend without minting another turn', async () => { + const journal = fakeGoalJournal(); + const host = fakeGoalTurnHost(); + const spend = new Map(); + const runtime = createGoalRuntime({ + journal, + tokenLedger: { + takeGoalTurnTokens: (turnId: string) => { + const tokens = spend.get(turnId) ?? 0; + spend.delete(turnId); + return tokens; + }, + }, + tokenBudgetGrant: 1_000, + }); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + + // Spend lands exactly on the ceiling: still spent, no further turn. + spend.set(host.started[0]!.turnId, 1_000); + await runtime.finishTurn(host.started[0]!); + + await vi.waitFor(() => { + expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'); + }); + expect(runtime.getSnapshot().goal).toMatchObject({ + limitKind: 'token_budget', + tokensUsed: 1_000, + tokenBudget: 1_000, + }); + expect(host.started).toHaveLength(1); + }); + + it('shows the budget stop even when the settle write fails', async () => { + const journal = fakeGoalJournal({ + appendErrors: [undefined, undefined, new Error('writer unavailable')], + }); + const host = fakeGoalTurnHost(); + const spend = new Map(); + const runtime = createGoalRuntime({ + journal, + tokenLedger: { + takeGoalTurnTokens: (turnId: string) => { + const tokens = spend.get(turnId) ?? 0; + spend.delete(turnId); + return tokens; + }, + }, + tokenBudgetGrant: 1_000, + }); + const causes: Array = []; + runtime.subscribe((_snapshot, cause) => causes.push(cause)); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + + spend.set(host.started[0]!.turnId, 1_500); + await runtime.finishTurn(host.started[0]!); + + await vi.waitFor(() => { + expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'); + }); + // The failed write never reaches the journal, but the visible state + // still settles: the gate refuses continuations either way, and the + // user's next action surfaces the persistence loss. + expect(journal.appended.map((payload) => payload.cause)).toEqual([ + 'create', + 'turn_finished', + ]); + expect(runtime.getSnapshot().goal).toMatchObject({ + limitKind: 'token_budget', + tokensUsed: 1_500, + tokenBudget: 1_000, + }); + expect(causes).toContain('usage_limited'); + expect(host.started).toHaveLength(1); + }); + + it('never arms a budget when the runtime opts out with an unbounded grant', async () => { + const journal = fakeGoalJournal(); + const host = fakeGoalTurnHost(); + const runtime = createGoalRuntime({ + journal, + tokenBudgetGrant: Number.POSITIVE_INFINITY, + }); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + expect(runtime.getSnapshot().goal).not.toHaveProperty('tokenBudget'); + await runtime.finishTurn(host.started[0]!); + expect(runtime.getSnapshot().goal?.status).toBe('active'); + expect(host.started).toHaveLength(2); + }); + it('bills nothing when no ledger is configured', async () => { const journal = fakeGoalJournal(); const host = fakeGoalTurnHost(); diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index d111e4448e0..601588975fb 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -26,8 +26,11 @@ import { GOAL_CHECKPOINT_REQUEST_TOO_LARGE_REASON, GOAL_CHECKPOINT_STALL_LIMIT, GOAL_CHECKPOINT_STALLED_REASON, + GOAL_DEFAULT_TOKEN_BUDGET, GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, GOAL_STATE_VERSION, + goalTokenBudgetReason, + isGoalTokenBudgetSpent, isRepeatedBlockerProposal, type GoalControlRequest, type GoalEvidenceCheckpoint, @@ -74,6 +77,15 @@ export interface CreateGoalRuntimeOptions { verifier?: GoalVerifier; checkpointVerifier?: GoalCheckpointVerifier; tokenLedger?: GoalTurnTokenLedger; + /** + * The autonomous spend window one user action (create, edit of a spent + * Goal, or resume of a Goal whose ceiling is spent) arms, in `tokensUsed` + * tokens. Defaults to `GOAL_DEFAULT_TOKEN_BUDGET`; tests shrink it to + * make the bound reachable. A non-finite grant (`Infinity`) opts out: + * Goals are then created unbounded, exactly like Goals persisted before + * budgets existed. + */ + tokenBudgetGrant?: number; } /** @@ -288,6 +300,104 @@ export function createGoalRuntime( } }; + const tokenBudgetGrant = + options.tokenBudgetGrant ?? GOAL_DEFAULT_TOKEN_BUDGET; + + /** + * The shared `usage_limited` settle: every stop builds the same limited + * snapshot, journals it, then commits it in memory and broadcasts. Each + * settling site keeps its own re-entry guard and flag resets around this. + */ + const usageLimitedSnapshot = ( + goal: NonNullable, + reason: string, + limitKind?: GoalLimitKind, + ): GoalSnapshotV2 => { + const now = Date.now(); + return { + v: GOAL_STATE_VERSION, + goal: { + ...goal, + status: 'usage_limited', + activeTimeMs: elapsedActiveTime(goal, now), + updatedAt: now, + lastReason: reason, + ...(limitKind === undefined ? {} : { limitKind }), + }, + activity: 'idle', + }; + }; + + const journalUsageLimitedSettle = async ( + goal: NonNullable, + reason: string, + limitKind?: GoalLimitKind, + ): Promise => { + const limitedSnapshot = usageLimitedSnapshot(goal, reason, limitKind); + await options.journal.recordGoalState(randomUUID(), { + v: GOAL_STATE_VERSION, + cause: 'usage_limited', + snapshot: limitedSnapshot, + }); + return limitedSnapshot; + }; + + const commitUsageLimitedSettle = (limitedSnapshot: GoalSnapshotV2): void => { + continuationQueued = false; + currentTurnFeedback = undefined; + snapshot = structuredClone(limitedSnapshot); + broadcast('usage_limited'); + }; + + /** + * Settle a spent budget instead of minting a continuation. + * + * Runs from `queueContinuation`, the single point every autonomous + * continuation passes through, so one gate bounds every continuation loop + * at once -- turn cadence, verifier-rejection retries, checkpoint cycles, + * and families not yet discovered. User-driven turns never pass through + * here and are never blocked by the budget. + */ + const stopForSpentBudget = () => { + void enqueue(async () => { + const goal = snapshot.goal; + if ( + !goal || + goal.status !== 'active' || + !isGoalTokenBudgetSpent(goal) || + currentPermit || + pendingProposal || + verificationAttempt || + checkpointAttempt + ) { + return; + } + const reason = goalTokenBudgetReason(goal.tokenBudget); + let limitedSnapshot: GoalSnapshotV2; + try { + limitedSnapshot = await journalUsageLimitedSettle( + goal, + reason, + 'token_budget', + ); + } catch { + // A lost settle write must not strand an "active" Goal the gate will + // never continue: the window is spent either way, so show the stop + // and let the user's next action surface the persistence loss. + limitedSnapshot = usageLimitedSnapshot(goal, reason, 'token_budget'); + } + if ( + snapshot.goal?.goalId !== goal.goalId || + snapshot.goal.revision !== goal.revision || + snapshot.goal.status !== 'active' || + currentPermit + ) { + return; + } + commitUsageLimitedSettle(limitedSnapshot); + }).catch(() => undefined); + }; + const withCheckpointStalls = ( goal: NonNullable, checkpointStalls: number, @@ -413,6 +523,10 @@ export function createGoalRuntime( ) { return; } + if (isGoalTokenBudgetSpent(snapshot.goal)) { + stopForSpentBudget(); + return; + } continuationQueued = true; flushContinuation(cause); }; @@ -590,33 +704,16 @@ export function createGoalRuntime( } if (outcome.kind === 'usage_limited') { - const limitedSnapshot: GoalSnapshotV2 = { - v: GOAL_STATE_VERSION, - goal: { - ...snapshot.goal, - status: 'usage_limited', - activeTimeMs: elapsedActiveTime(snapshot.goal, now), - updatedAt: now, - lastReason: outcome.reason, - ...(outcome.limitKind === undefined - ? {} - : { limitKind: outcome.limitKind }), - }, - activity: 'idle', - }; - await options.journal.recordGoalState(randomUUID(), { - v: GOAL_STATE_VERSION, - cause: 'usage_limited', - snapshot: limitedSnapshot, - }); + const limitedSnapshot = await journalUsageLimitedSettle( + snapshot.goal, + outcome.reason, + outcome.limitKind, + ); if (!isCurrentVerificationAttempt(attempt) || !snapshot.goal) return; verificationAttempt = undefined; pendingProposal = undefined; - continuationQueued = false; nextVerifierFeedback = undefined; - currentTurnFeedback = undefined; - snapshot = structuredClone(limitedSnapshot); - broadcast('usage_limited'); + commitUsageLimitedSettle(limitedSnapshot); return undefined; } @@ -824,32 +921,16 @@ export function createGoalRuntime( reason: string, limitKind?: GoalLimitKind, ): Promise => { - const now = Date.now(); - const limitedSnapshot: GoalSnapshotV2 = { - v: GOAL_STATE_VERSION, - goal: { - ...goal, - status: 'usage_limited', - activeTimeMs: elapsedActiveTime(goal, now), - updatedAt: now, - lastReason: reason, - ...(limitKind === undefined ? {} : { limitKind }), - }, - activity: 'idle', - }; - await options.journal.recordGoalState(randomUUID(), { - v: GOAL_STATE_VERSION, - cause: 'usage_limited', - snapshot: limitedSnapshot, - }); + const limitedSnapshot = await journalUsageLimitedSettle( + goal, + reason, + limitKind, + ); if (!isCurrentCheckpointAttempt(attempt) || !snapshot.goal) return; checkpointAttempt = undefined; - continuationQueued = false; // Keep nextVerifierFeedback: a rejection committed before this // checkpoint failure must still reach the resumed continuation. - currentTurnFeedback = undefined; - snapshot = structuredClone(limitedSnapshot); - broadcast('usage_limited'); + commitUsageLimitedSettle(limitedSnapshot); }; /** @@ -1537,6 +1618,7 @@ export function createGoalRuntime( request.action === 'edit' ? { recordId: recordUuid } : options.journal.getTranscriptCursor(), + tokenBudgetGrant, }); const nextSnapshot: GoalSnapshotV2 = { v: GOAL_STATE_VERSION, diff --git a/packages/core/src/goals/goal-tools.test.ts b/packages/core/src/goals/goal-tools.test.ts index 83a6e9ed1ee..b46362ce790 100644 --- a/packages/core/src/goals/goal-tools.test.ts +++ b/packages/core/src/goals/goal-tools.test.ts @@ -145,6 +145,7 @@ describe('GetGoalTool', () => { turnCount: 27, activeTimeMs: 1_763_705, tokensUsed: 4_500, + tokenBudget: 30_000_000, createdAt: 1, updatedAt: 2, lastReason: GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, @@ -176,6 +177,7 @@ describe('GetGoalTool', () => { turnCount: 27, activeTimeMs: 1_763_705, tokensUsed: 4_500, + tokenBudget: 30_000_000, lastReason: GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON, }, }); diff --git a/packages/core/src/goals/goal-tools.ts b/packages/core/src/goals/goal-tools.ts index 4b0d123ed87..9d90e2560d2 100644 --- a/packages/core/src/goals/goal-tools.ts +++ b/packages/core/src/goals/goal-tools.ts @@ -71,6 +71,7 @@ type LastGoalSummary = Pick< | 'turnCount' | 'activeTimeMs' | 'tokensUsed' + | 'tokenBudget' | 'lastReason' >; @@ -139,7 +140,7 @@ export class GetGoalTool extends BaseDeclarativeTool< super( GetGoalTool.Name, ToolDisplayNames.GET_GOAL, - `Read the current Goal identity, objective, evidence cursor, and bounded evidence-reference catalog for this permitted Goal turn. The default "summary" view keeps every read small: checkpoint claims are reported as a count (each claim is already an evidenceCatalog entry with its own preview), entries from this turn and checkpoint entries keep full previews, and entries from earlier turns carry previews shortened to ${SUMMARY_PREVIEW_BYTE_LIMIT} bytes. Every entry uuid is present in both views and is valid for update_goal; request view "full" only when a shortened preview is not enough to decide what to cite. Outside a permitted Goal turn it reports "active": false together with "lastGoal", a scalar summary (goalId, revision, status, turnCount, activeTimeMs, tokensUsed, and lastReason when one was recorded) of the session's most recent Goal, so a Goal that has already stopped can still be inspected. It never returns uncited transcript history or changes Goal state. Use the result silently; do not narrate or acknowledge the retrieval to the user.`, + `Read the current Goal identity, objective, evidence cursor, and bounded evidence-reference catalog for this permitted Goal turn. The default "summary" view keeps every read small: checkpoint claims are reported as a count (each claim is already an evidenceCatalog entry with its own preview), entries from this turn and checkpoint entries keep full previews, and entries from earlier turns carry previews shortened to ${SUMMARY_PREVIEW_BYTE_LIMIT} bytes. Every entry uuid is present in both views and is valid for update_goal; request view "full" only when a shortened preview is not enough to decide what to cite. Outside a permitted Goal turn it reports "active": false together with "lastGoal", a scalar summary (goalId, revision, status, turnCount, activeTimeMs, tokensUsed, plus tokenBudget and lastReason when recorded) of the session's most recent Goal, so a Goal that has already stopped can still be inspected. It never returns uncited transcript history or changes Goal state. Use the result silently; do not narrate or acknowledge the retrieval to the user.`, Kind.Read, { type: 'object', @@ -197,6 +198,9 @@ export class GetGoalTool extends BaseDeclarativeTool< turnCount: goal.turnCount, activeTimeMs: goal.activeTimeMs, tokensUsed: goal.tokensUsed, + ...(goal.tokenBudget === undefined + ? {} + : { tokenBudget: goal.tokenBudget }), ...(goal.lastReason === undefined ? {} : { lastReason: goal.lastReason }), }; } diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index ecee0f9be17..bff65f5c2b4 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -1409,9 +1409,7 @@ describe('UiTelemetryService', () => { service.addEvent(makeApiEvent('model-a', 77), SESSION_B); expect(service.getMetricsForSession(SESSION_B).models).toEqual({}); // ...but the aggregate still counts it. - expect(service.getMetrics().models['model-a']?.api.totalRequests).toBe( - 2, - ); + expect(service.getMetrics().models['model-a']?.api.totalRequests).toBe(2); }); it('keeps the bySource null prototype across restore', () => { diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 22b02452f64..df423367f03 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -39,9 +39,13 @@ export interface TranscriptCursor { * `lastReason` — that stays the human-readable half, this is the half a client * may key behavior off. Resuming an evidence-limited Goal restarts its evidence * window: the objective and revision carry over, but evidence recorded before - * the resume is no longer citable. + * the resume is no longer citable. `token_budget` marks a spent autonomous-spend + * authorization that a resume re-arms. */ -export type GoalLimitKind = 'evidence_catalog' | 'checkpoint_request'; +export type GoalLimitKind = + | 'evidence_catalog' + | 'checkpoint_request' + | 'token_budget'; export interface GoalRecord { goalId: string; diff --git a/packages/web-shell/client/utils/goalGate.test.ts b/packages/web-shell/client/utils/goalGate.test.ts index 62507c3b6bb..d7d4a11a6fc 100644 --- a/packages/web-shell/client/utils/goalGate.test.ts +++ b/packages/web-shell/client/utils/goalGate.test.ts @@ -56,6 +56,11 @@ describe('canResumeGoal', () => { expect(canResumeGoal(goal({ status: 'paused' }))).toBe(true); expect(canResumeGoal(goal({ status: 'blocked' }))).toBe(true); expect(canResumeGoal(goal({ status: 'usage_limited' }))).toBe(true); + expect( + canResumeGoal( + goal({ status: 'usage_limited', limitKind: 'token_budget' }), + ), + ).toBe(true); }); it('decides by status alone, never by stop metadata', () => { diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index a961eda9ccb..b3dc9ff2ee2 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -790,6 +790,46 @@ describe('updateConnectionFromDaemonEvent', () => { }); }); + it('carries a token_budget limitKind through from the wire', () => { + const next = applyEvent( + { status: 'connected', workspaceCwd: '/workspace' }, + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + _meta: { + goalState: { + v: 2, + activity: 'idle', + goal: { + goalId: 'goal-1', + revision: 3, + objective: 'ship it', + status: 'usage_limited', + evidenceCursor: { recordId: 'record-1' }, + turnCount: 2, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 2, + lastReason: 'The Goal spent its autonomous token budget.', + limitKind: 'token_budget', + }, + }, + }, + }, + }, + } as DaemonEvent, + ); + + expect(next.goalState?.goal).toMatchObject({ + status: 'usage_limited', + limitKind: 'token_budget', + }); + }); + it('drops an unknown limitKind rather than passing it through', () => { const next = applyEvent( { status: 'connected', workspaceCwd: '/workspace' }, diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 0930fa1284d..84a71802bc0 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -615,7 +615,9 @@ function getGoalState( const lastReason = getString(source, 'lastReason'); const limitKindRaw = getString(source, 'limitKind'); const limitKind = - limitKindRaw === 'evidence_catalog' || limitKindRaw === 'checkpoint_request' + limitKindRaw === 'evidence_catalog' || + limitKindRaw === 'checkpoint_request' || + limitKindRaw === 'token_budget' ? limitKindRaw : undefined; return {