@ultimat3/ai 30.9.0 → 31.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,123 @@
1
+ /**
2
+ * One model turn's tool calls, run as a BOUNDED fan-out: at most `concurrency` in flight, results in
3
+ * the order the model emitted the calls. The bound is what stands between an injected or confused
4
+ * model asking for N calls and N concurrent invocations — and N pool checkouts — in one process.
5
+ */
6
+
7
+ import type { Actor, Ctx } from '@ultimat3/core';
8
+ import { throwIfAborted } from '@ultimat3/core';
9
+ import type { RunDeadline } from './agent-guards';
10
+ import type { EmitChunk } from './agent-stream';
11
+ import { assistantTurn, toolResultTurn } from './agent-transcript';
12
+ import type { AiMessage, GenerateResult } from './provider';
13
+ import type { LlmToolCall, LlmToolResult, ProjectableAction } from './tools';
14
+ import { runLlmToolCall } from './tools';
15
+
16
+ /**
17
+ * Calls of one turn in flight at once, unless the agent declares `maxToolConcurrency`. Eight keeps
18
+ * the speed-up of a turn that asked for a handful (serial cost 5x wall clock for five) while a turn
19
+ * that asked for fifty cannot drain a database pool sized for the whole process.
20
+ */
21
+ export const DEFAULT_TOOL_CONCURRENCY = 8;
22
+
23
+ export interface ToolBatchOptions {
24
+ readonly concurrency: number;
25
+ readonly discloseErrors?: boolean;
26
+ /** Asked before EACH call starts — the run's abort and deadline checks: a throw starts nothing more. */
27
+ readonly beforeCall?: () => void;
28
+ }
29
+
30
+ /**
31
+ * Runs `calls` with at most `concurrency` in flight. Order is by INDEX, not by completion: each
32
+ * worker writes its result into the call's own slot, so a fast tool answering first cannot be paired
33
+ * with a slow tool's call. A worker takes its next call only after `beforeCall` passed, so a run that
34
+ * was aborted or ran out of time mid-batch starts nothing new.
35
+ */
36
+ export async function runToolBatch(
37
+ tools: readonly ProjectableAction[],
38
+ calls: readonly LlmToolCall[],
39
+ actor: Actor,
40
+ options: ToolBatchOptions,
41
+ ): Promise<readonly LlmToolResult[]> {
42
+ const results: LlmToolResult[] = new Array<LlmToolResult>(calls.length);
43
+ const disclosure =
44
+ options.discloseErrors === undefined ? {} : { discloseErrors: options.discloseErrors };
45
+ let next = 0;
46
+ const worker = async (): Promise<void> => {
47
+ while (next < calls.length) {
48
+ const index = next;
49
+ next += 1;
50
+ const call = calls[index];
51
+ if (call === undefined) continue;
52
+ options.beforeCall?.();
53
+ results[index] = await runLlmToolCall(tools, call, actor, disclosure);
54
+ }
55
+ };
56
+ const width = Math.max(1, Math.min(options.concurrency, calls.length));
57
+ await Promise.all(Array.from({ length: width }, worker));
58
+ return results;
59
+ }
60
+
61
+ /** What one run's tool turns share — fixed before the first turn. */
62
+ export interface ToolRun {
63
+ readonly tools: readonly ProjectableAction[];
64
+ readonly ctx: Ctx;
65
+ /** Read ONCE from `ctx`, never from the model: the only identity any tool runs as. */
66
+ readonly actor: Actor;
67
+ readonly deadline: RunDeadline;
68
+ readonly emit: EmitChunk;
69
+ readonly concurrency: number;
70
+ readonly discloseErrors: boolean;
71
+ readonly maxTurns: number;
72
+ readonly chars: number;
73
+ readonly redact: (text: string) => string;
74
+ readonly repeated: (calls: readonly LlmToolCall[], results: readonly LlmToolResult[]) => void;
75
+ }
76
+
77
+ /**
78
+ * One turn that asked for tools: run them, stream what happened, refuse a repeating loop, and
79
+ * answer with the transcript the next turn sends. `answers` are the same turn's `respond` calls.
80
+ */
81
+ export async function toolTurn(
82
+ run: ToolRun,
83
+ turn: number,
84
+ result: GenerateResult,
85
+ requested: readonly LlmToolCall[],
86
+ answers: readonly LlmToolCall[],
87
+ messages: readonly AiMessage[],
88
+ ): Promise<readonly AiMessage[]> {
89
+ const { deadline, emit } = run;
90
+ const stop = (): void => {
91
+ throwIfAborted(run.ctx);
92
+ deadline.check();
93
+ };
94
+ stop();
95
+ for (const call of requested) emit({ type: 'tool_call', turn, id: call.id, name: call.name });
96
+ const results = await deadline.guard(
97
+ // Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
98
+ deadline.scope(() =>
99
+ runToolBatch(run.tools, requested, run.actor, {
100
+ concurrency: run.concurrency,
101
+ discloseErrors: run.discloseErrors,
102
+ beforeCall: stop,
103
+ }),
104
+ ),
105
+ );
106
+ results.forEach((done, index) => {
107
+ const name = requested[index]?.name ?? '';
108
+ emit({ type: 'tool_result', turn, id: done.toolUseId, name, isError: done.isError === true });
109
+ });
110
+ emit({ type: 'turn', turn, maxTurns: run.maxTurns });
111
+ // Before the next turn is bought: the same calls with the same results cannot lead anywhere a
112
+ // further turn would not also lead.
113
+ run.repeated(requested, results);
114
+ return [
115
+ ...messages,
116
+ assistantTurn(result),
117
+ // Parallel tool use — one turn asking for a tool AND answering — is normal, and the answer is
118
+ // speculative: it was written before the result it asked for existed. So the loop continues,
119
+ // and the answer is REJECTED on the wire rather than dropped, because a replayed `respond`
120
+ // block with no `tool_result` is a 400 and the whole run becomes `X_AI_PROVIDER_UNAVAILABLE`.
121
+ toolResultTurn(results, run.chars, answers, run.redact),
122
+ ];
123
+ }
package/src/agent-turn.ts CHANGED
@@ -4,11 +4,14 @@
4
4
 
5
5
  import type { Span } from '@ultimat3/core';
6
6
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { SUBJECT } from './agent-declared';
7
8
  import type { AgentDef, AgentTurn } from './agent-def';
9
+ import { LlmTruncatedError } from './answer-errors';
8
10
  import { LlmRefusedError } from './errors';
9
11
  import { moreCapableThan } from './models';
10
12
  import type { PromptVars } from './prompt';
11
13
  import type { GenerateRequest, GenerateResult } from './provider';
14
+ import { isTruncated } from './provider';
12
15
 
13
16
  /**
14
17
  * One turn, reported twice: onto the span every run already opens, and to the declaration's own
@@ -26,13 +29,13 @@ export async function observeTurn<
26
29
  span.addEvent('agent.turn', {
27
30
  'agent.turn': event.turn,
28
31
  'agent.turn.tool_calls': event.toolCalls.length,
29
- 'llm.stop': event.stopReason,
30
- 'llm.cost.minor': event.cost.minor,
32
+ 'ultimate.llm.stop': event.stopReason,
33
+ 'ultimate.llm.cost.minor': event.cost.minor,
31
34
  'agent.turn.repair': event.repairOf !== undefined,
32
35
  // What the provider's prompt cache did for this turn — the two numbers that say whether the
33
36
  // stable prefix is really stable.
34
- 'llm.cache.read_tokens': event.usage.cacheReadTokens,
35
- 'llm.cache.write_tokens': event.usage.cacheWriteTokens,
37
+ 'ultimate.llm.cache.read_tokens': event.usage.cacheReadTokens,
38
+ 'ultimate.llm.cache.write_tokens': event.usage.cacheWriteTokens,
36
39
  });
37
40
  await def.onTurn?.(event);
38
41
  }
@@ -43,6 +46,13 @@ export function assertAnswerable(
43
46
  name: string,
44
47
  request: GenerateRequest,
45
48
  ): void {
49
+ // A turn cut off at the ceiling carries arguments that stop wherever the ceiling fell — `{}` on
50
+ // both wires for a call that never reached its first byte — so running a tool on them is a side
51
+ // effect the model did not ask for, and validating them accepts an empty answer for any `output`
52
+ // of optional keys. Refused before a tool runs and before the answer is read.
53
+ if (isTruncated(result.stopReason)) {
54
+ throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens, subject: SUBJECT });
55
+ }
46
56
  if (result.stopReason !== 'refusal') return;
47
57
  throw new LlmRefusedError({
48
58
  prompt: name,
package/src/agent.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * `agent()` — a multi-turn tool-using model call, declared as an `action`.
3
3
  *
4
- * One more instance of the framework's rule — the whole list is `PRIMITIVE_FACTORIES` in
5
- * `@ultimat3/core`: a new capability arrives as a FACTORY over an existing primitive, never as a
6
- * ninth kind, and the list is what a reader counts instead of a sentence in one of them that
7
- * cannot see the rest. A tool-using run is
8
- * still one server-authoritative operation with an input schema, an output schema and a policy —
9
- * so this returns an `action`, and inherits `.openapi()`, `.client()`, `.job()`, `.contract()`,
10
- * its MCP tool (`@ultimat3/mcp`'s `toolFrom`) and its manifest row without a line here.
4
+ * A FACTORY over an existing primitive (`PRIMITIVE_FACTORIES` in `@ultimat3/core`), never a ninth
5
+ * kind: a tool-using run is still one server-authoritative operation with an input schema, an
6
+ * output schema and a policy — so this returns an `action`, and inherits `.openapi()`, `.client()`,
7
+ * `.job()`, `.contract()`, its MCP tool (`toolFrom`) and its manifest row without a line here.
11
8
  *
12
9
  * It exists because the alternative is a hand-rolled loop outside the framework, and a hand-rolled
13
10
  * loop is where the dangerous mistake lives: taking the ACTOR from the model's output. Here the
@@ -23,7 +20,7 @@
23
20
  import type { Action } from '@ultimat3/action';
24
21
  import { action, noteToolNames } from '@ultimat3/action';
25
22
  import type { Ctx } from '@ultimat3/core';
26
- import { throwIfAborted, withSpan } from '@ultimat3/core';
23
+ import { finiteCount, throwIfAborted, withSpan } from '@ultimat3/core';
27
24
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
28
25
  import { formatIssues, validateAsync } from '@ultimat3/schema';
29
26
  import { checkpointer } from './agent-checkpoint';
@@ -32,14 +29,7 @@ import { compactor } from './agent-compact';
32
29
  import type { CompactPlan } from './agent-compact-def';
33
30
  import { screenCompact } from './agent-compact-def';
34
31
  import type { AgentAction, TranscriptBinding } from './agent-continue';
35
- import {
36
- claimAgentSession,
37
- continued,
38
- screenTranscript,
39
- sealTranscript,
40
- sessionCalls,
41
- withTranscript,
42
- } from './agent-continue';
32
+ import { continued, screenTranscript, sealTranscript, withTranscript } from './agent-continue';
43
33
  import { factsOf, limitsOf, SUBJECT, screenBounds } from './agent-declared';
44
34
  import type { AgentDef } from './agent-def';
45
35
  import { AgentTranscriptInvalidError } from './agent-errors';
@@ -50,20 +40,23 @@ import { project, resolvePerRun } from './agent-per-run';
50
40
  import { assertRunCost } from './agent-run-limits';
51
41
  import type { TurnSend } from './agent-send';
52
42
  import { sendTurn } from './agent-send';
43
+ import { claimAgentSession, sessionCalls } from './agent-session';
53
44
  import type { EmitChunk } from './agent-stream';
54
45
  import { AgentChunk, streamOf } from './agent-stream';
55
46
  import { sameSystem, systemOf } from './agent-system';
56
- import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
47
+ import type { ToolRun } from './agent-tools';
48
+ import { DEFAULT_TOOL_CONCURRENCY, toolTurn } from './agent-tools';
49
+ import { assistantTurn, repairTurn } from './agent-transcript';
57
50
  import { assertAnswerable, observeTurn } from './agent-turn';
58
51
  import { runTotal } from './agent-usage';
59
- import { LlmTruncatedError } from './answer-errors';
60
52
  import { budgetKeysFor, currentBudget, withBudget } from './budget';
61
53
  import { AgentMaxTurnsError, AgentToolUnexposedError, LlmOutputInvalidError } from './errors';
62
- import { answerAttributes } from './llm';
54
+ import { answerAttributes } from './llm-answer';
55
+ import { reportedCall } from './llm-instrument';
63
56
  import type { PromptVars } from './prompt';
64
57
  import { promptCacheHint } from './prompt-cache';
65
58
  import type { AiMessage, GenerateRequest } from './provider';
66
- import { estimateCost, isTruncated } from './provider';
59
+ import { estimateCost } from './provider';
67
60
  import { assertNoSecrets } from './redaction';
68
61
  import type { Respond } from './respond';
69
62
  import { RESPOND, respondFor } from './respond';
@@ -71,8 +64,7 @@ import { runMeter } from './run-meter';
71
64
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
72
65
  import type { Sampling } from './sampling';
73
66
  import { ignoredSamplingAttribute, screenSampling } from './sampling';
74
- import type { LlmToolResult } from './tools';
75
- import { isOfferedTool, runLlmToolCall, toolLabel } from './tools';
67
+ import { isOfferedTool, toolLabel } from './tools';
76
68
 
77
69
  export type { AgentCompaction, AgentCompactOption, AgentCompactRule } from './agent-compact-def';
78
70
  export type {
@@ -111,6 +103,12 @@ export function agent<
111
103
  const owner = {};
112
104
  const sampling = screenSampling(SUBJECT, def);
113
105
  const compact = screenCompact(def.compact, bounds.maxTokens);
106
+ const toolConcurrency = finiteCount(
107
+ SUBJECT,
108
+ 'maxToolConcurrency',
109
+ def.maxToolConcurrency ?? DEFAULT_TOOL_CONCURRENCY,
110
+ 1,
111
+ );
114
112
  // Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
115
113
  // scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
116
114
  // the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
@@ -132,7 +130,7 @@ export function agent<
132
130
  def,
133
131
  respond,
134
132
  await resolvePerRun(def, { ...declared, adapted: adapt() }, args),
135
- { sampling, owner, compact, emit },
133
+ { sampling, owner, compact, emit, toolConcurrency },
136
134
  args,
137
135
  ),
138
136
  ),
@@ -143,10 +141,7 @@ export function agent<
143
141
  return continuable(built, owner);
144
142
  }
145
143
 
146
- /**
147
- * Attach `.run()` and `.continue()` IN PLACE, for the reason `llm()`'s `streamable` gives: the
148
- * object an app exported is the one the registry names, and `named()`'s twin gets them too.
149
- */
144
+ /** `.run()` / `.continue()` IN PLACE (as `llm()`'s `streamable`): the export IS the registry's. */
150
145
  function continuable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
151
146
  target: Action<TInput, TOutput>,
152
147
  owner: object,
@@ -172,6 +167,7 @@ async function run<
172
167
  readonly owner: object;
173
168
  readonly compact: CompactPlan | undefined;
174
169
  readonly emit: EmitChunk;
170
+ readonly toolConcurrency: number;
175
171
  },
176
172
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
177
173
  ): Promise<InferOutput<TOutput>> {
@@ -224,8 +220,8 @@ async function run<
224
220
  'agent.prompt.hash': prompt.hash,
225
221
  'agent.tools': adapted.offered.length,
226
222
  'agent.max_turns': maxTurns,
227
- 'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
228
- 'llm.prompt_cache': promptCache !== undefined,
223
+ 'ultimate.llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
224
+ 'ultimate.llm.prompt_cache': promptCache !== undefined,
229
225
  });
230
226
  // Read when the run ends, not here: the gateway may be what registers the model's row.
231
227
  const reportSampling = (): void => {
@@ -259,7 +255,6 @@ async function run<
259
255
  // Every provider call below goes through the meter, so whatever ends the run — a ceiling, a
260
256
  // deadline, a truncated turn, the caller leaving — is thrown carrying what the run was billed.
261
257
  const meter = runMeter();
262
- const disclosure = { discloseErrors: def.discloseToolErrors !== false };
263
258
  // Worst case: the run so far (continuations and nested calls included) plus this request,
264
259
  // estimated as the gateway's own reservation will be — at the write rate only where a provider
265
260
  // serving the model would mark it. Before every turn, and before a compaction's summary call.
@@ -297,7 +292,9 @@ async function run<
297
292
  compacting,
298
293
  send: (request) => {
299
294
  assertCost(request);
300
- return deadline.guard(meter.call(gateway.generate(request))).catch((error: unknown) => {
295
+ // `reportedCall`: each turn is one model call, so each is one `ai.llm` (`llm-instrument.ts`).
296
+ const answered = reportedCall(request.model ?? model, gateway.generate(request));
297
+ return deadline.guard(meter.call(answered)).catch((error: unknown) => {
301
298
  // The caller left mid-call: the aborted socket reads as a provider failure, which is
302
299
  // RETRYABLE. Say what happened instead.
303
300
  throwIfAborted(args.ctx);
@@ -321,6 +318,19 @@ async function run<
321
318
  // identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
322
319
  // name the identity it acts as, and a loop that let it would be an escalation primitive.
323
320
  const { actor } = args.ctx;
321
+ const tools: ToolRun = {
322
+ tools: adapted.tools,
323
+ ctx: args.ctx,
324
+ actor,
325
+ deadline,
326
+ emit,
327
+ concurrency: fixed.toolConcurrency,
328
+ discloseErrors: def.discloseToolErrors !== false,
329
+ maxTurns,
330
+ chars,
331
+ redact,
332
+ repeated,
333
+ };
324
334
  let messages: readonly AiMessage[] =
325
335
  prior === undefined ? [{ role: 'user', content: rendered }] : continued(prior, rendered);
326
336
  let calls = 0;
@@ -369,68 +379,13 @@ async function run<
369
379
  run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
370
380
  });
371
381
  if (result.text !== '') emit({ type: 'text', turn, text: result.text });
382
+ // Before a tool runs and before the answer is read (`X_LLM_REFUSED`, `X_LLM_TRUNCATED`).
372
383
  assertAnswerable(result, name, base);
373
- // Before a tool runs and before the answer is read: a turn cut off at the ceiling carries
374
- // arguments that stop wherever the ceiling fell — `{}` on both wires for a call that never
375
- // reached its first byte — so running a tool on them is a side effect the model did not
376
- // ask for, and validating them accepts an empty answer for any `output` of optional keys.
377
- if (isTruncated(result.stopReason)) {
378
- throw new LlmTruncatedError({
379
- prompt: name,
380
- maxTokens: base.maxTokens,
381
- subject: SUBJECT,
382
- });
383
- }
384
384
 
385
385
  if (requested.length > 0) {
386
- // Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
387
- // model turn asked for, each entry is an ordinary `action` carrying its own policy and
388
- // its own `rateLimit`, and a second ceiling here would be a throttle competing with
389
- // those. A tool that calls a model still reserves on the same ledger chain, which
390
- // debits before it checks, so the budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
391
- // in the types or the docs ever said so.
392
- //
393
- // Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
394
- // result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
395
- // first cannot be paired with a slow tool's call.
396
- throwIfAborted(args.ctx);
397
- deadline.check();
398
- for (const call of requested)
399
- emit({ type: 'tool_call', turn, id: call.id, name: call.name });
400
- const results: readonly LlmToolResult[] = await deadline.guard(
401
- // Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
402
- deadline.scope(() =>
403
- Promise.all(
404
- requested.map((call) => runLlmToolCall(adapted.tools, call, actor, disclosure)),
405
- ),
406
- ),
407
- );
386
+ messages = await toolTurn(tools, turn, result, requested, answers, messages);
408
387
  calls += requested.length;
409
- results.forEach((done, index) => {
410
- const name = requested[index]?.name ?? '';
411
- emit({
412
- type: 'tool_result',
413
- turn,
414
- id: done.toolUseId,
415
- name,
416
- isError: done.isError === true,
417
- });
418
- });
419
- emit({ type: 'turn', turn, maxTurns });
420
388
  rejected = undefined;
421
- // Before the next turn is bought: the same calls with the same results cannot lead
422
- // anywhere a further turn would not also lead.
423
- repeated(requested, results);
424
- messages = [
425
- ...messages,
426
- assistantTurn(result),
427
- // Parallel tool use — one turn asking for a tool AND answering — is normal, and the
428
- // answer is speculative: it was written before the result it asked for existed. So
429
- // the loop continues, and the answer is REJECTED on the wire rather than dropped,
430
- // because a replayed `respond` block with no `tool_result` is a 400 and the whole run
431
- // becomes an `X_AI_PROVIDER_UNAVAILABLE`.
432
- toolResultTurn(results, chars, answers, redact),
433
- ];
434
389
  await landed(turn, messages, result.model);
435
390
  continue;
436
391
  }
@@ -0,0 +1,82 @@
1
+ // Single responsibility: where cross-request budget counters live — the `BudgetStore` contract
2
+ // (an atomic `take`), the per-process `memoryBudgetStore()`, and the guard a store's answer passes.
3
+ // Split from ./budget (the ledger) at its line ceiling.
4
+
5
+ /**
6
+ * What one `take` found. `spent` is the counter as the store read it, BEFORE this take — so a
7
+ * refusal can say how much was left.
8
+ */
9
+ export interface BudgetTake {
10
+ readonly taken: boolean;
11
+ readonly spent: number;
12
+ }
13
+
14
+ /** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
15
+ export interface BudgetStore {
16
+ spent(key: string): Promise<number> | number;
17
+ /** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
18
+ add(key: string, tokens: number): Promise<void> | void;
19
+ /**
20
+ * Add `tokens` to `key` only if the total stays at or under `limit`, in ONE atomic step — a
21
+ * Redis `EVAL`, a SQL `update … set spent = spent + $2 where key = $1 and spent + $2 <= $3`.
22
+ * Required, with no default built on `spent` + `add`: that pair is the read-then-write two
23
+ * concurrent requests both pass, which is the overspend this member exists to close.
24
+ */
25
+ take(key: string, tokens: number, limit: number): Promise<BudgetTake> | BudgetTake;
26
+ reset(key?: string): Promise<void> | void;
27
+ }
28
+
29
+ /**
30
+ * The per-process default. There is NO window: a counter lives until `reset()` or a restart, so an
31
+ * `actor` / `org` ceiling here is "per process lifetime". It holds one entry per caller that has
32
+ * spent under a DECLARED ceiling (none at all without one), and deliberately no eviction: dropping
33
+ * a counter hands that caller its whole ceiling back, which is the bypass the ceiling exists to
34
+ * stop. A window — per day, per month — is a shared store whose keys expire.
35
+ */
36
+ export class MemoryBudgetStore implements BudgetStore {
37
+ private readonly counters = new Map<string, number>();
38
+
39
+ spent(key: string): number {
40
+ return this.counters.get(key) ?? 0;
41
+ }
42
+
43
+ /** A counter back at zero is deleted: absent and zero are the same answer, and an entry costs. */
44
+ add(key: string, tokens: number): void {
45
+ this.write(key, this.spent(key) + tokens);
46
+ }
47
+
48
+ /** Atomic because it is synchronous: nothing else runs between the read and the write. */
49
+ take(key: string, tokens: number, limit: number): BudgetTake {
50
+ const spent = this.spent(key);
51
+ if (spent + tokens > limit) return { taken: false, spent };
52
+ this.write(key, spent + tokens);
53
+ return { taken: true, spent };
54
+ }
55
+
56
+ /** How many counters are held — one per caller that has spent under a declared ceiling. */
57
+ size(): number {
58
+ return this.counters.size;
59
+ }
60
+
61
+ private write(key: string, total: number): void {
62
+ if (total === 0) this.counters.delete(key);
63
+ else this.counters.set(key, total);
64
+ }
65
+
66
+ reset(key?: string): void {
67
+ if (key === undefined) this.counters.clear();
68
+ else this.counters.delete(key);
69
+ }
70
+ }
71
+
72
+ /** The one way to build the per-process store; the class is a type in the barrel only. */
73
+ export function memoryBudgetStore(): MemoryBudgetStore {
74
+ return new MemoryBudgetStore();
75
+ }
76
+
77
+ /** A store's `take` answer is app code's: read, never trusted. */
78
+ export function isBudgetTake(value: unknown): value is BudgetTake {
79
+ if (typeof value !== 'object' || value === null) return false;
80
+ const record = value as Record<string, unknown>;
81
+ return typeof record['taken'] === 'boolean' && Number.isFinite(record['spent']);
82
+ }
package/src/budget.ts CHANGED
@@ -14,11 +14,18 @@ import type { Actor } from '@ultimat3/core';
14
14
  import { assert, asyncContext, finiteCount } from '@ultimat3/core';
15
15
  import type { Money } from '@ultimat3/money';
16
16
  import { assertSameCurrency, commonScale, exponentOf, minorAt } from '@ultimat3/money';
17
+ import type { BudgetStore } from './budget-store';
18
+ import { isBudgetTake, MemoryBudgetStore } from './budget-store';
17
19
  import { compareMoney, costScale, ledgerUnits, roundUpCost } from './cost';
18
20
  import { AiBudgetExceededError } from './errors';
19
21
  import type { GenerateRequest, TokenUsage } from './provider';
20
22
  import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from './provider';
21
23
 
24
+ // The store half lives in ./budget-store; named here too, so `./budget` stays the one import a
25
+ // ledger reader needs.
26
+ export type { BudgetStore, BudgetTake } from './budget-store';
27
+ export { MemoryBudgetStore, memoryBudgetStore } from './budget-store';
28
+
22
29
  /** Ceilings. An omitted scope is unlimited — declare the ones that matter. */
23
30
  export interface BudgetLimits {
24
31
  /**
@@ -67,30 +74,6 @@ export function estimateSpend(request: GenerateRequest, marked = false): SpendEs
67
74
  };
68
75
  }
69
76
 
70
- /**
71
- * What one `take` found. `spent` is the counter as the store read it, BEFORE this take — so a
72
- * refusal can say how much was left.
73
- */
74
- export interface BudgetTake {
75
- readonly taken: boolean;
76
- readonly spent: number;
77
- }
78
-
79
- /** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
80
- export interface BudgetStore {
81
- spent(key: string): Promise<number> | number;
82
- /** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
83
- add(key: string, tokens: number): Promise<void> | void;
84
- /**
85
- * Add `tokens` to `key` only if the total stays at or under `limit`, in ONE atomic step — a
86
- * Redis `EVAL`, a SQL `update … set spent = spent + $2 where key = $1 and spent + $2 <= $3`.
87
- * Required, with no default built on `spent` + `add`: that pair is the read-then-write two
88
- * concurrent requests both pass, which is the overspend this member exists to close.
89
- */
90
- take(key: string, tokens: number, limit: number): Promise<BudgetTake> | BudgetTake;
91
- reset(key?: string): Promise<void> | void;
92
- }
93
-
94
77
  /**
95
78
  * The store keys a call's identity is counted under: the acting identity (kind and id — one window
96
79
  * per actor, whichever org it acts in) and its org, when it has one. Derived, never taken from a
@@ -129,54 +112,6 @@ export interface BudgetReservation {
129
112
  readonly tokens: number;
130
113
  }
131
114
 
132
- /**
133
- * The per-process default. There is NO window: a counter lives until `reset()` or a restart, so an
134
- * `actor` / `org` ceiling here is "per process lifetime". It holds one entry per caller that has
135
- * spent under a DECLARED ceiling (none at all without one), and deliberately no eviction: dropping
136
- * a counter hands that caller its whole ceiling back, which is the bypass the ceiling exists to
137
- * stop. A window — per day, per month — is a shared store whose keys expire.
138
- */
139
- export class MemoryBudgetStore implements BudgetStore {
140
- private readonly counters = new Map<string, number>();
141
-
142
- spent(key: string): number {
143
- return this.counters.get(key) ?? 0;
144
- }
145
-
146
- /** A counter back at zero is deleted: absent and zero are the same answer, and an entry costs. */
147
- add(key: string, tokens: number): void {
148
- this.write(key, this.spent(key) + tokens);
149
- }
150
-
151
- /** Atomic because it is synchronous: nothing else runs between the read and the write. */
152
- take(key: string, tokens: number, limit: number): BudgetTake {
153
- const spent = this.spent(key);
154
- if (spent + tokens > limit) return { taken: false, spent };
155
- this.write(key, spent + tokens);
156
- return { taken: true, spent };
157
- }
158
-
159
- /** How many counters are held — one per caller that has spent under a declared ceiling. */
160
- size(): number {
161
- return this.counters.size;
162
- }
163
-
164
- private write(key: string, total: number): void {
165
- if (total === 0) this.counters.delete(key);
166
- else this.counters.set(key, total);
167
- }
168
-
169
- reset(key?: string): void {
170
- if (key === undefined) this.counters.clear();
171
- else this.counters.delete(key);
172
- }
173
- }
174
-
175
- /** The one way to build the per-process store; the class is a type in the barrel only. */
176
- export function memoryBudgetStore(): MemoryBudgetStore {
177
- return new MemoryBudgetStore();
178
- }
179
-
180
115
  export interface BudgetLedgerInput {
181
116
  readonly limits: BudgetLimits;
182
117
  /** Stable identity keys. Omit a key to skip that scope even when a limit is set. */
@@ -461,12 +396,6 @@ export function screenedMoney(subject: string, key: string, money: Money): Money
461
396
  return money;
462
397
  }
463
398
 
464
- function isBudgetTake(value: unknown): value is BudgetTake {
465
- if (typeof value !== 'object' || value === null) return false;
466
- const record = value as Record<string, unknown>;
467
- return typeof record['taken'] === 'boolean' && Number.isFinite(record['spent']);
468
- }
469
-
470
399
  /** Spreadable single-key record, so an absent limit stays absent under exactOptionalPropertyTypes. */
471
400
  function pick<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
472
401
  return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
package/src/embeddings.ts CHANGED
@@ -147,5 +147,5 @@ export function cosine(a: Float32Array, b: Float32Array): number {
147
147
  normB += y * y;
148
148
  }
149
149
  const similarity = dot / Math.sqrt(normA * normB);
150
- return similarity > 1 ? 1 : similarity < -1 ? -1 : similarity;
150
+ return Math.min(1, Math.max(-1, similarity));
151
151
  }
package/src/errors.ts CHANGED
@@ -72,7 +72,7 @@ export class AiGatewayMissingError extends UltimateError {
72
72
  // The provider is the app's choice, so no vendor is the default (M12): the fix is the shape
73
73
  // and the cause names what `provider` is — one of the peers, or the app's own.
74
74
  cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured (provider: anthropicProvider({ models }) for the Anthropic Messages format, openAiProvider({ baseUrl, models }) for the OpenAI chat-completions format on any compatible server, or your own Provider)`,
75
- fix: 'configureAi({ gateway: providerGateway({ providers: [provider] }) }) at boot',
75
+ fix: 'export const runtime = { aiGateway: providerGateway({ providers: [provider] }) } # apps/<app>/runtime.ts — in a test, installAiGateway(gateway) from @ultimat3/ai/adapter',
76
76
  });
77
77
  }
78
78
  }
@@ -187,16 +187,16 @@ export class AgentMaxTurnsError extends UltimateError {
187
187
  * than filtered at the call, because a silently dropped tool is the worst of both: the
188
188
  * declaration reads as if the model can call it, and the model is never offered it.
189
189
  *
190
- * `isMcpExposed` is the one predicate — an in-app agent and an external MCP client see exactly
191
- * the same catalogue, which is what keeps "there is no second authz system" true of the catalogue
192
- * too.
190
+ * `isAgentOffered` is the one predicate — an in-app agent sees every action an external MCP client
191
+ * sees, plus the ones declared `agents: true` — so the catalogue is still declared at the action,
192
+ * never filtered here.
193
193
  */
194
194
  export class AgentToolUnexposedError extends UltimateError {
195
195
  constructor(input: { agent: string; tools: readonly string[] }) {
196
196
  super({
197
197
  code: 'X_AGENT_TOOL_UNEXPOSED',
198
198
  cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
199
- fix: "inProcessTool({ name, description, input, execute }) # for a tool only this agent may call — or add mcp: { expose: true } to the action named in cause to offer it to MCP clients too, or drop it from the agent's tools",
199
+ fix: "inProcessTool({ name, description, input, execute }) # for a tool only this agent may call — or add mcp: { expose: false, agents: true } to the action named in cause for this app's agents alone, mcp: { expose: true } to offer it to MCP clients too, or drop it from the agent's tools",
200
200
  });
201
201
  }
202
202
  }
package/src/eval-agent.ts CHANGED
@@ -66,7 +66,7 @@ export function agentPrompt(agent: AnyAction, evalName: string): Prompt {
66
66
  assert(
67
67
  prompt !== undefined,
68
68
  `defineEval "${evalName}" names an agent whose prompt is not registered: an action agent() did not build, a named() twin, or a prompt reset since`,
69
- `registerAction('<name>', agent({ … })) and pass that action to defineEval({ agent })`,
69
+ `export the agent({ … }) from a module defineApi({ actions }) registers, and pass that action to defineEval({ agent })`,
70
70
  );
71
71
  return prompt;
72
72
  }