Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,34 @@ export {
AgentKernel,
SessionManager,
RunManager,
BUDGET_KEYS,
addUsage,
budgetStop,
checkBudget,
createUsage,
resolveBudget,
createRunawayState,
defaultRunawayPolicy,
observeEvidence,
observeRetry,
observeSearchQuery,
observeToolCall,
runawayStop,
type AgentKernelOptions,
type AgentProvider,
type BudgetExhaustion,
type BudgetKey,
type ResolveBudgetInput,
type ResolvedBudget,
type RunBudgetLimits,
type RunBudgetUsage,
type RunStop,
type RunawayDetection,
type RunawayPolicy,
type RunawaySignal,
type RunawayState,
type RunawayStep,
type StopReason,
} from './kernel/index.ts';
export {
LocalFinanceAgentBackend,
Expand Down
30 changes: 30 additions & 0 deletions packages/shared/src/kernel/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
export { AgentKernel, type AgentKernelOptions, type AgentProvider } from './agent-kernel.ts';
export { SessionManager, type SessionManagerOptions } from './session-manager.ts';
export { RunManager, type RunManagerOptions } from './run-manager.ts';
export {
BUDGET_KEYS,
addUsage,
budgetStop,
checkBudget,
createUsage,
resolveBudget,
type BudgetExhaustion,
type BudgetKey,
type ResolveBudgetInput,
type ResolvedBudget,
type RunBudgetLimits,
type RunBudgetUsage,
type RunStop,
type StopReason,
} from './run-budget.ts';
export {
createRunawayState,
defaultRunawayPolicy,
observeEvidence,
observeRetry,
observeSearchQuery,
observeToolCall,
runawayStop,
type RunawayDetection,
type RunawayPolicy,
type RunawaySignal,
type RunawayState,
type RunawayStep,
} from './runaway-detector.ts';
119 changes: 119 additions & 0 deletions packages/shared/src/kernel/run-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'bun:test';
import {
addUsage,
budgetStop,
checkBudget,
createUsage,
resolveBudget,
type RunBudgetUsage,
} from './run-budget.ts';

describe('createUsage', () => {
it('starts every budget key at zero', () => {
expect(createUsage()).toEqual({
wallClockMs: 0,
modelCalls: 0,
toolCalls: 0,
searchIterations: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
});
});
});

describe('resolveBudget', () => {
it('applies overrides on top of defaults', () => {
const { limits, clamped } = resolveBudget({
defaults: { modelCalls: 20, toolCalls: 30 },
overrides: { modelCalls: 50 },
});

expect(limits).toEqual({ modelCalls: 50, toolCalls: 30 });
expect(clamped).toEqual([]);
});

it('lets a run tighten a limit below the default', () => {
const { limits } = resolveBudget({ defaults: { toolCalls: 30 }, overrides: { toolCalls: 5 } });

expect(limits.toolCalls).toBe(5);
});

it('clamps an override to the system ceiling and reports the clamped key', () => {
const { limits, clamped } = resolveBudget({
defaults: { modelCalls: 20 },
overrides: { modelCalls: 1000, costUsd: 999 },
ceiling: { modelCalls: 100, costUsd: 5 },
});

expect(limits).toEqual({ modelCalls: 100, costUsd: 5 });
expect(clamped).toEqual(['modelCalls', 'costUsd']);
});

it('leaves a limit unlimited when no default, override or ceiling sets it', () => {
const { limits } = resolveBudget({ defaults: { modelCalls: 20 } });

expect(limits.toolCalls).toBeUndefined();
expect(limits.costUsd).toBeUndefined();
});

it('clamps a default that already exceeds the ceiling', () => {
const { limits, clamped } = resolveBudget({ defaults: { modelCalls: 100 }, ceiling: { modelCalls: 10 } });

expect(limits.modelCalls).toBe(10);
expect(clamped).toEqual(['modelCalls']);
});

it('fails loudly on a non-positive or non-finite limit', () => {
expect(() => resolveBudget({ overrides: { modelCalls: 0 } })).toThrow(/modelCalls/);
expect(() => resolveBudget({ overrides: { toolCalls: -1 } })).toThrow(/toolCalls/);
expect(() => resolveBudget({ overrides: { costUsd: Number.POSITIVE_INFINITY } })).toThrow(/costUsd/);
expect(() => resolveBudget({ overrides: { wallClockMs: Number.NaN } })).toThrow(/wallClockMs/);
});
});

describe('addUsage', () => {
it('accumulates deltas without mutating the input usage', () => {
const before = createUsage();
const after = addUsage(before, { modelCalls: 2, costUsd: 0.5 });

expect(after.modelCalls).toBe(2);
expect(after.costUsd).toBe(0.5);
expect(before.modelCalls).toBe(0);
expect(before.costUsd).toBe(0);
});

it('fails loudly on a negative delta', () => {
expect(() => addUsage(createUsage(), { toolCalls: -1 })).toThrow(/toolCalls/);
});
});

describe('checkBudget', () => {
const usage: RunBudgetUsage = { ...createUsage(), modelCalls: 5, toolCalls: 3 };

it('reports the first exhausted key in a deterministic order', () => {
const exhaustion = checkBudget({ toolCalls: 3, modelCalls: 5 }, usage);

// wall-clock and the call counters are checked before token/cost keys, so the
// model-call limit wins even though the tool-call limit is listed first above.
expect(exhaustion).toEqual({ key: 'modelCalls', limit: 5, used: 5 });
});

it('treats a limit as exhausted once usage reaches it', () => {
expect(checkBudget({ modelCalls: 6 }, usage)).toBeUndefined();
expect(checkBudget({ modelCalls: 5 }, usage)).toEqual({ key: 'modelCalls', limit: 5, used: 5 });
});

it('ignores keys with no limit and usage that stays under budget', () => {
expect(checkBudget({ costUsd: 10, toolCalls: 10 }, usage)).toBeUndefined();
});
});

describe('budgetStop', () => {
it('turns an exhaustion into a machine-readable stop reason with its detail', () => {
const stop = budgetStop({ key: 'searchIterations', limit: 8, used: 8 });

expect(stop.stopReason).toBe('budget_exhausted');
expect(stop.detail).toEqual({ key: 'searchIterations', limit: 8, used: 8 });
});
});
200 changes: 200 additions & 0 deletions packages/shared/src/kernel/run-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Run budget contract for Agent / Deep Research runs.
*
* A run's ceiling is a contract, not a workflow-local constant: defaults come
* from the application, a run may override them, and a system-level ceiling
* always wins. Exhaustion surfaces as a machine-readable `StopReason` plus the
* key that ran out, so traces, UI and evaluation can explain why a run stopped
* instead of reporting it as an ordinary success.
*/

/** Every dimension a run budget can constrain. */
export type BudgetKey =
| 'wallClockMs'
| 'modelCalls'
| 'toolCalls'
| 'searchIterations'
| 'inputTokens'
| 'outputTokens'
| 'costUsd';

/**
* Budget keys in check order: wall-clock first, then the counters the runtime
* observes directly, then provider-reported usage. `checkBudget` reports the
* first exhausted key in this order, so the same state always yields the same
* reason.
*/
export const BUDGET_KEYS: readonly BudgetKey[] = [
'wallClockMs',
'modelCalls',
'toolCalls',
'searchIterations',
'inputTokens',
'outputTokens',
'costUsd',
];

/** Upper bounds for a run; an absent key is unlimited. */
export type RunBudgetLimits = Partial<Record<BudgetKey, number>>;

/**
* Consumption accumulated so far; every key is always present.
*
* `wallClockMs` is absolute elapsed time since the run started, not a delta, so
* a caller recomputes it from the run's start timestamp at each check; every
* other key accumulates through {@link addUsage}.
*/
export type RunBudgetUsage = Record<BudgetKey, number>;

/** The key that ran out, with the numbers needed to explain it. */
export interface BudgetExhaustion {
key: BudgetKey;
limit: number;
used: number;
}

/**
* Why a run stopped. Machine-readable so traces, run summaries and evaluation
* can branch on it; `completed` is the only success value.
*/
export type StopReason =
| 'completed'
| 'budget_exhausted'
| 'loop_detected'
| 'retry_storm'
| 'cancelled'
| 'error';

/** A run outcome paired with the detail behind a non-success reason. */
export interface RunStop {
stopReason: StopReason;
detail?: Record<string, unknown>;
}

/** Resolution result: the limits a run must obey, and any override the ceiling cut down. */
export interface ResolvedBudget {
limits: RunBudgetLimits;
clamped: BudgetKey[];
}

/** How a caller asks for effective limits. */
export interface ResolveBudgetInput {
defaults?: RunBudgetLimits;
overrides?: RunBudgetLimits;
ceiling?: RunBudgetLimits;
}

/** Zeroed usage; the starting point of every run. */
export function createUsage(): RunBudgetUsage {
return {
wallClockMs: 0,
modelCalls: 0,
toolCalls: 0,
searchIterations: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
}

/**
* Reject a limit the runtime cannot enforce. Fail-loud by design: a silently
* ignored budget is worse than a refused run, because it looks enforced.
* @param key - the budget dimension being validated.
* @param value - the requested limit.
*/
function assertLimit(key: BudgetKey, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new Error(
`run-budget: ${key} must be a positive finite number, received ${String(value)}`,
);
}
}

/**
* Resolve the limits a run must obey: defaults, then per-run overrides, always
* clamped by the system ceiling.
* @param input - defaults, overrides and the ceiling.
* @returns the effective limits and the keys the ceiling cut down.
*/
export function resolveBudget(input: ResolveBudgetInput): ResolvedBudget {
const limits: RunBudgetLimits = {};
const clamped: BudgetKey[] = [];

for (const key of BUDGET_KEYS) {
const ceiling = input.ceiling?.[key];
const override = input.overrides?.[key];
const fallback = input.defaults?.[key];
if (ceiling !== undefined) assertLimit(key, ceiling);
if (override !== undefined) assertLimit(key, override);
if (fallback !== undefined) assertLimit(key, fallback);

const requested = override ?? fallback;
if (requested === undefined) {
if (ceiling !== undefined) limits[key] = ceiling;
continue;
}

const effective = ceiling === undefined ? requested : Math.min(requested, ceiling);
if (effective !== requested) clamped.push(key);
limits[key] = effective;
}

return { limits, clamped };
}

/**
* Accumulate one step's consumption without mutating the previous usage, so a
* run can report the usage of an abandoned branch of work.
* @param usage - usage accumulated so far.
* @param delta - what this step consumed.
* @returns a new usage record.
*/
export function addUsage(usage: RunBudgetUsage, delta: Partial<RunBudgetUsage>): RunBudgetUsage {
const next: RunBudgetUsage = { ...usage };

for (const key of BUDGET_KEYS) {
const value = delta[key];
if (value === undefined) continue;
if (!Number.isFinite(value) || value < 0) {
throw new Error(
`run-budget: ${key} delta must be a non-negative finite number, received ${String(value)}`,
);
}
next[key] = usage[key] + value;
}

return next;
}

/**
* Whether a run has spent its budget.
* @param limits - effective limits; absent keys are unlimited.
* @param usage - consumption so far.
* @returns the first exhausted key in {@link BUDGET_KEYS} order, else undefined.
*/
export function checkBudget(
limits: RunBudgetLimits,
usage: RunBudgetUsage,
): BudgetExhaustion | undefined {
for (const key of BUDGET_KEYS) {
const limit = limits[key];
if (limit === undefined) continue;
if (usage[key] >= limit) return { key, limit, used: usage[key] };
}

return undefined;
}

/**
* Turn an exhaustion into the run's stop reason, keeping the numbers in the
* detail so a summary can say which budget ran out and by how much.
* @param exhaustion - the exhausted budget key.
* @returns a `budget_exhausted` stop with its detail.
*/
export function budgetStop(exhaustion: BudgetExhaustion): RunStop {
return {
stopReason: 'budget_exhausted',
detail: { key: exhaustion.key, limit: exhaustion.limit, used: exhaustion.used },
};
}
Loading
Loading