@ultimat3/ai 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The transcript one agent turn leaves behind: the assistant message replaying what the model
3
+ * emitted, and the user message answering it. One file because the Messages API's rule spans both
4
+ * halves — every `tool_use` block replayed here must be answered by a `tool_result` in the very
5
+ * next message — and a rule split across two call sites is a rule one of them will miss.
6
+ */
7
+
8
+ import { RESPOND } from './llm';
9
+ import type { AiContentBlock, AiMessage, GenerateResult } from './provider';
10
+ import type { LlmToolCall, LlmToolResult } from './tools';
11
+
12
+ /**
13
+ * The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
14
+ * as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
15
+ * because it has no `tool_result` to follow them with, and the API demands one.
16
+ */
17
+ export function assistantTurn(result: GenerateResult): AiMessage {
18
+ const blocks: AiContentBlock[] = [];
19
+ if (result.text !== '') blocks.push({ type: 'text', text: result.text });
20
+ for (const call of result.toolCalls) {
21
+ blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
22
+ }
23
+ // A turn with neither text nor a tool call would be an empty content array, which is a 400.
24
+ return blocks.length === 0
25
+ ? { role: 'assistant', content: '(no answer)' }
26
+ : { role: 'assistant', content: blocks };
27
+ }
28
+
29
+ /**
30
+ * What the model is told when it answered in the same turn it asked for a tool. Parallel tool use
31
+ * is normal, and the answer that comes with it was written before the tool result existed — so it
32
+ * is superseded, not wrong, and saying which is what stops the next turn repeating it verbatim.
33
+ */
34
+ const SUPERSEDED = `This answer arrived in the same turn as the tool calls above, so it was written before their results existed. Read the results in this message, then call the "${RESPOND}" tool again.`;
35
+
36
+ /**
37
+ * The tool results of one turn, plus a `tool_result` for every `respond` call the loop did not
38
+ * accept. The second half is not optional: `respond` is filtered out of the calls that get RUN,
39
+ * but it is replayed as a `tool_use` like any other, and a `tool_use` the next message does not
40
+ * answer is a 400 (`tool_use ids were found without tool_result blocks`) — the whole run lost to
41
+ * an `X_AI_PROVIDER_UNAVAILABLE` because the model did the ordinary thing.
42
+ *
43
+ * Real results stay first: they are what the model asked for, and the rejection reads as the
44
+ * instruction that follows them.
45
+ */
46
+ export function toolResultTurn(
47
+ results: readonly LlmToolResult[],
48
+ chars: number,
49
+ unaccepted: readonly LlmToolCall[],
50
+ ): AiMessage {
51
+ return {
52
+ role: 'user',
53
+ content: [
54
+ ...results.map((result) => ({
55
+ type: 'tool_result' as const,
56
+ tool_use_id: result.toolUseId,
57
+ content: truncate(result.content, chars),
58
+ // A denial or a failure is an outcome the model should read and react to, flagged so it
59
+ // does not read as data.
60
+ ...(result.isError === true ? { is_error: true } : {}),
61
+ })),
62
+ ...rejections(unaccepted, SUPERSEDED),
63
+ ],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The correction after an answer that failed its schema, in whichever shape the turn it corrects
69
+ * demands: a `tool_result` when the answer came through `respond` (the dominant path, and a
70
+ * `tool_use` the API insists on seeing answered), a plain user message when the model answered in
71
+ * prose and there is no id to name. Framework text, so never truncated by `maxToolResultChars`.
72
+ */
73
+ export function repairTurn(unaccepted: readonly LlmToolCall[], issues: string): AiMessage {
74
+ const text = `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
75
+ return unaccepted.length === 0
76
+ ? { role: 'user', content: text }
77
+ : { role: 'user', content: rejections(unaccepted, text) };
78
+ }
79
+
80
+ /** A `tool_result` saying an answer was not taken, flagged so the model does not read it as data. */
81
+ function rejections(calls: readonly LlmToolCall[], text: string): readonly AiContentBlock[] {
82
+ return calls.map((call) => ({
83
+ type: 'tool_result' as const,
84
+ tool_use_id: call.id,
85
+ content: text,
86
+ is_error: true,
87
+ }));
88
+ }
89
+
90
+ /** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
91
+ function truncate(text: string, chars: number): string {
92
+ if (text.length <= chars) return text;
93
+ return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
94
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * `agent()` — a multi-turn tool-using model call, declared as an `action`.
3
+ *
4
+ * The third instance of the framework's rule, after `llm()` and `backfill()`: a new capability
5
+ * arrives as a FACTORY over an existing primitive, never as a ninth kind. A tool-using run is
6
+ * still one server-authoritative operation with an input schema, an output schema and a policy —
7
+ * so this returns an `action`, and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`,
8
+ * `.contract()` and its manifest row without a line here.
9
+ *
10
+ * It exists because the alternative is a hand-rolled loop outside the framework, and a hand-rolled
11
+ * loop is where the dangerous mistake lives: taking the ACTOR from the model's output. Here the
12
+ * actor is `ctx.actor` and nothing the model emits can reach it — `runLlmToolCall` is handed an
13
+ * identity the request established, and the tool it runs is an ordinary action whose own policy
14
+ * decides. There is no "LLM permissions" concept, because there is no second authz system.
15
+ *
16
+ * Deliberately NOT here: a semantic cache. Similar prompts do not have similar answers once the
17
+ * answer depends on what `lookupOrder` returned this second, and a cache over that would serve
18
+ * one run's world state to another. Version bump plus the tools' own caching is the story.
19
+ */
20
+
21
+ import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
22
+ import { action } from '@ultimat3/action';
23
+ import type { Ctx, Span } from '@ultimat3/core';
24
+ import { isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
25
+ import type { Money } from '@ultimat3/money';
26
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
27
+ import { formatIssues, validateAsync } from '@ultimat3/schema';
28
+ import type { AgentFact } from './agent-facts';
29
+ import { registerAgentFact } from './agent-facts';
30
+ import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
31
+ import type { BudgetLimits } from './budget';
32
+ import { BudgetLedger, currentBudget, withBudget } from './budget';
33
+ import {
34
+ AgentMaxTurnsError,
35
+ AgentToolUnexposedError,
36
+ LlmOutputInvalidError,
37
+ LlmRefusedError,
38
+ LlmTruncatedError,
39
+ } from './errors';
40
+ import type { LlmBudget } from './llm';
41
+ import { answerAttributes, RESPOND, respondToolFor, structuredOutputOf } from './llm';
42
+ import type { ModelId } from './models';
43
+ import { DEFAULT_MODEL, moreCapableThan } from './models';
44
+ import type { Prompt, PromptVars } from './prompt';
45
+ import type {
46
+ AiMessage,
47
+ GenerateRequest,
48
+ GenerateResult,
49
+ StopReason,
50
+ TokenUsage,
51
+ } from './provider';
52
+ import { assertNoSecrets } from './redaction';
53
+ import { aiGateway, aiRedactor } from './runtime';
54
+ import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
55
+ import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
56
+
57
+ /**
58
+ * Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
59
+ * is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
60
+ * grows quadratically in turns rather than linearly.
61
+ */
62
+ const DEFAULT_MAX_TURNS = 8;
63
+
64
+ /** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
65
+ const DEFAULT_MAX_TOKENS = 4_096;
66
+
67
+ /**
68
+ * Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
69
+ * spends the whole context window on turn two and every later turn re-sends it — the transcript
70
+ * is the request, so an untruncated result is billed once per remaining turn.
71
+ */
72
+ const DEFAULT_TOOL_RESULT_CHARS = 4_000;
73
+
74
+ export interface AgentBudget extends LlmBudget {
75
+ /**
76
+ * Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
77
+ * a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
78
+ */
79
+ readonly tokensPerRun?: number;
80
+ }
81
+
82
+ export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
83
+ readonly input: InferOutput<TInput>;
84
+ readonly ctx: Ctx;
85
+ }
86
+
87
+ export interface AgentDef<
88
+ TInput extends StandardSchemaV1,
89
+ TOutput extends StandardSchemaV1,
90
+ V extends PromptVars,
91
+ > {
92
+ readonly model?: ModelId;
93
+ readonly input: TInput;
94
+ readonly output: TOutput;
95
+ readonly prompt: Prompt<V>;
96
+ /** Same contract as `llm()`: the one declared place a run loads data. */
97
+ vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
98
+ /**
99
+ * The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
100
+ * external MCP client is filtered by, so an in-app agent and an external one are offered
101
+ * exactly the same tools. Listing one that is not exposed is refused at declaration rather than
102
+ * dropped, because a tool that reads as offered and silently is not is the worst of both.
103
+ */
104
+ readonly tools: readonly AgentTool[];
105
+ /** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
106
+ readonly maxTurns?: number;
107
+ readonly maxToolResultChars?: number;
108
+ readonly budget?: AgentBudget;
109
+ readonly policy: ActionPolicy;
110
+ readonly mcp?: ActionMcp;
111
+ /** Enforced completion ceiling PER TURN. The model never sees it. */
112
+ readonly maxTokens?: number;
113
+ /**
114
+ * Called once per completed model turn, before the answer or the tool calls are acted on. The
115
+ * one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
116
+ * it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
117
+ * hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
118
+ *
119
+ * Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
120
+ * actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
121
+ * run's own path, and an observer that silently stopped working would be indistinguishable from
122
+ * one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
123
+ * declares no hook is still readable in a trace.
124
+ *
125
+ * NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
126
+ * one turn deep by construction; this is per TURN.
127
+ */
128
+ onTurn?(event: AgentTurn): void | Promise<void>;
129
+ }
130
+
131
+ /** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
132
+ export interface AgentTurn {
133
+ /** 1-based, so `turn === maxTurns` is the last one this run will take. */
134
+ readonly turn: number;
135
+ readonly maxTurns: number;
136
+ readonly model: ModelId;
137
+ /** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
138
+ readonly toolCalls: readonly string[];
139
+ readonly stopReason: StopReason;
140
+ readonly usage: TokenUsage;
141
+ /** This turn's cost alone, integer minor units — never the run's running total. */
142
+ readonly cost: Money;
143
+ }
144
+
145
+ export function agent<
146
+ TInput extends StandardSchemaV1,
147
+ TOutput extends StandardSchemaV1,
148
+ V extends PromptVars,
149
+ >(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
150
+ const respond = respondToolFor(def.output);
151
+ // At declaration, because the tools are values by then and a run that discovers this at the
152
+ // first request has already been declared, registered and projected as if it worked. Asked of
153
+ // the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
154
+ // `action()` beside this one in the same module has none until `registerActions` runs at boot.
155
+ const unexposed = def.tools.filter((tool) => !isMcpExposed(tool.mcp));
156
+ if (unexposed.length > 0) {
157
+ throw new AgentToolUnexposedError({
158
+ agent: def.prompt.ref,
159
+ tools: unexposed.map(toolLabel),
160
+ });
161
+ }
162
+ // Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
163
+ // scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
164
+ // the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
165
+ let adapted: Adapted | undefined;
166
+ const adapt = (): Adapted => {
167
+ if (adapted === undefined) adapted = project(def.tools);
168
+ return adapted;
169
+ };
170
+ const built = action<TInput, TOutput>({
171
+ input: def.input,
172
+ output: def.output,
173
+ policy: def.policy,
174
+ ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
175
+ handle: (args) => run(def, respond, adapt(), args),
176
+ });
177
+ // A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
178
+ // line runs at module scope.
179
+ registerAgentFact(built, () => factsOf(def));
180
+ return built;
181
+ }
182
+
183
+ function factsOf<
184
+ TInput extends StandardSchemaV1,
185
+ TOutput extends StandardSchemaV1,
186
+ V extends PromptVars,
187
+ >(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
188
+ const budget = def.budget;
189
+ return {
190
+ prompt: def.prompt.ref,
191
+ promptId: def.prompt.id,
192
+ promptHash: def.prompt.hash,
193
+ model: def.model ?? def.prompt.model ?? DEFAULT_MODEL,
194
+ maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
195
+ maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
196
+ tools: [...def.tools.map(toolLabel)].sort(),
197
+ budget: {
198
+ tokensIn: budget?.tokensIn ?? null,
199
+ tokensPerRun: budget?.tokensPerRun ?? null,
200
+ costPerCall: budget?.costPerCall ?? null,
201
+ },
202
+ mcp: isMcpExposed(def.mcp),
203
+ };
204
+ }
205
+
206
+ /** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
207
+ interface Adapted {
208
+ readonly tools: readonly ProjectableAction[];
209
+ readonly offered: readonly LlmTool[];
210
+ }
211
+
212
+ function project(tools: readonly AgentTool[]): Adapted {
213
+ const projected = tools.map(asProjectableAction);
214
+ return { tools: projected, offered: toLlmTools(projected) };
215
+ }
216
+
217
+ async function run<
218
+ TInput extends StandardSchemaV1,
219
+ TOutput extends StandardSchemaV1,
220
+ V extends PromptVars,
221
+ >(
222
+ def: AgentDef<TInput, TOutput, V>,
223
+ respond: LlmTool,
224
+ adapted: Adapted,
225
+ args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
226
+ ): Promise<InferOutput<TOutput>> {
227
+ const { prompt } = def;
228
+ const name = prompt.ref;
229
+ const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
230
+ const vars = await def.vars({ input: args.input, ctx: args.ctx });
231
+ assertNoSecrets(name, vars);
232
+ const redact = aiRedactor();
233
+ const rawPrompt = prompt.render(vars);
234
+ const rendered = redact(rawPrompt);
235
+ const system = prompt.system === undefined ? undefined : redact(prompt.system);
236
+ const maxTurns = def.maxTurns ?? DEFAULT_MAX_TURNS;
237
+ const chars = def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS;
238
+
239
+ return withSpan('ai.agent', async (span) => {
240
+ span.setAttributes({
241
+ 'agent.model': model,
242
+ 'agent.prompt': name,
243
+ 'agent.prompt.hash': prompt.hash,
244
+ 'agent.tools': adapted.offered.length,
245
+ 'agent.max_turns': maxTurns,
246
+ 'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
247
+ });
248
+
249
+ const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
250
+ const gateway = aiGateway(name);
251
+ const base: GenerateRequest = {
252
+ model,
253
+ ...(system === undefined ? {} : { system }),
254
+ messages: [],
255
+ maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
256
+ ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
257
+ ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
258
+ tools: [...adapted.offered, respond],
259
+ // The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
260
+ // not just the top of the next turn: a provider call already in flight is the expensive one.
261
+ signal: args.ctx.signal,
262
+ };
263
+
264
+ return withBudget(ledger, async () => {
265
+ // The actor is read ONCE, from the context the request established, and is the only
266
+ // identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
267
+ // name the identity it acts as, and a loop that let it would be an escalation primitive.
268
+ const { actor } = args.ctx;
269
+ let messages: readonly AiMessage[] = [{ role: 'user', content: rendered }];
270
+ let calls = 0;
271
+ let issues: string | undefined;
272
+
273
+ for (let turn = 1; turn <= maxTurns; turn += 1) {
274
+ // The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
275
+ // transcript, so a loop that keeps going after a disconnect spends the rest of the run's
276
+ // budget, runs every remaining tool's side effects, and discards the answer.
277
+ throwIfAborted(args.ctx);
278
+ const result = await gateway.generate({ ...base, messages });
279
+ const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
280
+ // The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
281
+ // `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
282
+ const answers = result.toolCalls.filter((call) => call.name === RESPOND);
283
+ span.setAttributes({
284
+ 'agent.turns': turn,
285
+ 'agent.tool_calls': calls,
286
+ ...answerAttributes(result),
287
+ });
288
+ await observeTurn(def, span, {
289
+ turn,
290
+ maxTurns,
291
+ model: result.model,
292
+ toolCalls: requested.map((call) => call.name),
293
+ stopReason: result.stopReason,
294
+ usage: result.usage,
295
+ cost: result.cost,
296
+ });
297
+ assertAnswerable(result, name);
298
+
299
+ if (requested.length > 0) {
300
+ // Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
301
+ // model turn asked for, each entry is an ordinary `action` carrying its own policy and
302
+ // its own `rateLimit`, and a second ceiling here would be a throttle competing with
303
+ // those. A tool that calls a model still queues on the ledger's root turnstile, so the
304
+ // budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
305
+ // in the types or the docs ever said so.
306
+ //
307
+ // Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
308
+ // result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
309
+ // first cannot be paired with a slow tool's call.
310
+ throwIfAborted(args.ctx);
311
+ const results: readonly LlmToolResult[] = await Promise.all(
312
+ requested.map((call) => runLlmToolCall(adapted.tools, call, actor)),
313
+ );
314
+ calls += requested.length;
315
+ messages = [
316
+ ...messages,
317
+ assistantTurn(result),
318
+ // Parallel tool use — one turn asking for a tool AND answering — is normal, and the
319
+ // answer is speculative: it was written before the result it asked for existed. So
320
+ // the loop continues, and the answer is REJECTED on the wire rather than dropped,
321
+ // because a replayed `respond` block with no `tool_result` is a 400 and the whole run
322
+ // becomes an `X_AI_PROVIDER_UNAVAILABLE`.
323
+ toolResultTurn(results, chars, answers),
324
+ ];
325
+ continue;
326
+ }
327
+
328
+ const parsed = await validateAsync(def.output, structuredOutputOf(result));
329
+ if (parsed.issues === undefined) return parsed.value;
330
+ // A wrong shape gets another turn like any other, because unlike `llm()` this loop has
331
+ // turns left by construction — and unlike a tool result, the correction is the message.
332
+ issues = formatIssues(parsed.issues).join('; ');
333
+ if (result.stopReason === 'max_tokens') {
334
+ throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
335
+ }
336
+ messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
337
+ }
338
+
339
+ // Two different exhaustions, so two different causes: a loop that kept calling tools and
340
+ // never answered is not the same event as one that answered the wrong shape every time.
341
+ if (issues !== undefined) {
342
+ throw new LlmOutputInvalidError({ prompt: name, attempts: maxTurns, issues });
343
+ }
344
+ throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
345
+ });
346
+ });
347
+ }
348
+
349
+ /**
350
+ * One turn, reported twice: onto the span every run already opens, and to the declaration's own
351
+ * hook. The span event is free — nothing to install, and a trace is where a stalled run is
352
+ * actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
353
+ *
354
+ * Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
355
+ * path, and an observer that quietly stopped working reads exactly like one that is fine.
356
+ */
357
+ async function observeTurn<
358
+ TInput extends StandardSchemaV1,
359
+ TOutput extends StandardSchemaV1,
360
+ V extends PromptVars,
361
+ >(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
362
+ span.addEvent('agent.turn', {
363
+ 'agent.turn': event.turn,
364
+ 'agent.turn.tool_calls': event.toolCalls.length,
365
+ 'llm.stop': event.stopReason,
366
+ 'llm.cost.minor': event.cost.minor,
367
+ });
368
+ await def.onTurn?.(event);
369
+ }
370
+
371
+ /** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
372
+ function assertAnswerable(result: GenerateResult, name: string): void {
373
+ if (result.stopReason !== 'refusal') return;
374
+ throw new LlmRefusedError({
375
+ prompt: name,
376
+ model: result.model,
377
+ alternative: moreCapableThan(result.model),
378
+ category: result.stopDetails?.category,
379
+ explanation: result.stopDetails?.explanation,
380
+ });
381
+ }
382
+
383
+ function limitsOf<
384
+ TInput extends StandardSchemaV1,
385
+ TOutput extends StandardSchemaV1,
386
+ V extends PromptVars,
387
+ >(def: AgentDef<TInput, TOutput, V>): BudgetLimits {
388
+ const budget = def.budget;
389
+ return {
390
+ ...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
391
+ ...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
392
+ // The ledger's `request` scope accumulates across every call made under one ledger, which for
393
+ // a run under `withBudget` is exactly "the whole run".
394
+ ...(budget?.tokensPerRun === undefined ? {} : { request: budget.tokensPerRun }),
395
+ };
396
+ }
package/src/budget.ts CHANGED
@@ -62,10 +62,20 @@ export function estimateSpend(request: GenerateRequest): SpendEstimate {
62
62
  /** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
63
63
  export interface BudgetStore {
64
64
  spent(key: string): Promise<number> | number;
65
+ /** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
65
66
  add(key: string, tokens: number): Promise<void> | void;
66
67
  reset(key?: string): Promise<void> | void;
67
68
  }
68
69
 
70
+ /**
71
+ * What `reserve` debited, so `record` can reconcile it against the provider's real counts and
72
+ * `release` can give it back. Held by the caller rather than the ledger because one ledger serves
73
+ * every concurrent call in a request, and each one owns its own reservation.
74
+ */
75
+ export interface BudgetReservation {
76
+ readonly tokens: number;
77
+ }
78
+
69
79
  export class MemoryBudgetStore implements BudgetStore {
70
80
  private readonly counters = new Map<string, number>();
71
81
 
@@ -108,6 +118,23 @@ export class BudgetLedger {
108
118
  private requestTokens = 0;
109
119
  private costMinor = 0;
110
120
  private readonly currency: string;
121
+ /**
122
+ * The ledger this one was `derive`d from, or `undefined` for a scope's root. Set by `derive`
123
+ * rather than taken through `BudgetLedgerInput`, so the chain is always the derivation and a
124
+ * caller cannot build a cycle out of it.
125
+ *
126
+ * Without it a derived ledger reported to nobody: `llm()` derives one per call, so the ambient
127
+ * ledger `gateway.scope()` installed counted zero tokens and zero cost however many calls ran
128
+ * inside it, and its `request` ceiling was re-granted in full to every one of them.
129
+ */
130
+ private parent: BudgetLedger | undefined;
131
+ /**
132
+ * Reservations take turns. Check-then-debit spans an `await store.spent()`, and three callers
133
+ * interleaving inside it is the bypass this ledger exists to close — one event loop, so a
134
+ * promise chain IS the lock. A store shared across PROCESSES needs an atomic increment of its
135
+ * own; this closes the parallelism inside one.
136
+ */
137
+ private turnstile: Promise<unknown> = Promise.resolve();
111
138
 
112
139
  constructor(input: BudgetLedgerInput) {
113
140
  this.limits = input.limits;
@@ -118,12 +145,40 @@ export class BudgetLedger {
118
145
  }
119
146
 
120
147
  /**
121
- * Check an estimate against every applicable scope BEFORE the call. Throws on the first
122
- * scope that cannot cover it, naming that scope, so the fix line points at one knob rather
123
- * than four. Nothing is debited here: `record` does that with the provider's real counts.
148
+ * Check an estimate against every applicable scope BEFORE the call, then DEBIT it. Throws on
149
+ * the first scope that cannot cover it, naming that scope, so the fix line points at one knob
150
+ * rather than four.
151
+ *
152
+ * The debit is what makes the ceiling hold under parallelism. Checking without debiting meant
153
+ * three concurrent calls under one ledger all read `spent() === 0`, all passed, and all three
154
+ * recorded against a ceiling only one of them fitted — an "un-bypassable" org budget bypassed
155
+ * by `Promise.all`. `record` replaces the estimate with the real counts; `release` gives it
156
+ * back when the call never happened.
124
157
  */
125
- async reserve(estimate: SpendEstimate): Promise<void> {
126
- this.assertScope('request', this.limits.request, this.requestTokens, estimate.tokens);
158
+ async reserve(estimate: SpendEstimate): Promise<BudgetReservation> {
159
+ // The ROOT's turnstile, not this ledger's: reservations under one scope take turns even when
160
+ // each call derived its own ledger, which is every `llm()` call. A per-ledger queue serialised
161
+ // nothing once `derive` existed — `Promise.all` of three derived ledgers all read the chain
162
+ // before any of them debited it.
163
+ const gate = this.rootLedger();
164
+ const turn = gate.turnstile.then(() => this.reserveNow(estimate));
165
+ // Chained on a settled shadow: one refusal must not reject every reservation queued behind it.
166
+ gate.turnstile = turn.catch(() => undefined);
167
+ return await turn;
168
+ }
169
+
170
+ private rootLedger(): BudgetLedger {
171
+ let ledger: BudgetLedger = this;
172
+ while (ledger.parent !== undefined) ledger = ledger.parent;
173
+ return ledger;
174
+ }
175
+
176
+ private async reserveNow(estimate: SpendEstimate): Promise<BudgetReservation> {
177
+ // Every ledger in the chain, because each keeps its own counter and the tightest limit is not
178
+ // always the one with the most spent against it.
179
+ for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
180
+ l.assertScope('request', l.limits.request, l.requestTokens, estimate.tokens);
181
+ }
127
182
  // Per call, so nothing is "already spent" against it.
128
183
  this.assertScope('tokensIn', this.limits.tokensIn, 0, estimate.inputTokens);
129
184
  if (this.limits.actor !== undefined && this.actorKey !== undefined) {
@@ -135,6 +190,14 @@ export class BudgetLedger {
135
190
  this.assertScope(`org:${this.orgKey}`, this.limits.org, spent, estimate.tokens);
136
191
  }
137
192
  this.assertCost(estimate.cost);
193
+ await this.debit(estimate.tokens);
194
+ return { tokens: estimate.tokens };
195
+ }
196
+
197
+ /** Give a reservation back: a provider that threw, a stream abandoned before `done`. */
198
+ async release(reservation: BudgetReservation | undefined): Promise<void> {
199
+ if (reservation === undefined) return;
200
+ await this.debit(-reservation.tokens);
138
201
  }
139
202
 
140
203
  /**
@@ -143,7 +206,7 @@ export class BudgetLedger {
143
206
  * an `llm()` action must not be able to widen the actor or org ceiling it runs inside.
144
207
  */
145
208
  derive(limits: BudgetLimits): BudgetLedger {
146
- return new BudgetLedger({
209
+ const child = new BudgetLedger({
147
210
  limits: {
148
211
  ...pick('request', tighterNumber(this.limits.request, limits.request)),
149
212
  ...pick('tokensIn', tighterNumber(this.limits.tokensIn, limits.tokensIn)),
@@ -156,13 +219,37 @@ export class BudgetLedger {
156
219
  store: this.store,
157
220
  currency: this.currency,
158
221
  });
222
+ child.parent = this;
223
+ return child;
224
+ }
225
+
226
+ /**
227
+ * Debit ACTUAL usage after the call, replacing the estimate `reserve` worked from — so only
228
+ * the DIFFERENCE lands here. Called without the reservation it behaves as it always did and
229
+ * debits the full amount, which double-counts a reserved call: pass the handle `reserve`
230
+ * returned.
231
+ */
232
+ async record(usage: TokenUsage, cost: Money, reservation?: BudgetReservation): Promise<void> {
233
+ // Up the chain, because `derive` copies the currency: a scope's reported cost is its own
234
+ // calls plus every call made under a ledger derived from it.
235
+ for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
236
+ l.costMinor += cost.minor;
237
+ }
238
+ await this.debit(totalTokens(usage) - (reservation?.tokens ?? 0));
159
239
  }
160
240
 
161
- /** Debit ACTUAL usage after the call, replacing the estimate `reserve` worked from. */
162
- async record(usage: TokenUsage, cost: Money): Promise<void> {
163
- const tokens = totalTokens(usage);
164
- this.requestTokens += tokens;
165
- this.costMinor += cost.minor;
241
+ /**
242
+ * The one write path. Negative credits a release or an over-estimate back.
243
+ *
244
+ * The in-memory counters walk the chain; the STORE is written once, by the ledger the call was
245
+ * made on. A child shares its parent's store and identity keys, so debiting through the parent
246
+ * as well would bill the actor and the org twice for one call.
247
+ */
248
+ private async debit(tokens: number): Promise<void> {
249
+ if (tokens === 0) return;
250
+ for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
251
+ l.requestTokens += tokens;
252
+ }
166
253
  if (this.actorKey !== undefined) await this.store.add(this.actorKey, tokens);
167
254
  if (this.orgKey !== undefined) await this.store.add(this.orgKey, tokens);
168
255
  }