@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/CLAUDE.md +363 -0
- package/README.md +229 -6
- package/package.json +10 -9
- package/src/agent.ts +287 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +142 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +40 -10
- package/src/index.ts +45 -8
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +260 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +94 -35
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +13 -4
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
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
|
-
|
|
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.
|
|
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 `
|
|
259
|
-
|
|
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": "
|
|
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": "
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/money": "
|
|
38
|
-
"@ultimat3/policy": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
40
|
-
"@ultimat3/time": "
|
|
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
|
+
}
|