@ultimat3/ai 1.2.0 → 2.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.
package/README.md CHANGED
@@ -25,6 +25,46 @@ const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async
25
25
  });
26
26
  ```
27
27
 
28
+ ## Budgets — and which of the three is fleet-wide
29
+
30
+ `request` is one call chain. `actor` and `orgs` are counters across calls, so where they live
31
+ decides what they mean:
32
+
33
+ | `budgetStore` | `actor` / `org` counts | Right for |
34
+ |---|---|---|
35
+ | omitted — `MemoryBudgetStore` (the default) | **per process**, and reset on every deploy | `x dev`, tests, a single-replica app |
36
+ | your own `BudgetStore` | fleet-wide | anything with more than one replica |
37
+
38
+ ```ts
39
+ import { AnthropicProvider, type BudgetStore, createGateway } from '@ultimat3/ai';
40
+
41
+ declare const redis: {
42
+ incrby(key: string, by: number): Promise<number>;
43
+ del(key: string): Promise<unknown>;
44
+ flushdb(): Promise<unknown>;
45
+ };
46
+
47
+ const sharedBudget: BudgetStore = {
48
+ spent: (key) => redis.incrby(key, 0),
49
+ add: async (key, tokens) => {
50
+ await redis.incrby(key, tokens);
51
+ },
52
+ reset: async (key) => {
53
+ await (key === undefined ? redis.flushdb() : redis.del(key));
54
+ },
55
+ };
56
+
57
+ export const sharedGateway = createGateway({
58
+ providers: [new AnthropicProvider()],
59
+ budget: { request: 40_000, actor: 500_000, org: 20_000_000 },
60
+ budgetStore: sharedBudget,
61
+ });
62
+ ```
63
+
64
+ Three methods, and `add` takes a **negative** `tokens` — releasing a reservation the call never
65
+ spent is a credit, so a store that clamps at zero leaks the ceiling. `org: 20_000_000` on the
66
+ default store at `replicas: 6` is six ledgers of twenty million, which is a budget that is not one.
67
+
28
68
  ## Rules the gateway enforces
29
69
 
30
70
  | Rule | Why |
@@ -38,6 +78,10 @@ const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async
38
78
  | A control the model lacks is **refused**, never dropped | a declaration reading `effort: 'max'` that quietly runs at the default is the failure nobody can see |
39
79
  | A control nobody asked for is **omitted**, never defaulted | a default sent as a request is indistinguishable on the wire from one that was declared |
40
80
  | A refusal is `X_LLM_REFUSED`, not a schema failure | it is a 200 with no answer in it, and a repair turn buys the same refusal again |
81
+ | The refusal's `alternative` is only ever a **more capable** model | registration order is most-capable-first and `moreCapableThan` walks it upward; retrying a refusal on a weaker model is the one retry that cannot help, so an unbeatable model gets no suggestion at all |
82
+ | Fallback is across **providers serving one model**, never across models | a silent model swap changes what answered, what it cost and which eval baseline the answer belongs to; the gateway stamps `result.provider`, and `llm()` puts it on the span as `llm.provider`, so the fallback that does exist is never silent |
83
+ | The repair turn replays the tool call's arguments, never an empty `text` | an answer through the `respond` tool leaves `text` empty, and an empty text block is a 400 — the repair came back as `X_AI_PROVIDER_UNAVAILABLE` |
84
+ | `reserve()` **debits** the estimate and takes a turn | three concurrent calls otherwise read the same `spent()`, all pass, and all three record against a ceiling only one of them fitted; `record` reconciles and `release` gives it back |
41
85
  | A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
42
86
  | Retries use **full jitter** | synchronised retries from N workers reproduce the rate limit |
43
87
  | A 4xx is never retried | the same body gets the same rejection and burns the budget |
@@ -80,7 +124,36 @@ Vectors are L2-normalised on arrival, so `cosine` stays a dot product. A width o
80
124
  declared `dimension` is `X_VECTOR_DIM_MISMATCH` **before** anything reaches a store — a store
81
125
  half-written at the wrong width has no error to report, only worse answers.
82
126
 
83
- Models, `As of 2026-08`:
127
+ ## The model catalogue is open
128
+
129
+ `ModelId` is a **`string`**, and the catalogue is a registry. Your own gateway, Bedrock, Azure,
130
+ Vertex, a fine-tune, a negotiated rate — all expressible, none needing a fork.
131
+
132
+ ```ts
133
+ registerModel({
134
+ id: 'llama-internal-70b',
135
+ contextWindow: 128_000,
136
+ maxOutput: 8_192,
137
+ inputPerMillion: { minor: 20, currency: 'USD' }, // YOUR price, integer minor units
138
+ outputPerMillion: { minor: 40, currency: 'USD' },
139
+ cacheMinimumTokens: 0,
140
+ reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
141
+ });
142
+
143
+ configureAi({ gateway: createGateway({ providers: [new InternalGatewayProvider()] }) });
144
+ ```
145
+
146
+ **The three built-ins register through this same call.** There is one way to put a model in the
147
+ catalogue, and the default path is the app's path. Re-registering an id replaces its spec and keeps
148
+ its rung — which is how a negotiated enterprise rate is expressed, and why there is no second
149
+ `overrideModel` call. An id nothing registered is `X_AI_MODEL_UNKNOWN` at the first read, naming
150
+ the registered set; that check is what replaced the closed union, so a wrong id is still caught
151
+ without making a right one inexpressible.
152
+
153
+ Registration order is the capability ladder, most capable first — `moreCapableThan` is its only
154
+ reader, and `X_LLM_REFUSED`'s fix line the only thing that acts on it.
155
+
156
+ Built in, `As of 2026-08`:
84
157
 
85
158
  | Model | Context | Max output | Input / MTok | Output / MTok | `effort` | adaptive thinking |
86
159
  |---|---|---|---|---|---|---|
@@ -90,6 +163,76 @@ Models, `As of 2026-08`:
90
163
 
91
164
  The last two columns are data on the spec, not prose: `body()` builds the reasoning half from
92
165
  them, so a downgrade for price cannot become a request the provider rejects.
166
+ `AnthropicProvider.models` is its own list, never the registry's — your internal model is not
167
+ routed to Anthropic.
168
+
169
+ ## The OpenAI **format** — Azure, vLLM, Ollama, your own gateway
170
+
171
+ `openAiProvider()` speaks the OpenAI chat-completions **wire format**, not one vendor. Azure
172
+ OpenAI, vLLM, Ollama, LiteLLM, OpenRouter, Together and most self-hosted company gateways serve
173
+ that format, so "point Ultimate at our internal model gateway" is a `baseUrl` and a `models` list.
174
+
175
+ ```ts
176
+ import { openAiProvider, OPENAI_MODEL_IDS, createGateway, configureAi } from '@ultimat3/ai';
177
+
178
+ // OpenAI itself. `apiKey` takes a `Secret`; OPENAI_API_KEY is read when it is omitted.
179
+ openAiProvider({ apiKey: env.OPENAI_API_KEY, models: [...OPENAI_MODEL_IDS] });
180
+
181
+ // Azure OpenAI — the deployment URL as written, api-version query and all. `models` are
182
+ // DEPLOYMENT names on Azure, and the key rides in `api-key`, not `Authorization`.
183
+ openAiProvider({
184
+ apiKey: env.AZURE_OPENAI_KEY,
185
+ auth: 'api-key',
186
+ baseUrl: 'https://acme.openai.azure.com/openai/deployments/prod?api-version=2026-05-01',
187
+ models: ['prod'],
188
+ });
189
+
190
+ // vLLM / your own gateway, on the cluster. Register the model first — nothing can price an id
191
+ // the catalogue has never heard of.
192
+ openAiProvider({
193
+ apiKey: env.GATEWAY_TOKEN,
194
+ baseUrl: 'https://llm.acme.internal/v1',
195
+ models: ['llama-internal-70b'],
196
+ name: 'acme-gateway', // what `result.provider` and `llm.provider` will say
197
+ headers: { 'x-team': 'platform' },
198
+ });
199
+
200
+ // Ollama, on a laptop. The key is required and ignored, exactly as Ollama's own docs have it.
201
+ openAiProvider({ apiKey: 'ollama', baseUrl: 'http://localhost:11434/v1', models: ['qwen3'] });
202
+ ```
203
+
204
+ Priced built-ins — list price from `developers.openai.com/api/docs/pricing`, read **2026-08-16**:
205
+
206
+ | Model | Context | Max output | Input / MTok | Output / MTok | `reasoning_effort` |
207
+ |---|---|---|---|---|---|
208
+ | `gpt-5.6-sol` | 1.05M | 128K | $5 | $30 | yes |
209
+ | `gpt-5.6-terra` | 1.05M | 128K | $2 | $12 | yes |
210
+ | `gpt-5.6-luna` | 1.05M | 128K | $0.20 | $1.20 | yes |
211
+
212
+ Three, and no more, on purpose: `gpt-4o` and the `o1` family cache at **0.5x** input where `costOf`
213
+ assumes 0.1x, and the `pro` tiers publish no cached rate at all. A wrong price is worse than a
214
+ missing one — `costOf` answers confidently either way, and the missing entry says so with
215
+ `X_AI_MODEL_UNKNOWN`. Register those yourself, at the rate your own contract names.
216
+
217
+ | Rule | Why |
218
+ |---|---|
219
+ | **Structured output is the `respond` tool**, never `response_format` | `llm()` already projects `output` into one tool and reads the answer out of the tool call; `json_schema` + `strict` would be a second structured-output path (axiom 1) and is the one feature most OpenAI-*compatible* servers do not implement |
220
+ | `tool_choice` is forced when the request offers **exactly one** tool | one tool is nothing to choose between, and that is precisely `llm()`'s shape. A tool loop (`agent()`) is never forced — that would decide the model's next step for it |
221
+ | `strict: true` is claimed only when the schema **can keep the promise** | on this wire `strict` is checked by the server: one optional field and the request is a 400. The flag is derived from the projected schema, never forwarded |
222
+ | `max_completion_tokens`, never `max_tokens` | the old field is rejected outright by every current reasoning model |
223
+ | `stream_options: { include_usage: true }` on every streamed call | without it the final chunk carries no `usage`, and the budget reconciles a real call against nothing |
224
+ | Usage absent anyway → **estimated**, never zero | a compatible server that ignores `stream_options` would otherwise refund the whole reservation |
225
+ | `prompt_tokens` minus `cached_tokens` is the input count | this format counts the cached prefix inside `prompt_tokens`; Anthropic's excludes it, and reporting it as-is bills the cached half twice |
226
+ | Tool-call deltas are merged by `tool_calls[].index` | id and name arrive on the first fragment only — merging by array position builds one call per chunk |
227
+ | A tool call is emitted **whole**, at the finish reason | there is no per-block stop event here, and a fragment is not an argument list |
228
+ | `role: 'system'`, not `developer` | every other server in the family knows only `system`, and OpenAI accepts it |
229
+ | A refusal (`message.refusal`, or `finish_reason: 'content_filter'`) is `X_LLM_REFUSED` | it is a 200 with no answer in it, exactly as on the Anthropic path |
230
+ | The API key is revealed as late as possible, and scrubbed out of error detail | a proxy that echoes request headers into its 4xx body is the one path by which a key reaches a log index |
231
+
232
+ `thinking` maps onto the one field this format has: `'disabled'` is `reasoning_effort: 'none'`,
233
+ `effort` is `reasoning_effort` as written, and asking for both is `X_AI_REQUEST_INVALID` rather
234
+ than a silent pick. A model registered with `reasoning: { effort: false }` refuses both locally, so
235
+ a llama behind vLLM never gets a field it would reject.
93
236
 
94
237
  ## `llm()` — a model call, declared as an action
95
238
 
@@ -124,7 +267,73 @@ summarize.contract(); // the contract tests
124
267
  | `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
125
268
  | `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation |
126
269
  | `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
127
- | `vars` | the one declared place a model call loads data, so a reader can see what was sent |
270
+ | `vars` | the one declared place a model call loads data, so a reader can see what was sent — and the one place a redactor sees it, and where a `Secret` is refused |
271
+
272
+ ### Streaming is the same action
273
+
274
+ ```ts
275
+ for await (const chunk of summarize.stream({ postId }, { ctx })) {
276
+ if (chunk.type === 'text') write(chunk.text);
277
+ if (chunk.type === 'done') save(chunk.value); // validated against `output`
278
+ }
279
+ ```
280
+
281
+ Policy, input parse, budget scope, semantic cache, span, audit and `.tool()` all still apply: the
282
+ invocation is an ordinary one, marked so the model half streams. Two consequences worth knowing:
283
+
284
+ | Decision | Why |
285
+ |---|---|
286
+ | the `done` chunk carries the validated value; text increments are **unvalidated** | a schema cannot be checked until the last token has landed |
287
+ | **no repair turn** — a bad shape is `X_LLM_STREAM_INVALID` | the consumer has already read the tokens; a second answer over the top is two answers to one question. The fix names the non-streaming call |
288
+ | the budget is reserved **before the first token** and reconciled at `done` | unchanged from `generate()`; a stream that throws or is abandoned releases in a `finally` |
289
+ | no `respond` tool is offered | a tool call is emitted whole, so forcing one leaves nothing to stream — the answer is prose, and its JSON parse is what a non-string `output` validates |
290
+ | lazy | nothing is authorised, budgeted or sent until the first pull |
291
+
292
+ ## `agent()` — the tool loop, also an action
293
+
294
+ The second half of "no ninth primitive": a tool-using run is still one server-authoritative
295
+ operation with an input schema, an output schema and a policy.
296
+
297
+ ```ts
298
+ export const support = agent({
299
+ input: t.object({ orderId: t.string }),
300
+ output: t.object({ answer: t.string }),
301
+ prompt: supportPrompt,
302
+ vars: ({ input }) => ({ orderId: input.orderId }),
303
+ tools: [lookupOrder, issueRefund], // actions, each mcp.expose
304
+ maxTurns: 6,
305
+ maxToolResultChars: 4_000,
306
+ budget: { tokensPerRun: 200_000, costPerCall: { minor: 50, currency: 'USD' } },
307
+ policy: can('order:support'),
308
+ });
309
+ ```
310
+
311
+ | Rule | Why |
312
+ |---|---|
313
+ | the actor is **`ctx.actor`**, read once, never from the model | this is the mistake a hand-rolled loop ships, and the reason the loop belongs in the framework |
314
+ | a tool that is not `mcp: { expose: true }` is `X_AGENT_TOOL_UNEXPOSED` **at declaration** | a silently dropped tool reads as offered and is not; `isMcpExposed` is the one predicate, so an in-app agent and an external MCP client see the same catalogue |
315
+ | running out of turns is `X_AGENT_MAX_TURNS`, never a partial answer | a half-finished transcript returned as a result is working notes presented as a decision |
316
+ | `budget.tokensPerRun` caps the **whole run** | a single call is bounded by `maxTokens`; a loop is bounded by nothing until this is set |
317
+ | a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
318
+ | **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
319
+
320
+ ## Redaction: one declared seam
321
+
322
+ `vars()` is the one place a model call loads data, so it is the one place anything can sit between
323
+ the row and a third-party endpoint.
324
+
325
+ ```ts
326
+ configureAi({ gateway, redact: (text) => scrubPatientIdentifiers(text) });
327
+ ```
328
+
329
+ The redactor sees the whole rendered prompt and the system prompt — template as well as values,
330
+ because a redactor shown only the values cannot tell a name in a data slot from the same name in an
331
+ instruction. Whether it changed anything is on the span as `llm.redacted`.
332
+
333
+ **What** to remove is yours: a PII classifier is a model choice, so the framework ships the seam
334
+ and not the classifier. The one rule it does enforce, redactor or not: a `Secret` among the
335
+ variables is `X_AI_PROMPT_SECRET`. Not a leak — `Secret` renders `[redacted]` by value — but a
336
+ prompt that reads fine, means something else, and costs full price.
128
337
 
129
338
  The gateway is ambient, installed once at boot — a declaration is evaluated at module scope,
130
339
  long before a provider exists:
@@ -186,7 +395,7 @@ X_EVAL_THRESHOLD: an eval scored below its tolerance
186
395
  cause: eval "summarize" scored 0.667 against a recorded baseline of 1.000
187
396
  (tolerance 0.050) on prompt version summarize@1.0.0 (a3f1…);
188
397
  regressed: overall 0.67 ← 1.00, refund 0.00 ← 1.00
189
- fix: x test summarize to see per-case scores, then fix the prompt — or
398
+ fix: x test eval --filter summarize to see per-case scores, then fix the prompt — or
190
399
  ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff
191
400
  ```
192
401
 
@@ -229,7 +438,14 @@ RRF fuses by *rank*, so the two score scales never have to be reconciled.
229
438
  `PgVectorStore` is the production path: pgvector cosine (`<=>`, HNSW) and Postgres FTS
230
439
  (`websearch_to_tsquery` + `ts_rank_cd`, GIN) in **the same Postgres**, fused by `1/(k+rank)` in
231
440
  one statement. `MemoryVectorStore` is the dev twin — BM25 instead of `ts_rank_cd`, the same RRF,
232
- the same envelope. `store.ddl()` prints the table and both indexes; `x db gen` emits it.
441
+ the same envelope.
442
+
443
+ `store.ddl()` returns one string: `create extension if not exists vector`, the table, and the
444
+ three indexes (hnsw on `embedding`, GIN on `tsv`, GIN on `metadata`). **No command emits it,
445
+ `As of 2026-08`** — `x db gen <name>` diffs `describeEntities()`, a vector store is not an
446
+ `entity()`, and no CLI file references `PgVectorStore` or `ddl()` at all. Split it and paste each
447
+ statement into its own file under `packages/db/migrations/`, exactly as `AUTH_TABLES` is applied,
448
+ then `x db migrate`.
233
449
 
234
450
  ### The scope is the leak-proofing
235
451
 
@@ -251,12 +467,14 @@ nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant
251
467
  ## Tools: the same projection as MCP
252
468
 
253
469
  ```ts
470
+ // `ProjectableAction` — `{ name, mcp?, inputJsonSchema?, run }`, the projection SEAM.
254
471
  const tools = toLlmTools([publishPost, suspendUser]); // only those with mcp.expose
255
472
  const result = await runLlmToolCall(actions, call, actor);
256
473
  ```
257
474
 
258
- An in-app agent and an external MCP agent both end at `action.run`, so they authorize
259
- identically. The actor comes from the request context, never from the model.
475
+ An in-app agent and an external MCP agent both end at the same `invoke` — `run` is the seam that
476
+ carries it, and an action facade has no `.run` of its own. So they authorize identically. The
477
+ actor comes from the request context, never from the model.
260
478
 
261
479
  ## Errors
262
480
 
@@ -266,7 +484,12 @@ identically. The actor comes from the request context, never from the model.
266
484
  | `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains |
267
485
  | `X_AI_GATEWAY_MISSING` | an `llm()` action ran before `configureAi` |
268
486
  | `X_AI_PROMPT_VERSION` | version drift, or a render missing a declared variable |
487
+ | `X_AI_MODEL_UNKNOWN` | a model id nothing called `registerModel` for; names the registered set |
488
+ | `X_AI_PROMPT_SECRET` | `vars()` returned a `Secret`, which would render `[redacted]` into the prompt |
269
489
  | `X_LLM_OUTPUT_INVALID` | the model failed its `output` schema on the answer and on the repair turn |
490
+ | `X_LLM_STREAM_INVALID` | a streamed answer failed its schema, and a stream cannot take a repair turn |
491
+ | `X_AGENT_MAX_TURNS` | an `agent()` used every turn without answering |
492
+ | `X_AGENT_TOOL_UNEXPOSED` | an `agent()` lists an action no MCP surface exposes |
270
493
  | `X_EVAL_THRESHOLD` | an eval scored below its bar |
271
494
  | `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
272
495
  | `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,13 +31,13 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/action": "1.2.0",
34
- "@ultimat3/cache": "1.2.0",
35
- "@ultimat3/core": "1.2.0",
36
- "@ultimat3/db": "1.2.0",
37
- "@ultimat3/money": "1.2.0",
38
- "@ultimat3/policy": "1.2.0",
39
- "@ultimat3/schema": "1.2.0",
40
- "@ultimat3/time": "1.2.0"
34
+ "@ultimat3/action": "2.0.0",
35
+ "@ultimat3/cache": "2.0.0",
36
+ "@ultimat3/core": "2.0.0",
37
+ "@ultimat3/db": "2.0.0",
38
+ "@ultimat3/money": "2.0.0",
39
+ "@ultimat3/policy": "2.0.0",
40
+ "@ultimat3/schema": "2.0.0",
41
+ "@ultimat3/time": "2.0.0"
41
42
  }
42
43
  }
package/src/agent.ts ADDED
@@ -0,0 +1,287 @@
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 } from '@ultimat3/core';
24
+ import { withSpan } from '@ultimat3/core';
25
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
26
+ import { formatIssues, validateAsync } from '@ultimat3/schema';
27
+ import type { BudgetLimits } from './budget';
28
+ import { BudgetLedger, currentBudget, withBudget } from './budget';
29
+ import {
30
+ AgentMaxTurnsError,
31
+ AgentToolUnexposedError,
32
+ LlmOutputInvalidError,
33
+ LlmRefusedError,
34
+ LlmTruncatedError,
35
+ } from './errors';
36
+ import type { LlmBudget } from './llm';
37
+ import { answerAttributes, RESPOND, respondToolFor, structuredOutputOf } from './llm';
38
+ import type { ModelId } from './models';
39
+ import { DEFAULT_MODEL, moreCapableThan } from './models';
40
+ import type { Prompt, PromptVars } from './prompt';
41
+ import type { AiContentBlock, AiMessage, GenerateRequest, GenerateResult } from './provider';
42
+ import { assertNoSecrets } from './redaction';
43
+ import { aiGateway, aiRedactor } from './runtime';
44
+ import type { LlmTool, LlmToolResult, ProjectableAction } from './tools';
45
+ import { runLlmToolCall, toLlmTools } from './tools';
46
+
47
+ /**
48
+ * Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
49
+ * is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
50
+ * grows quadratically in turns rather than linearly.
51
+ */
52
+ const DEFAULT_MAX_TURNS = 8;
53
+
54
+ /** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
55
+ const DEFAULT_MAX_TOKENS = 4_096;
56
+
57
+ /**
58
+ * Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
59
+ * spends the whole context window on turn two and every later turn re-sends it — the transcript
60
+ * is the request, so an untruncated result is billed once per remaining turn.
61
+ */
62
+ const DEFAULT_TOOL_RESULT_CHARS = 4_000;
63
+
64
+ export interface AgentBudget extends LlmBudget {
65
+ /**
66
+ * Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
67
+ * a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
68
+ */
69
+ readonly tokensPerRun?: number;
70
+ }
71
+
72
+ export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
73
+ readonly input: InferOutput<TInput>;
74
+ readonly ctx: Ctx;
75
+ }
76
+
77
+ export interface AgentDef<
78
+ TInput extends StandardSchemaV1,
79
+ TOutput extends StandardSchemaV1,
80
+ V extends PromptVars,
81
+ > {
82
+ readonly model?: ModelId;
83
+ readonly input: TInput;
84
+ readonly output: TOutput;
85
+ readonly prompt: Prompt<V>;
86
+ /** Same contract as `llm()`: the one declared place a run loads data. */
87
+ vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
88
+ /**
89
+ * The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
90
+ * external MCP client is filtered by, so an in-app agent and an external one are offered
91
+ * exactly the same tools. Listing one that is not exposed is refused at declaration rather than
92
+ * dropped, because a tool that reads as offered and silently is not is the worst of both.
93
+ */
94
+ readonly tools: readonly ProjectableAction[];
95
+ /** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
96
+ readonly maxTurns?: number;
97
+ readonly maxToolResultChars?: number;
98
+ readonly budget?: AgentBudget;
99
+ readonly policy: ActionPolicy;
100
+ readonly mcp?: ActionMcp;
101
+ /** Enforced completion ceiling PER TURN. The model never sees it. */
102
+ readonly maxTokens?: number;
103
+ }
104
+
105
+ export function agent<
106
+ TInput extends StandardSchemaV1,
107
+ TOutput extends StandardSchemaV1,
108
+ V extends PromptVars,
109
+ >(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
110
+ const respond = respondToolFor(def.output);
111
+ // At declaration, because the tools are values by then and a run that discovers this at the
112
+ // first request has already been declared, registered and projected as if it worked.
113
+ const offered = toLlmTools(def.tools);
114
+ if (offered.length !== def.tools.length) {
115
+ throw new AgentToolUnexposedError({
116
+ agent: def.prompt.ref,
117
+ tools: def.tools.filter((a) => !offered.some((o) => o.name === a.name)).map((a) => a.name),
118
+ });
119
+ }
120
+ return action<TInput, TOutput>({
121
+ input: def.input,
122
+ output: def.output,
123
+ policy: def.policy,
124
+ ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
125
+ handle: (args) => run(def, respond, offered, args),
126
+ });
127
+ }
128
+
129
+ async function run<
130
+ TInput extends StandardSchemaV1,
131
+ TOutput extends StandardSchemaV1,
132
+ V extends PromptVars,
133
+ >(
134
+ def: AgentDef<TInput, TOutput, V>,
135
+ respond: LlmTool,
136
+ offered: readonly LlmTool[],
137
+ args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
138
+ ): Promise<InferOutput<TOutput>> {
139
+ const { prompt } = def;
140
+ const name = prompt.ref;
141
+ const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
142
+ const vars = await def.vars({ input: args.input, ctx: args.ctx });
143
+ assertNoSecrets(name, vars);
144
+ const redact = aiRedactor();
145
+ const rawPrompt = prompt.render(vars);
146
+ const rendered = redact(rawPrompt);
147
+ const system = prompt.system === undefined ? undefined : redact(prompt.system);
148
+ const maxTurns = def.maxTurns ?? DEFAULT_MAX_TURNS;
149
+ const chars = def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS;
150
+
151
+ return withSpan('ai.agent', async (span) => {
152
+ span.setAttributes({
153
+ 'agent.model': model,
154
+ 'agent.prompt': name,
155
+ 'agent.prompt.hash': prompt.hash,
156
+ 'agent.tools': offered.length,
157
+ 'agent.max_turns': maxTurns,
158
+ 'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
159
+ });
160
+
161
+ const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
162
+ const gateway = aiGateway(name);
163
+ const base: GenerateRequest = {
164
+ model,
165
+ ...(system === undefined ? {} : { system }),
166
+ messages: [],
167
+ maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
168
+ ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
169
+ ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
170
+ tools: [...offered, respond],
171
+ };
172
+
173
+ return withBudget(ledger, async () => {
174
+ // The actor is read ONCE, from the context the request established, and is the only
175
+ // identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
176
+ // name the identity it acts as, and a loop that let it would be an escalation primitive.
177
+ const { actor } = args.ctx;
178
+ let messages: readonly AiMessage[] = [{ role: 'user', content: rendered }];
179
+ let calls = 0;
180
+ let issues: string | undefined;
181
+
182
+ for (let turn = 1; turn <= maxTurns; turn += 1) {
183
+ const result = await gateway.generate({ ...base, messages });
184
+ span.setAttributes({
185
+ 'agent.turns': turn,
186
+ 'agent.tool_calls': calls,
187
+ ...answerAttributes(result),
188
+ });
189
+ assertAnswerable(result, name);
190
+
191
+ const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
192
+ if (requested.length > 0) {
193
+ const results: LlmToolResult[] = [];
194
+ for (const call of requested) results.push(await runLlmToolCall(def.tools, call, actor));
195
+ calls += requested.length;
196
+ messages = [...messages, assistantTurn(result), toolResults(results, chars)];
197
+ continue;
198
+ }
199
+
200
+ const parsed = await validateAsync(def.output, structuredOutputOf(result));
201
+ if (parsed.issues === undefined) return parsed.value;
202
+ // A wrong shape gets another turn like any other, because unlike `llm()` this loop has
203
+ // turns left by construction — and unlike a tool result, the correction is the message.
204
+ issues = formatIssues(parsed.issues).join('; ');
205
+ if (result.stopReason === 'max_tokens') {
206
+ throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
207
+ }
208
+ messages = [...messages, assistantTurn(result), { role: 'user', content: repair(issues) }];
209
+ }
210
+
211
+ // Two different exhaustions, so two different causes: a loop that kept calling tools and
212
+ // never answered is not the same event as one that answered the wrong shape every time.
213
+ if (issues !== undefined) {
214
+ throw new LlmOutputInvalidError({ prompt: name, attempts: maxTurns, issues });
215
+ }
216
+ throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
217
+ });
218
+ });
219
+ }
220
+
221
+ /** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
222
+ function assertAnswerable(result: GenerateResult, name: string): void {
223
+ if (result.stopReason !== 'refusal') return;
224
+ throw new LlmRefusedError({
225
+ prompt: name,
226
+ model: result.model,
227
+ alternative: moreCapableThan(result.model),
228
+ category: result.stopDetails?.category,
229
+ explanation: result.stopDetails?.explanation,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
235
+ * as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
236
+ * because it has no `tool_result` to follow them with, and the API demands one.
237
+ */
238
+ function assistantTurn(result: GenerateResult): AiMessage {
239
+ const blocks: AiContentBlock[] = [];
240
+ if (result.text !== '') blocks.push({ type: 'text', text: result.text });
241
+ for (const call of result.toolCalls) {
242
+ blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
243
+ }
244
+ // A turn with neither text nor a tool call would be an empty content array, which is a 400.
245
+ return blocks.length === 0
246
+ ? { role: 'assistant', content: '(no answer)' }
247
+ : { role: 'assistant', content: blocks };
248
+ }
249
+
250
+ function toolResults(results: readonly LlmToolResult[], chars: number): AiMessage {
251
+ return {
252
+ role: 'user',
253
+ content: results.map((result) => ({
254
+ type: 'tool_result' as const,
255
+ tool_use_id: result.toolUseId,
256
+ content: truncate(result.content, chars),
257
+ // A denial or a failure is an outcome the model should read and react to, flagged so it
258
+ // does not read as data.
259
+ ...(result.isError === true ? { is_error: true } : {}),
260
+ })),
261
+ };
262
+ }
263
+
264
+ /** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
265
+ function truncate(text: string, chars: number): string {
266
+ if (text.length <= chars) return text;
267
+ return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
268
+ }
269
+
270
+ function repair(issues: string): string {
271
+ return `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
272
+ }
273
+
274
+ function limitsOf<
275
+ TInput extends StandardSchemaV1,
276
+ TOutput extends StandardSchemaV1,
277
+ V extends PromptVars,
278
+ >(def: AgentDef<TInput, TOutput, V>): BudgetLimits {
279
+ const budget = def.budget;
280
+ return {
281
+ ...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
282
+ ...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
283
+ // The ledger's `request` scope accumulates across every call made under one ledger, which for
284
+ // a run under `withBudget` is exactly "the whole run".
285
+ ...(budget?.tokensPerRun === undefined ? {} : { request: budget.tokensPerRun }),
286
+ };
287
+ }