@ultimat3/ai 6.0.0 → 8.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 CHANGED
@@ -302,8 +302,9 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
302
302
  - `.stream()` is LAZY. Nothing is authorised, budgeted or sent until the first pull. `named()` is
303
303
  re-narrowed for the same reason `stream` is assigned in place: `action()`'s `named` builds a
304
304
  fresh twin that would silently not stream.
305
- - **`agent()` is a job for the tool loop, and the third instance of the factory rule** (after
306
- `llm()` and `backfill()`) — it returns an `action`, never a ninth primitive. It exists because
305
+ - **`agent()` is a job for the tool loop, and one of the factory rule's instances** — the list is
306
+ `PRIMITIVE_FACTORIES` in `@ultimat3/core`, never an ordinal in a header — it returns an `action`,
307
+ never a ninth primitive. It exists because
307
308
  the alternative is a hand-rolled loop, and a hand-rolled loop is where the dangerous mistake
308
309
  lives: **taking the actor from the model's output.** `ctx.actor` is read once and is the only
309
310
  identity any tool runs as; nothing the model emits can reach it. Bounded by `maxTurns`
@@ -379,8 +380,8 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
379
380
  - `AiMessage.content` widened to `string | readonly AiContentBlock[]` for this: a `tool_result`
380
381
  has to name the `tool_use` it answers and a string has nowhere to put the id. The block field
381
382
  names are the Messages API's, so `body()` passes them through untouched.
382
- - **`hive()` is a fan-out, and the FOURTH instance of the factory rule** (after `llm()`,
383
- `backfill()` and `agent()`) — it returns an `action`, never a ninth primitive. It exists because
383
+ - **`hive()` is a fan-out, and another of the factory rule's instances** (`PRIMITIVE_FACTORIES`
384
+ again) — it returns an `action`, never a ninth primitive. It exists because
384
385
  the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that loop gets four
385
386
  things wrong every time: the actor, the order, the difference between ran-and-failed and
386
387
  never-ran, and the ceiling.
@@ -487,7 +488,20 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
487
488
  - Server-side `fallbacks` (beta) are deliberately NOT sent. The provider speaks the stable
488
489
  `2023-06-01` surface, and a 1.0 package that promises semver cannot pin a beta wire contract;
489
490
  the typed refusal plus the gateway's own model routing is the framework's answer instead.
490
- - `definePrompt` refuses a re-registered version whose hash moved.
491
+ - `definePrompt` refuses a re-registered version whose hash moved, and the hash is taken over
492
+ `@ultimat3/core`'s `canonicalJson` (`As of 2026-08-22`). It was a local sorted-key
493
+ `JSON.stringify` — the framework's third copy of one canonical form — which spells `-0` as `0`
494
+ and every non-finite number as `null`: a schema `default` the model is told about could move
495
+ while the ref did not, and every score already filed goes on claiming to describe the new prompt.
496
+ Ordinary JSON hashes byte-identically between the two, so **no committed baseline is
497
+ invalidated**; `prompt.test.ts` pins one hash literally to keep it that way. An ABSENT schema
498
+ stays the empty string rather than `canonicalJson(undefined)`'s `null`, for the same reason.
499
+ - **A caught value is read with `renderThrowable`, never `error instanceof Error ? error.message :
500
+ String(error)`.** `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap, and a throw there escapes
501
+ the very `catch` that exists to produce a coded refusal — proven against `RemoteEmbedder`, whose
502
+ injected `fetch` made it reachable from app config. Five sites in this package
503
+ (`remote-embedder.ts`, `wire.ts` x2, `openai-wire.ts` x2); `scripts/error-render.ts` cannot see
504
+ any of them, because it reads PARAMETERS typed `unknown` and these are `catch` bindings.
491
505
  - Every eval result carries the prompt hash. A score without one is not a measurement.
492
506
  - An eval gates on the DROP from its recorded baseline, never on an absolute score. An absolute
493
507
  floor fails every eval at once the day a provider ships a slightly different model, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "6.0.0",
3
+ "version": "8.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",
@@ -32,14 +32,14 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "6.0.0",
36
- "@ultimat3/cache": "6.0.0",
37
- "@ultimat3/core": "6.0.0",
38
- "@ultimat3/db": "6.0.0",
39
- "@ultimat3/jobs": "6.0.0",
40
- "@ultimat3/money": "6.0.0",
41
- "@ultimat3/policy": "6.0.0",
42
- "@ultimat3/schema": "6.0.0",
43
- "@ultimat3/time": "6.0.0"
35
+ "@ultimat3/action": "8.0.0",
36
+ "@ultimat3/cache": "8.0.0",
37
+ "@ultimat3/core": "8.0.0",
38
+ "@ultimat3/db": "8.0.0",
39
+ "@ultimat3/jobs": "8.0.0",
40
+ "@ultimat3/money": "8.0.0",
41
+ "@ultimat3/policy": "8.0.0",
42
+ "@ultimat3/schema": "8.0.0",
43
+ "@ultimat3/time": "8.0.0"
44
44
  }
45
45
  }
package/src/agent.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * `agent()` — a multi-turn tool-using model call, declared as an `action`.
3
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
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
6
8
  * still one server-authoritative operation with an input schema, an output schema and a policy —
7
9
  * so this returns an `action`, and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`,
8
10
  * `.contract()` and its manifest row without a line here.
package/src/budget.ts CHANGED
@@ -4,12 +4,13 @@
4
4
  // wrong answer that looks like a real one, and the caller has no signal anything happened.
5
5
  // A thrown X_AI_BUDGET_EXCEEDED with the remaining count is strictly more useful.
6
6
  //
7
- // The carrier is an AsyncLocalStorage so nested calls (a RAG retrieval, a tool call that
8
- // generates, an eval judge) all debit the same ledger without threading it through every
9
- // signature. `node:async_hooks` is used directly because Bun implements it natively and the
10
- // framework's ALS context is established at the HTTP boundary, above this package.
7
+ // The carrier is an async context so nested calls (a RAG retrieval, a tool call that generates,
8
+ // an eval judge) all debit the same ledger without threading it through every signature. It opens
9
+ // through `@ultimat3/core`'s one lazy seam rather than constructing an `AsyncLocalStorage` here: a
10
+ // module-scope `new` threw at EVALUATION in a browser bundle, where the bundler stubs
11
+ // `node:async_hooks` to `{}`, and took every importer of `@ultimat3/ai` with it.
11
12
 
12
- import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import { asyncContext } from '@ultimat3/core';
13
14
  import type { Money } from '@ultimat3/money';
14
15
  import { assertSameCurrency } from '@ultimat3/money';
15
16
  import { AiBudgetExceededError } from './errors';
@@ -307,7 +308,7 @@ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undef
307
308
  return a.minor <= b.minor ? a : b;
308
309
  }
309
310
 
310
- const storage = new AsyncLocalStorage<BudgetLedger>();
311
+ const storage = asyncContext<BudgetLedger>('an AI budget');
311
312
 
312
313
  /** Run `fn` with `ledger` as the ambient budget for everything it awaits. */
313
314
  export function withBudget<T>(ledger: BudgetLedger, fn: () => Promise<T>): Promise<T> {
@@ -316,5 +317,5 @@ export function withBudget<T>(ledger: BudgetLedger, fn: () => Promise<T>): Promi
316
317
 
317
318
  /** The ambient ledger, or `undefined` outside a budget scope (spend is then unmetered). */
318
319
  export function currentBudget(): BudgetLedger | undefined {
319
- return storage.getStore();
320
+ return storage.get();
320
321
  }
package/src/hive.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `hive()` — one action fanned out over many inputs, declared as an `action`.
3
3
  *
4
- * The fourth instance of the framework's factory rule, after `llm()`, `backfill()` and `agent()`:
5
- * a fan-out is still one server-authoritative operation with an input schema, an output schema and
6
- * a policy, so this returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`,
7
- * `.job()`, `.contract()` and its manifest row without a line here.
4
+ * One more instance of the framework's factory rule the whole list is `PRIMITIVE_FACTORIES` in
5
+ * `@ultimat3/core`, and it is a list rather than a sentence because the ordinal a file writes for
6
+ * itself is wrong the day the next one lands and no file can see the others. A fan-out is still
7
+ * one server-authoritative operation with an input schema, an output schema and a policy, so this
8
+ * returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`, `.contract()`
9
+ * and its manifest row without a line here.
8
10
  *
9
11
  * It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
10
12
  * loop gets four things wrong every time: it takes the actor from somewhere other than the request,
package/src/llm-stream.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * async chain — abandoning the iterator stops delivery, never the accounting.
22
22
  */
23
23
 
24
- import { AsyncLocalStorage } from 'node:async_hooks';
24
+ import { asyncContext } from '@ultimat3/core';
25
25
  import { AiTransportError } from './errors';
26
26
  import type { Gateway } from './gateway';
27
27
  import type { GenerateRequest, GenerateResult } from './provider';
@@ -102,7 +102,9 @@ export class LlmSink {
102
102
  }
103
103
  }
104
104
 
105
- const sinks = new AsyncLocalStorage<LlmSink>();
105
+ // Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
106
+ // browser bundle, where the bundler stubs `node:async_hooks` to `{}`.
107
+ const sinks = asyncContext<LlmSink>('an LLM stream sink');
106
108
 
107
109
  /** Mark everything `fn` awaits as a streamed invocation. */
108
110
  export function withLlmSink<T>(sink: LlmSink, fn: () => Promise<T>): Promise<T> {
@@ -111,7 +113,7 @@ export function withLlmSink<T>(sink: LlmSink, fn: () => Promise<T>): Promise<T>
111
113
 
112
114
  /** The sink of the streamed invocation this call belongs to, or `undefined` for a plain one. */
113
115
  export function currentLlmSink(): LlmSink | undefined {
114
- return sinks.getStore();
116
+ return sinks.get();
115
117
  }
116
118
 
117
119
  /**
@@ -6,6 +6,7 @@
6
6
  // order, or stops half way. An LLM response is untrusted input, so every field is parsed and
7
7
  // nothing is cast.
8
8
 
9
+ import { renderThrowable } from '@ultimat3/core';
9
10
  import { AiTransportError } from './errors';
10
11
  import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
11
12
  import type { SseFrame } from './sse';
@@ -148,9 +149,7 @@ function parseArguments(raw: unknown, name: string, provider: string): Record<st
148
149
  } catch (error) {
149
150
  throw malformed(
150
151
  provider,
151
- `tool "${name}" returned arguments that are not JSON: ${
152
- error instanceof Error ? error.message : 'unreadable'
153
- }`,
152
+ `tool "${name}" returned arguments that are not JSON: ${renderThrowable(error)}`,
154
153
  );
155
154
  }
156
155
  }
@@ -321,9 +320,7 @@ export class ChatCompletionStream {
321
320
  } catch (error) {
322
321
  throw malformed(
323
322
  this.provider,
324
- `unreadable "${frame.event}" frame: ${
325
- error instanceof Error ? error.message : 'unreadable'
326
- }`,
323
+ `unreadable "${frame.event}" frame: ${renderThrowable(error)}`,
327
324
  );
328
325
  }
329
326
  }
package/src/prompt.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // So: edit the template, bump the version. Re-registering a version whose hash moved is a
10
10
  // build error, not a warning.
11
11
 
12
+ import { canonicalJson } from '@ultimat3/core';
12
13
  import { AiPromptRenderError, AiPromptVersionError } from './errors';
13
14
  import type { Effort, ModelId, ThinkingMode } from './models';
14
15
  import type { JsonSchema } from './tools';
@@ -141,34 +142,31 @@ function render(template: string, vars: PromptVars, ref: string): string {
141
142
  }
142
143
 
143
144
  /**
144
- * Canonical serialisation then sha256. Keys are written in a fixed order rather than
145
- * `JSON.stringify(object)` so the hash never depends on property insertion order.
145
+ * Canonical serialisation then sha256, over `@ultimat3/core`'s `canonicalJson` the framework's
146
+ * one INJECTIVE form, so a schema that changed cannot hash as the schema it replaced. A local
147
+ * sorted-key `JSON.stringify` was here and spelled `-0` as `0` and every non-finite number as
148
+ * `null`, which is a `default` the model is told about moving under a ref that did not: every eval
149
+ * score already filed goes on claiming to describe the new prompt.
150
+ *
151
+ * An absent schema stays the empty string rather than becoming `canonicalJson(undefined)`'s
152
+ * `null` — that is what keeps every hash already recorded in a baseline the same value.
146
153
  */
147
154
  export function contentHash<V extends PromptVars>(input: DefinePromptInput<V>): string {
148
- const canonical = [
155
+ const fields = [
149
156
  `id:${input.id}`,
150
157
  `version:${input.version}`,
151
158
  `system:${input.system ?? ''}`,
152
159
  `template:${input.template}`,
153
- `input:${stableJson(input.input)}`,
154
- `output:${stableJson(input.output)}`,
160
+ `input:${schemaField(input.input)}`,
161
+ `output:${schemaField(input.output)}`,
155
162
  `model:${input.model ?? ''}`,
156
163
  `effort:${input.effort ?? ''}`,
157
164
  `thinking:${input.thinking ?? ''}`,
158
165
  ].join('\n');
159
166
  const hasher = new Bun.CryptoHasher('sha256');
160
- hasher.update(canonical);
167
+ hasher.update(fields);
161
168
  return hasher.digest('hex').slice(0, 32);
162
169
  }
163
170
 
164
- /** Sorted-key JSON so two structurally equal schemas hash identically. */
165
- function stableJson(value: unknown): string {
166
- if (value === undefined) return '';
167
- return JSON.stringify(value, (_key, val: unknown) => {
168
- if (typeof val !== 'object' || val === null || Array.isArray(val)) return val;
169
- const record = val as Record<string, unknown>;
170
- const sorted: Record<string, unknown> = {};
171
- for (const key of Object.keys(record).sort()) sorted[key] = record[key];
172
- return sorted;
173
- });
174
- }
171
+ const schemaField = (schema: JsonSchema | undefined): string =>
172
+ schema === undefined ? '' : canonicalJson(schema);
@@ -6,7 +6,7 @@
6
6
  // vendor: `baseUrl` selects the provider and nothing else changes. A second class per vendor
7
7
  // would be a second thing to learn for a difference that does not exist on the wire.
8
8
 
9
- import { readWithinLimit } from '@ultimat3/core';
9
+ import { readWithinLimit, renderThrowable } from '@ultimat3/core';
10
10
  import type { Embedder } from './embeddings';
11
11
  import { normalize } from './embeddings';
12
12
  import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
@@ -95,7 +95,11 @@ export class RemoteEmbedder implements Embedder {
95
95
  } catch (error) {
96
96
  throw new AiTransportError({
97
97
  provider: this.name,
98
- detail: `${error instanceof Error ? error.message : 'the request failed before a response'} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
98
+ // `renderThrowable`, never `error instanceof Error` and `.message`: `fetch` is injected
99
+ // and the endpoint is app config, so the rejection is a value this package did not build —
100
+ // and `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap, whose throw escapes this very
101
+ // `catch` and replaces a coded refusal with an uncoded crash.
102
+ detail: `${renderThrowable(error)} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
99
103
  envVar: API_KEY_ENV,
100
104
  });
101
105
  }
package/src/wire.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // with no socket, which is the only way to cover a stream that arrives out of order or stops
8
8
  // half way. Imports from provider.ts are types only — the dependency runs one way.
9
9
 
10
+ import { renderThrowable } from '@ultimat3/core';
10
11
  import { AiTransportError } from './errors';
11
12
  import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
12
13
  import type { SseFrame } from './sse';
@@ -205,9 +206,7 @@ export class MessageStream {
205
206
  } catch (error) {
206
207
  throw new AiTransportError({
207
208
  provider: 'anthropic',
208
- detail: `unreadable "${frame.event}" frame: ${
209
- error instanceof Error ? error.message : String(error)
210
- }`,
209
+ detail: `unreadable "${frame.event}" frame: ${renderThrowable(error)}`,
211
210
  });
212
211
  }
213
212
  }
@@ -275,9 +274,7 @@ export class MessageStream {
275
274
  } catch (error) {
276
275
  throw new AiTransportError({
277
276
  provider: 'anthropic',
278
- detail: `tool "${tool.name}" streamed arguments that are not JSON: ${
279
- error instanceof Error ? error.message : String(error)
280
- }`,
277
+ detail: `tool "${tool.name}" streamed arguments that are not JSON: ${renderThrowable(error)}`,
281
278
  });
282
279
  }
283
280
  }