@ultimat3/ai 27.8.1 → 27.10.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
@@ -9,18 +9,13 @@ does), `jobs` (`agentJob()`), `money`, `policy`, `schema`, `time`.
9
9
 
10
10
  `jobs` is declared for `agentJob()`: the action→job bridge has to live at tier 4+.
11
11
 
12
- `mcp` is the same tier, so the LLM-tool projection is restated structurally in `tools.ts`
13
- rather than imported. Same contract, two wire formats — and the same *decision*: `toLlmTools` and
14
- `runLlmToolCall` ask `isMcpExposed` from `@ultimat3/core` (tier 0, reachable by both), never a
15
- local `=== true`. An in-app agent and an external one must be offered exactly the same tools.
16
-
17
- **And the same NAME**: `toLlmTool` passes `action.name` through untouched (the export name
18
- `@ultimat3/mcp` serves); `llm()` and `agent()` tool names are verbatim too. Never derive one.
19
-
20
- **And the same TOOL**: `asProjectableAction` and mcp's `primitiveFromAction` are two copies the
21
- tier table forces; `scripts/ai-mcp-tool-parity.test.ts` holds them equal — name, description,
22
- exposure, the whole input schema (an idempotent action's reserved `idempotencyKey` argument,
23
- `tool-idempotency.ts`), the policy verdict and the replay. A field one adds, the other adds.
12
+ `mcp` is the same tier, so the LLM-tool projection is restated structurally in `tools.ts`, never
13
+ imported: same contract, same *decision* (`isMcpExposed` from `@ultimat3/core`, never a local
14
+ `=== true`), same NAME (`action.name` verbatim, never derived) and the same TOOL —
15
+ `asProjectableAction` and mcp's `primitiveFromAction` are two copies the tier table forces, held
16
+ equal by `scripts/ai-mcp-tool-parity.test.ts` (name, description, exposure, the whole input schema
17
+ incl. `tool-idempotency.ts`'s reserved `idempotencyKey`, the policy verdict, the replay). A field
18
+ one adds, the other adds.
24
19
 
25
20
  ## Owns
26
21
 
@@ -28,8 +23,8 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
28
23
  |---|---|
29
24
  | `models.ts` | the model REGISTRY: `registerModel`, limits, prices, the reasoning controls each one accepts — and NO row of its own |
30
25
  | `model-resolve.ts` | `resolveModel` / `modelForCall` — declaration → prompt → gateway `defaultModel`, none is `X_AI_MODEL_UNRESOLVED` naming all three; `describedModel` for a published fact |
31
- | `model-fixture.ts` | the rows this package's own suites register (`useFixtureModels()`), as an app's `models.ts` would. Not shipped (`!src/**/*-fixture.ts`) |
32
- | `provider.ts` | `Provider` interface, the request half, the money arithmetic, `anthropicProvider()` |
26
+ | `model-fixture.ts` | the rows this package's own suites register (`useFixtureModels()`). Not shipped |
27
+ | `provider.ts` | `Provider` interface, the request half, `anthropicProvider()` |
33
28
  | `echo-provider.ts` | `echoProvider()`, the deterministic double — split from `provider.ts` at its line ceiling |
34
29
  | `content-blocks.ts` | the `image` / `document` block shapes, the screen every request runs them through, their token estimate |
35
30
  | `content-errors.ts` | the `X_AI_CONTENT_UNSUPPORTED` class; its code and title belong in `errors.ts` |
@@ -57,13 +52,21 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
57
52
  | `rag.ts` | chunker, retriever, reranker, budgeted context assembler |
58
53
  | `tools.ts` | action → LLM tool definition; the `AgentTool` union and `asProjectableAction`; `runLlmToolCall` |
59
54
  | `tool-idempotency.ts` | an idempotent action's reserved `idempotencyKey` tool argument — MCP's, restated (same tier), held equal by the parity test |
60
- | `failure-disclosure.ts` | `discloseFailure` — the ONE rule for what of a throw a remote reader sees (tool result, hive member); the withheld half is logged as code, name and stack frames — never its message or cause |
55
+ | `tool-arguments.ts` / `answer-errors.ts` | one argument-string reader for both wires and what an unreadable one means (`assertToolArguments`); `X_LLM_TRUNCATED` + `X_LLM_TOOL_ARGUMENTS_INVALID` |
56
+ | `billed.ts` / `run-meter.ts` | `meta.billed` + `billedOf`; the meter every `llm()`/`agent()` provider call goes through |
57
+ | `failure-disclosure.ts` | `discloseFailure` — the ONE rule for what of a throw a remote reader sees (tool result, hive member); the withheld half is logged, never its message or cause |
61
58
  | `llm.ts` | `llm()` — the model call, declared as an `action`; and what a streamed answer must satisfy |
62
59
  | `respond.ts` | the `respond` tool for one `output` (a non-object one wrapped in `{ value }`), its reader, and `parseJsonish` |
63
60
  | `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
64
61
  | `agent.ts` | `agent()` — the tool loop, declared as an `action` |
62
+ | `agent-def.ts` / `agent-declared.ts` / `agent-system.ts` / `agent-usage.ts` | the declaration's types; its defaults, bound screen, limits and fact row; the system one run sends; its running usage |
63
+ | `llm-def.ts` / `cost.ts` | `llm()`'s declaration types and `LlmCall`; the money arithmetic (`costOf`, `exactCostOf`) |
64
+ | `agent-guards.ts` / `agent-errors.ts` | the repeated-turn detector and the run deadline; their two classes |
65
+ | `prompt-cache.ts` | PROVIDER prompt caching: `SystemParts`, `PromptCacheHint`, `PROMPT_CACHE_RULES` (sources in its header) |
66
+ | `prompt-cache-blocks.ts` | the Anthropic wire's marks (chat-completions': `openai-messages.ts`) |
67
+ | `agent-continue.ts` / `agent-turn.ts` | `.run()` / `.continue()`, the transcript handle and its screen; one turn's report and refusals |
65
68
  | `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
66
- | `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish: nothing reads it yet, because `manifest` is tier 4 like this package and the consumer has to be `cli` at tier 5 |
69
+ | `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish (its consumer has to be `cli`, tier 5) |
67
70
  | `agent-job.ts` | `agentJob()` — an agent as a real `JobHandle`, composed from `job()` |
68
71
  | `hive.ts` | `hive()` — one action fanned out over many inputs, declared as an `action` |
69
72
  | `hive-result.ts` | `HiveMember` / `HiveResult`, and the SCHEMA built from the member's own `output` |
@@ -71,87 +74,69 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
71
74
  | `redaction.ts` | the one gate between `vars()` and the provider: a `Secret` never reaches a prompt |
72
75
  | `eval-errors.ts` | the five `X_EVAL_*` classes; their codes and titles stay in `errors.ts` |
73
76
  | `llm-cache.ts` | the semantic cache half of `llm()`: what a declaration may partition on, and the store it reaches |
74
- | `llm-fixture.ts` | the harness `llm.test.ts` and `llm-cache.test.ts` share. Not shipped (`!src/**/*-fixture.ts`) |
75
- | `bounds-fixture.ts` | the one `refusal`/`asyncRefusal` every numeric-bound suite here asserts through. Not shipped, same rule |
76
- | `agent-bounds.test.ts` | the agent loop's ceilings (split from `agent.test.ts`, at its line ceiling) |
77
+ | `llm-fixture.ts` / `bounds-fixture.ts` | the `llm()` harness; the one `refusal`/`asyncRefusal` of every bound suite. Not shipped (`!src/**/*-fixture.ts`) |
77
78
  | `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
78
- | `fix-line-fixture.ts` / `fix-line.evals-fixture.ts` / `fix-line.v1.baseline.json` / `fix-line.eval.test.ts` | the package's dogfood eval — proof the baseline convention fails a build |
79
+ | `fix-line*` | the package's dogfood eval — proof the baseline convention fails a build |
79
80
 
80
81
  ## Invariants — bounds and budget
81
82
 
82
- - **Every numeric option is screened; `??` is not the screen.** `finiteOption`/`finiteCount` from
83
- `@ultimat3/core` are the one form; `bun run finite-bounds` is the ratchet, and it cannot see a
84
- required option with no `??`, a default parameter or a cross-field product. **`maxTokens` is the
85
- worst**: it IS the budget's pre-flight estimate, and a `NaN` switches every actor and org ceiling
86
- off. `Gateway.generate`/`stream` screen it at the one seam, `registerModel` screens `maxOutput`, and
87
- `llm()`/`agent()` screen theirs at DECLARATION.
88
- - **A bound is screened under the key the DECLARATION uses** (`budget.tokensPerRun` in `limitsOf`
89
- before the ledger's `request`; `retrieve()`'s own `k`; `remoteEmbedder()`'s `maxResponseBytes`).
90
- - A per-call budget `derive`s from the ambient ledger and only TIGHTENS. **A derived ledger reports
91
- back up the chain** (every debit on every ancestor; `reserve` checks each `request` scope); the
92
- STORE is written once, by the ledger the call was made on.
93
- - **`reserve` debits, then checks — never a read-then-write across an `await`.** The in-memory scopes
94
- check and debit synchronously; the store's go through `BudgetStore.take(key, tokens, limit)`, which
95
- is REQUIRED and must be atomic (no default over `spent` + `add` — that pair is the race). A refusal
96
- at a later scope gives back what the earlier ones took.
97
- - **The store is touched only for a scope whose ceiling is DECLARED** (`takeScope`, `meteredKeys`):
98
- every call carries keys, so a write per key grew the default store by one entry per caller.
99
- `memoryBudgetStore()` has no window and no eviction (eviction is a ceiling bypass); a counter back
100
- at zero is deleted.
83
+ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#bounds-and-budget).
84
+
85
+ - **Every numeric option is screened; `??` is not the screen** (`finiteOption`/`finiteCount`;
86
+ `bun run finite-bounds`). **`maxTokens` is the worst**: it IS the pre-flight estimate, and a `NaN`
87
+ switches every ceiling off — screened by the gateway, `registerModel` and `llm()`/`agent()`.
88
+ - **A bound is screened under the key the DECLARATION uses** (`budget.tokensPerRun`, not `request`).
89
+ - A per-call budget `derive`s from the ambient ledger and only TIGHTENS; a derived ledger reports
90
+ back up the chain; the STORE is written once, by the ledger the call was made on.
91
+ - **`reserve` debits, then checks — never a read-then-write across an `await`**:
92
+ `BudgetStore.take(key, tokens, limit)` is REQUIRED and atomic; a later refusal gives back.
93
+ - **The store is touched only for a scope whose ceiling is DECLARED**; `memoryBudgetStore()` has no
94
+ window and no eviction; a zero counter is deleted.
101
95
  - **`llm()`, `agent()`, `hive()` root as `currentBudget() ?? gateway.callLedger(budgetKeysFor(ctx.actor))`**
102
- — the gateway's ceilings, keyed `actor:<kind>:<id>` / `org:<orgId>` (none for an `undefined`/`null`/`''`
103
- org — the `orgless` test `query`'s cache uses). `callLedger` is required on
104
- `Gateway`; `hive()` asks `installedGateway()` so a hive of plain actions needs none.
105
- - **`BudgetStore` is where `actor` and `org` live; the default is per PROCESS**
106
- (`providerGateway({ budgetStore })`). `add` takes a negative `tokens` for a release — a store that
107
- clamps at zero leaks the ceiling.
108
- - Cost is `Money` (integer minor units), rounded **up**. A budget throws `X_AI_BUDGET_EXCEEDED`
109
- **before** the provider call. Never truncate. List price over-reserves (no introductory prices).
110
- - **`Gateway.stream` routes BEFORE it reserves**, and `settled = true` comes after
111
- `await ledger.record(...)`.
96
+ (`actor:<kind>:<id>` / `org:<orgId>`). `callLedger` is required on `Gateway`.
97
+ - **`BudgetStore` is where `actor` and `org` live; the default is per PROCESS**; `add` takes a
98
+ negative `tokens` for a release — a store that clamps at zero leaks the ceiling.
99
+ - A budget throws `X_AI_BUDGET_EXCEEDED` **before** the provider call. Never truncate. List price
100
+ over-reserves.
101
+ - **Cost is exact in bigint (`cost.ts`, `Money` at `costScale`), rounded UP once**: per call by
102
+ `costOf`, per ledger at `report()`, per run from the summed usage — never a sum of round-ups. A
103
+ price may state a `scale` (≤ `PRICE_SCALE_DIGITS` finer); it is always read through `minorAt`.
104
+ - **`Gateway.stream` routes BEFORE it reserves**; `settled = true` comes after `ledger.record`.
112
105
 
113
106
  ## Invariants — `llm()`
114
107
 
108
+ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm-invariants).
109
+
115
110
  - **`llm()` returns an `action`**, never a ninth primitive; it adds only the model half: render the
116
- prompt, project `output` into the one `respond` tool, reserve the budget, consult the semantic cache.
117
- - **A non-object `output` is wrapped in `{ value }` and unwrapped** (`respond.ts`) — a tool's input is
118
- an object; never refused, because `.stream()` serves `output: t.string`. `parseJsonish` is two
119
- `indexOf` scans, never a backtracking pattern.
111
+ prompt, project `output` into the one `respond` tool, reserve the budget, consult the cache.
112
+ - **A non-object `output` is wrapped in `{ value }` and unwrapped** (`respond.ts`), never refused;
113
+ `parseJsonish` is two `indexOf` scans, never a backtracking pattern.
120
114
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts`).
121
115
  - One repair turn, then `X_LLM_OUTPUT_INVALID`. A refusal (a 200 with no answer) is `X_LLM_REFUSED`
122
- before the parse, carrying `stopDetails.category`; a truncated invalid answer is `X_LLM_TRUNCATED`.
123
- The gateway never caches a refusal.
124
- - **Semantic scopes are separate cache INSTANCES**; the instance key carries the prompt hash AND
125
- `ctx.locale` (in the unconditional half). **The default scope is the calling ACTOR**
126
- (`JSON.stringify([actor.kind, actor.id, actor.orgId ?? null])`); `scope` receives
127
- `{ input, ctx }`; widening is `scope: () => 'global'`. The instance map is bounded
128
- (core's `MAX_CACHED_FORMATTERS`).
129
- - `cache.invalidates` is not on `llm()`: a `SemanticCache` is not a `CacheTier`. Version bump + `ttl`
130
- is the invalidation.
131
- - The gateway is ambient (`configureAi`); absent at call time is `X_AI_GATEWAY_MISSING`, never a
132
- default provider.
116
+ before the parse; a truncated turn is `X_LLM_TRUNCATED` before the schema AND before any tool
117
+ runs (a cut `respond` reads `{}`). The gateway never caches a refusal.
118
+ - **Semantic scopes are separate cache INSTANCES** (key: prompt hash AND `ctx.locale`); **the
119
+ default scope is the calling ACTOR**; widening is `scope: () => 'global'`; the map is bounded.
120
+ `cache.invalidates` is not on `llm()`: version bump + `ttl` is the invalidation.
121
+ - The gateway is ambient (`configureAi`); absent at call time is `X_AI_GATEWAY_MISSING`.
133
122
  - **`llm()` forwards `ctx.signal`** onto `GenerateRequest`; `.stream()` inherits it.
134
- - **`.stream()` is the SAME action**, marked with an ambient sink: it yields UNVALIDATED text and one
135
- final `done` with the value that satisfied `output`; no repair turn (`X_LLM_STREAM_INVALID`); the
136
- budget is reserved before the provider, debited on the first pull, reconciled at `done`; no
137
- `respond` tool; a cache hit yields `done` alone. LAZY. `named()` is re-narrowed so the twin still streams.
123
+ - **`.stream()` is the SAME action**, marked with an ambient sink: UNVALIDATED text, then one `done`
124
+ with the validated value; no repair turn (`X_LLM_STREAM_INVALID`), no `respond` tool. LAZY.
138
125
  - **`configureAi({ redact })` is the one seam between `vars()` and the provider**; a `Secret` in
139
- `vars()` is `X_AI_PROMPT_SECRET` regardless. The `llm.redacted` span attribute records it.
126
+ `vars()` is `X_AI_PROMPT_SECRET` regardless.
140
127
  - **Fallback is across PROVIDERS serving one model, never across models**; `GenerateResult.provider`
141
- is stamped by the gateway and put on the span as `llm.provider`.
142
- - Server-side `fallbacks` (beta) are never sent — the stable `2023-06-01` surface only.
143
- - **A slot inside a tag pair is DATA** (`prompt-fence.ts`, #689): `render` breaks, in the ASSEMBLED
144
- prompt, every fence closer a fenced value wrote any character of (never per value: two slots can
145
- each hold half) — never deletes, never touches a template-only closer. The
146
- fences are read once at `definePrompt`; no hash moves.
128
+ is stamped by the gateway. Server-side `fallbacks` (beta) are never sent.
129
+ - **A slot inside a tag pair is DATA** (`prompt-fence.ts`, #689): `render` breaks every fence closer
130
+ a fenced value wrote, in the ASSEMBLED prompt — never deletes, never touches a template's own.
147
131
  - `definePrompt` refuses a re-registered version whose hash moved; the hash is over core's
148
- `canonicalJson` (`prompt.test.ts` pins one hash literally); an absent schema hashes as `''`.
132
+ `canonicalJson`; an absent schema hashes as `''`.
149
133
 
150
134
  ## Invariants — providers and the gateway
151
135
 
152
136
  - **The response cache key is core's `fingerprint` over the request, each tool WHOLE** (every `llm()`
153
137
  tool is `respond`); a throwing `cache.set` is logged (`ai.cache.write_failed`), never a failure.
154
- - **An unknown stop/finish reason FAILS CLOSED as `max_tokens` on both formats**, streamed or not.
138
+ - **An unknown stop reason FAILS CLOSED as `max_tokens` on the Anthropic wire; on chat-completions
139
+ it is UNKNOWN** (`readFinishReason`): a whole turn is a stop, else `X_AI_PROVIDER_UNEXPECTED`.
155
140
  - **One failure-body reader** (`error-body.ts`'s `detailOf`, bounded at `READ_LIMIT`) and one
156
141
  `withoutKey` scrub — the embedder included.
157
142
  - **`attempt` collects TRANSPORT failures only**: an `UltimateError` other than
@@ -160,39 +145,37 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
160
145
  - **The gateway's reads of a provider's throw are total**: `isRetryable` fails closed; the line goes
161
146
  through `renderThrowable`.
162
147
  - **`X_AI_PROVIDER_UNAVAILABLE` is the one code classified `retryable`** (`AI_ERROR_RETRY`);
163
- `AiProviderUnavailableError({ unserved: true })` carries a per-instance `terminal`. Every other code
164
- stays UNREGISTERED on purpose.
148
+ `AiProviderUnavailableError({ unserved: true })` carries a per-instance `terminal`. Six
149
+ deterministic failures are `terminal` (`errors-retry.ts`); the rest stay UNREGISTERED on purpose.
165
150
  - **The retry SCHEDULE is core's (`backoffDelay`, `isRetryableStatus`); the LOOP is this package's** —
166
- core's `retry()` retries unclassified throws, and every value here is an app `Provider`'s plain
167
- object, so it would retry a 400. `gateway-backoff.test.ts`. `RetryPolicy`/`DEFAULT_GATEWAY_RETRY` keep the
168
- field names an app writes.
151
+ core's `retry()` retries unclassified throws, so it would retry a 400 (`gateway-backoff.test.ts`).
169
152
  - Every non-2xx and every in-band `error` frame is `AiTransportError` with a real `status`.
170
153
  - **Both wire formats answer the same question the same way** (`provider-parity.test.ts`): an in-band
171
- `error` in a 200 body throws (`throwInBandError`, `wire.ts`); a `refusal` stop detail forces
172
- `stopReason: 'refusal'`; a tool call's `input` is parsed (`asToolInput`); the credential is scrubbed
173
- from `detail` (`error-body.ts`'s `withoutKey`/`detailOf`). The status tables and the OpenAI
174
- `estimatedUsage` fallback are asserted as differences.
154
+ `error` in a 200 throws (`throwInBandError`); a `refusal` detail forces `stopReason: 'refusal'`; a
155
+ tool call's `input` is parsed (`parseToolArguments`); the credential is scrubbed from `detail`.
156
+ - **Arguments that are not JSON are never thrown by a wire reader**: recorded as `malformed`, and
157
+ the PROVIDER decides once usage is known — dropped on a truncated turn, else
158
+ `X_LLM_TOOL_ARGUMENTS_INVALID` with `meta.billed`, which the gateway RECORDS (`settleFailure`).
159
+ - **Every throw of `llm()`/`agent()` after a provider call STARTED carries `meta.billed`**
160
+ (`meter.fail`): answered calls summed, `unsettled` counted and never priced.
175
161
  - A stream ending without `message_stop` throws. **`readSse` caps the unterminated buffer**
176
162
  (`MAX_FRAME_CHARS`); `provider` is required so a transport error names its endpoint.
177
163
  - A tool call is emitted whole. Thinking chunks are never appended to `text`.
178
- - Anthropic body: no `temperature`/`top_p`/`top_k`, no `budget_tokens`, `effort` inside
179
- `output_config`. Model IDs are exact aliases, never date-suffixed.
180
- - **Apps bring their own models and providers (M12, 25.0.0): no vendor choice, no vendor data.** No
181
- default model, no catalogue row, no id list ships; the registry starts EMPTY and `resetModels()`
182
- empties it. A model resolves declaration → prompt → `Gateway.defaultModel` through `resolveModel`
183
- / `modelForCall` only; none is `X_AI_MODEL_UNRESOLVED` naming all three (with no gateway and nothing
184
- declared, `X_AI_GATEWAY_MISSING` — the third place does not exist yet). `vendor-neutral.test.ts`
185
- proves it in a fresh process; no fix line names a vendor id.
164
+ - Anthropic body: no `top_p`/`top_k`, no `budget_tokens`, `effort` inside `output_config`. Model
165
+ IDs are exact aliases, never date-suffixed.
166
+ - **`temperature`/`seed` (`sampling.ts`) are sent ONLY to a row with `sampling: { … : true }`** —
167
+ else left out (never refused; `llm.sampling.ignored`); no `seed` on the Anthropic wire; in
168
+ `promptCacheKey` only when set.
169
+ - **Apps bring their own models and providers (M12, 25.0.0): no vendor choice, no vendor data.** The
170
+ registry starts EMPTY; a model resolves declaration → prompt → `Gateway.defaultModel`
171
+ (`resolveModel` / `modelForCall`), none is `X_AI_MODEL_UNRESOLVED` (no gateway at all:
172
+ `X_AI_GATEWAY_MISSING`). `vendor-neutral.test.ts` proves it; no fix line names a vendor id.
186
173
  - **`ModelId` is `string`; the catalogue is an open registry** — `modelSpec(id)` refuses an
187
- unregistered id (`X_AI_MODEL_UNKNOWN`, fix `registerModel(…)`) at `costOf`, the budget estimate and
188
- the body builders, before a socket or a reservation. **Re-registering REPLACES the spec and keeps
189
- its rung** (negotiated rates). **Registration order is the ladder within a `family`**, read only by
190
- `moreCapableThan`, over the APP's rows; `X_LLM_REFUSED`'s fix names a rung ABOVE or nothing.
191
- - **Every provider serves the app's list**: `anthropicProvider({ models })` and
192
- `openAiProvider({ models })` require a non-empty one (`X_AI_REQUEST_INVALID`, a missing config
193
- too); `echoProvider().models` reads the registry. **No provider has a model of its own**: a direct
194
- call naming none is `X_AI_MODEL_UNRESOLVED` from all three — the list is what it serves, never a
195
- fourth place (`vendor-neutral.test.ts`, one parity case).
174
+ unregistered id (`X_AI_MODEL_UNKNOWN`) before a socket or a reservation. Re-registering REPLACES
175
+ the spec and keeps its rung; registration order is the ladder within a `family`
176
+ (`moreCapableThan`); `X_LLM_REFUSED`'s fix names a rung ABOVE or nothing.
177
+ - **Every provider serves the app's list** (`{ models }` required, non-empty); no provider has a
178
+ model of its own.
196
179
  - **The reasoning controls are PER MODEL (`models.ts`)**: an unasked control is omitted; an asked
197
180
  control the model lacks is `X_AI_REQUEST_INVALID`, never dropped. Adding a model is a row the app
198
181
  registers. `disableThinkingUpTo: 'never'` refuses `thinking: 'disabled'` at every effort;
@@ -207,12 +190,13 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
207
190
  - **`openAiProvider()` is a FORMAT, not a vendor** — `baseUrl`, `auth`, `headers` are the
208
191
  differences; never a second class per vendor:
209
192
  - structured output is the `respond` tool, never `response_format`; `tool_choice` names the tool
210
- only when the request offers exactly one;
193
+ only when the request offers exactly one and the row is not `forcedToolChoice: false`;
211
194
  - `strict: true` only when `satisfiesStrictMode` says the schema qualifies;
212
195
  - `max_completion_tokens`; `role: 'system'`; `stream_options: { include_usage: true }` and an
213
196
  ESTIMATE when usage never arrives; subtract `cached_tokens` from `prompt_tokens`;
214
- - tool-call deltas merge by `tool_calls[].index`; `isComplete()` accepts `[DONE]` or a finish reason,
215
- except `[DONE]` with pending tool fragments, which is refused;
197
+ - tool-call deltas merge by `tool_calls[].index`, a differing `id` starting a new call; calls are
198
+ read at `end()` (`[DONE]` or socket close), never at the finish reason; `isComplete()` accepts
199
+ `[DONE]` or a finish reason, except `[DONE]` with pending tool fragments, which is refused;
216
200
  - no code of its own (a media refusal is the shared `X_AI_CONTENT_UNSUPPORTED`);
217
201
  `AiTransportError` takes the provider's `envVar`; the key is revealed late,
218
202
  never stored, scrubbed from `detail`.
@@ -228,11 +212,14 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
228
212
 
229
213
  - **`agent()` returns an action** (listed in `PRIMITIVE_FACTORIES`). `ctx.actor` is read once and is
230
214
  the only identity any tool runs as — never taken from the model's output. Bounded by `maxTurns`
231
- (`X_AGENT_MAX_TURNS`), `budget.tokensPerRun` and `maxToolResultChars`.
215
+ (`X_AGENT_MAX_TURNS`), `budget.tokensPerRun`, `maxToolResultChars`, `maxRepeatedToolCalls`
216
+ (`X_AGENT_TOOL_LOOP`) and `deadlineMs` (`X_AGENT_DEADLINE`).
232
217
  - `tools` takes a real `action()` (`AgentTool = AnyAction | ProjectableAction`,
233
218
  `asProjectableAction`); projection happens on the FIRST RUN, memoised. An agent can be another
234
219
  agent's tool.
235
- - A tool not `mcp: { expose: true }` is `X_AGENT_TOOL_UNEXPOSED` at DECLARATION.
220
+ - A tool is an exposed action or an `inProcessTool()` (`isOfferedTool`); anything else is
221
+ `X_AGENT_TOOL_UNEXPOSED` at DECLARATION. An in-process tool is never an action, never projected
222
+ to MCP, has no policy (the agent's decides) and parses its own input.
236
223
  - `ctx.signal` is read at the top of every turn, before every tool batch, and forwarded to
237
224
  `fetch`; `X_ABORTED` is core's.
238
225
  - One turn's tools run concurrently, results paired by index; no tool STARTS after an abort.
@@ -246,15 +233,21 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
246
233
  - **A 5xx code's cause reaches the model only when core's `hasPublicCause` allows it** (status from
247
234
  `@ultimat3/http`'s `statusFor`) — else `CODE: HIDDEN_TOOL_CAUSE`, plus a branded `callerFix`. The
248
235
  same verdict a problem document and `@ultimat3/mcp` give, from `failure-disclosure.ts` — the
249
- rule a hive member's `reason` follows too. Never a second copy.
236
+ rule a hive member's `reason` follows too. Never a second copy. One widening, for an
237
+ `inProcess` tool only (`discloseOwnFailure`): a branded error under a code with NO declared
238
+ status is shown as `{ error: { code, cause, fix } }`; `discloseErrors: false` opts out.
239
+ - **`.run()`/`.continue()` are the SAME action**, marked by an ambient session only the declaring
240
+ agent may claim, once (a nested agent starts fresh). The handle is SIGNED (`sealTranscript`: the
241
+ cursor secret, own label) and bound to agent, model, tool names and `orgId`; screened before
242
+ anything is loaded. `maxTurns`/`tokensPerRun` are cumulative — the remainder is the ceiling,
243
+ NEVER a ledger debit. A failure seals an updated handle onto the error (`transcriptOf`).
250
244
  - **`hive()` is a fan-out action** (`PRIMITIVE_FACTORIES`): `HiveResult` is a SCHEMA from the
251
245
  member's `output`; three arms (`ok`/`failed`/`skipped`) and three counters; `members` in SPLIT
252
- order with `index`; the hive never names an actor; no hive budget code (the reservation holds
253
- it); a member's throw is recorded through `discloseFailure` — a 5xx cause or an uncoded throw's
254
- message becomes `HIDDEN_MEMBER_CAUSE`, logged as facts only; `skipped` reasons
255
- `SKIPPED_ABORTED` / `SKIPPED_NO_INPUT`; `onMemberError` is required; `concurrency` 4, `minMembers`
256
- 2 (a below-floor split still runs serially); an empty split is `X_HIVE_EMPTY`; an aborted `ctx`
257
- unwinds with `X_ABORTED`.
246
+ order with `index`; the hive never names an actor; no hive budget code; a member's throw goes
247
+ through `discloseFailure` (`HIDDEN_MEMBER_CAUSE`);
248
+ `skipped` reasons `SKIPPED_ABORTED` / `SKIPPED_NO_INPUT`; `onMemberError` is required;
249
+ `concurrency` 4, `minMembers` 2 (a below-floor split runs serially); an empty split is
250
+ `X_HIVE_EMPTY`; an aborted `ctx` is `X_ABORTED`.
258
251
  - **`describeAgents()` / `registeredModels()` are offered, not published** — their only legal consumer
259
252
  is `@ultimat3/cli`. The facts are a THUNK; an unnamed agent has no row.
260
253
  - **`agentJob()` composes `job()`** (never an imitation). `name`, `tenant`, `retry` are
@@ -263,30 +256,37 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
263
256
  idempotent (unenforceable). **`actor`** re-resolves who it acts FOR each attempt (a served worker:
264
257
  ANONYMOUS) via `impersonate`; off-tenant is TERMINAL `X_JOB_TENANT_MISMATCH`.
265
258
 
259
+ ## Invariants — provider prompt caching
260
+
261
+ - **`GenerateRequest.promptCache` is the one switch; what it sends is a ROW in `PROMPT_CACHE_RULES`**
262
+ (wire, host, model prefix → marks, session carrier), never an `if`. An unnamed endpoint gets
263
+ NOTHING. At most 3 `cache_control`, a caller's own counted first (cap 4), never on a blank text
264
+ block; never a `provider` routing object.
265
+ - **`agent()` defaults `'auto'`, `llm()` `'off'`.** The stable system part (`prompt.system` +
266
+ `system().stable`) is sent FIRST; the mark follows the tail. The hint is not in `promptCacheKey`.
267
+ - **Usage**: chat-completions `prompt_tokens` INCLUDES read and written tokens; both are subtracted,
268
+ clamped. A budget debits all four counters; the estimate prices a WRITE only where a provider
269
+ would mark (`marksPromptCache`), at the dearer rate.
270
+ - **`maxRepeatedToolCalls` compares calls AND results over cycles of 1–3 turns**; `deadlineMs` has no
271
+ default and rides the request's AND each tool's signal; both stops are `terminal`
272
+ (`errors-retry.ts`).
273
+
266
274
  ## Invariants — evals, retrieval, fix lines
267
275
 
268
- - **No fix line may name `x ai`** (planned). An eval is selected with `x test eval --filter <name>`;
269
- `eval-errors.test.ts` asserts every `x test <word>` is one of the six types.
270
- - Every eval result carries the prompt hash. An eval gates on the DROP from its recorded baseline,
271
- mean AND per case, both sides rounded as the baseline is written. Never-recorded is `X_EVAL_BASELINE_MISSING`, corrupt is `X_EVAL_BASELINE_INVALID`.
272
- `baseline` is `import.meta.resolve('./…')`. Every prompt is named by an eval (`X_EVAL_MISSING`, by
273
- prompt ID). `ULTIMATE_EVAL_RECORD=1` records; a deliberately-worse test calls `run`, never
274
- `assert`; `x verify` with the flag is `X_EVAL_RECORDING` and runs no eval. The gate asks for a
275
- recorded baseline, not only a declaration.
276
- - Retrieval is hybrid by default; no vector-only path. **`chunk()` hard-wraps**, the overlap
277
- carry stops at `buffer.length - 1` and yields to a next unit it does not fit beside (no chunk over
278
- `size`); `metadata.source` is written AFTER the caller's spread. `VectorStore.prune` is required.
279
- - **A store with no tenant bound, read inside a request whose actor has an `orgId`, is
280
- `X_VECTOR_UNSCOPED`** (`assertTenantRead`, both stores, every read). `scope: UNSCOPED`
281
- (`crossTenant: true`) is the named opt-in; an omitted scope is `UNBOUND`.
282
- - `postgresVectorStore()` is the only production vector path (pgvector + FTS in the app's Postgres);
283
- `memoryVectorStore()` enforces the same envelope. **`pg-vector.live.test.ts` refuses to skip** on a
284
- Postgres without the extension (CI uses `pgvector/pgvector:pg17`).
285
- - The distance ordering lives in an ascending raw subquery (the only shape hnsw answers), pinned by a
286
- plan assertion. hnsw scopes after the scan — assert rows, never the node; `analyze` after a backfill.
287
- - Tenant and policy filters are in SQL on every statement (`conditionsSql`), on BOTH fusion halves.
288
- `(tenant, id)` is the primary key. `scoped()` only TIGHTENS (`X_VECTOR_SCOPE_WIDENED`). Metadata is
289
- bound `::text::jsonb`.
276
+ Full text of each rule: [`docs/history/ai.md`](../../docs/history/ai.md#evals-retrieval-fix-lines).
277
+
278
+ - **No fix line may name `x ai`** (planned); an eval is selected with `x test eval --filter <name>`.
279
+ - Every eval result carries the prompt hash; an eval gates on the DROP from its recorded baseline,
280
+ mean AND per case (`X_EVAL_BASELINE_MISSING` / `_INVALID`); every prompt is named by an eval
281
+ (`X_EVAL_MISSING`); `ULTIMATE_EVAL_RECORD=1` records, and `x verify` with it is `X_EVAL_RECORDING`.
282
+ - Retrieval is hybrid by default, no vector-only path. `chunk()` hard-wraps (no chunk over `size`);
283
+ `metadata.source` is written AFTER the caller's spread; `VectorStore.prune` is required.
284
+ - **An unbound store read inside a request whose actor has an `orgId` is `X_VECTOR_UNSCOPED`**
285
+ (both stores, every read); `scope: UNSCOPED` is the named opt-in, an omitted scope is `UNBOUND`.
286
+ - `postgresVectorStore()` is the only production vector path; `pg-vector.live.test.ts` refuses to
287
+ skip without the extension. Distance ordering is an ascending raw subquery (the shape hnsw
288
+ answers), pinned by a plan assertion. Tenant and policy filters are in SQL on every statement and
289
+ BOTH fusion halves; `(tenant, id)` is the key; `scoped()` only TIGHTENS (`X_VECTOR_SCOPE_WIDENED`).
290
290
 
291
291
  ## Commands
292
292
 
package/README.md CHANGED
@@ -205,7 +205,7 @@ registerModel({
205
205
  id: 'llama-internal-70b',
206
206
  contextWindow: 128_000,
207
207
  maxOutput: 8_192,
208
- inputPerMillion: { minor: 20, currency: 'USD' }, // YOUR price, integer minor units
208
+ inputPerMillion: { minor: 20, currency: 'USD' }, // YOUR price, integer minor units — or finer: { minor: 75, currency: 'USD', scale: 3 } is $0.075
209
209
  outputPerMillion: { minor: 40, currency: 'USD' },
210
210
  cacheMinimumTokens: 0,
211
211
  reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
@@ -214,6 +214,8 @@ registerModel({
214
214
  configureAi({ gateway: providerGateway({ providers: [new InternalGatewayProvider()] }) });
215
215
  ```
216
216
 
217
+ Two optional per-model rows: `sampling: { temperature: true, seed: true }` (the model takes the `temperature` / `seed` an `llm()` or `agent()` declares — absent, they are not sent) and `forcedToolChoice: false` (the model rejects a forced `tool_choice`, so `llm()`'s one `respond` tool is offered under the endpoint's default).
218
+
217
219
  **The framework registers no model.** `registerModel` is the one way into the catalogue and only the
218
220
  app calls it — at boot, by convention in its own `models.ts`, each row with the page and the date its
219
221
  numbers were read from, so a stale price is a diff in the app's repo rather than a framework release.
@@ -433,12 +435,91 @@ sub-agent in its own `tools` and the sub-agent runs under the same actor, throug
433
435
  | an aborted `ctx` unwinds the run — at the top of every turn, before every tool batch, and on the socket | the transcript IS the request, so a loop that keeps going after the caller disconnects re-sends it once per remaining turn, runs every remaining side effect and discards the answer. `ctx.signal` rides on `GenerateRequest` too, so a call already in flight is cut rather than paid for |
434
436
  | the tools of **one turn** run concurrently, results paired by `tool_use` id | a turn asking for five tools cost 5x wall clock and nothing said so. Order is positional, never by completion; the batch is bounded by what one turn asked for, and each tool is an action with its own `policy` and `rateLimit`, so a second ceiling here would be a throttle competing with those |
435
437
  | `onTurn` reports each completed turn as it happens (and an `agent.turn` span event, always) | a 90-second run emitted nothing until it returned. Observation only — it cannot steer the loop, see the transcript or reach the actor — and a throw from it fails the run rather than being swallowed |
436
- | 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 |
438
+ | a tool is an `mcp: { expose: true }` action or an `inProcessTool()`; anything else is `X_AGENT_TOOL_UNEXPOSED` **at declaration** | a silently dropped tool reads as offered and is not; `isMcpExposed` is the one predicate for ACTIONS, so an in-app agent and an external MCP client see the same catalogue of them — and a tool no MCP client may see is declared as one that is this process's alone |
437
439
  | 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 |
440
+ | a cycle of 1 to 3 tool turns — same calls, arguments, results and failures — gone round `maxRepeatedToolCalls` (default 3) times is `X_AGENT_TOOL_LOOP` | such a turn can only be followed by another like it; a changed result is progress; a repair turn neither counts nor resets |
441
+ | `deadlineMs` (no default) is the run's wall-clock ceiling: `X_AGENT_DEADLINE` | the in-flight model call is aborted on the socket and every tool's `ctx.signal` fires; a tool that ignores it still finishes late. A caller that leaves first is still `X_ABORTED` |
442
+ | both stops are registered `terminal` | an `agentJob()` dead-letters on attempt one instead of buying the same run per attempt |
443
+ | every turn asks the provider to cache its prefix (`promptCache`, default `'auto'`) | the transcript IS the request, so an uncached run pays for its stable prefix once per turn — [below](#provider-prompt-caching) |
438
444
  | `budget.tokensPerRun` caps the **whole run** | a single call is bounded by `maxTokens`; a loop is bounded by nothing until this is set |
439
445
  | a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
440
446
  | **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
441
447
 
448
+ ### What a call cost
449
+
450
+ `cost` is the currency's minor units, rounded up once per call; `costExact` beside it is the same figure exactly (`Money` at a finer `scale`, from `exactCostOf`). `agent()` reports both per turn and for the run in `onTurn`; `llm()` per provider call in `onCall`; a ledger sums exact costs and rounds once when read. `repairOf` on either event is what a repair turn was told to fix.
451
+
452
+ ### Continuing a run
453
+
454
+ `agent()` returns an `AgentAction`: the action, plus `.run(input, opts)` → `{ output, transcript, turns, usage }` and `.continue(input, { transcript, user }, opts)`, which appends one user turn to a stored transcript and resumes the loop. The transcript is re-sent byte for byte (the provider's prompt cache reads it back; no tool re-runs); `maxTurns` and `budget.tokensPerRun` are cumulative; the handle is signed, bound to the agent, model, tools and tenant, and screened (`X_AGENT_TRANSCRIPT_INVALID`); a failed call leaves an updated one on the error (`transcriptOf(error)`), which is the one a job stores. Full contract: [Agents → Continuing a run](https://github.com/developerz-ai/ultimate/wiki/Agents#continuing-a-run-run--continue).
455
+
456
+ ### Provider prompt caching
457
+
458
+ Not the gateway's response cache and not `llm()`'s semantic one: this asks the **provider** to keep
459
+ a request's prefix, so the next request reads it at the cache rate.
460
+
461
+ ```ts
462
+ import { agent, definePrompt, t } from '@ultimat3/ai';
463
+ import { can } from '@ultimat3/policy';
464
+
465
+ const kbPrompt = definePrompt<{ question: string }>({
466
+ id: 'kb',
467
+ version: '1.0.0',
468
+ system: 'Answer from the knowledge base only.', // starts the stable block
469
+ template: 'Question: {{question}}',
470
+ });
471
+
472
+ export const kbAgent = agent({
473
+ input: t.object({ question: t.string, index: t.string }),
474
+ output: t.object({ answer: t.string }),
475
+ prompt: kbPrompt,
476
+ vars: ({ input }) => ({ question: input.question }),
477
+ // `stable` joins prompt.system in the block every run shares; `run` follows it
478
+ system: ({ input }) => ({ stable: input.index, run: `Question length: ${input.question.length}` }),
479
+ promptCache: { session: 'kb-agent' }, // 'auto' (default) | 'off' | { session }
480
+ tools: [],
481
+ policy: can('kb:ask'),
482
+ onTurn: ({ usage, run }) => {
483
+ console.log(usage.cacheReadTokens, usage.cacheWriteTokens, run.cost.minor);
484
+ },
485
+ });
486
+ ```
487
+
488
+ | Shape | Meaning |
489
+ |---|---|
490
+ | `GenerateRequest.system: string \| { stable, run? }` | `stable` is byte-identical across runs and is sent first, as its own block; `run` follows it |
491
+ | `GenerateRequest.promptCache?: { session? }` | present: mark the stable system part and the transcript's tail, per `PROMPT_CACHE_RULES`. Absent: nothing is marked |
492
+ | `agent({ promptCache })` | default `'auto'` — every turn carries the hint |
493
+ | `llm({ promptCache })` | default `'off'` — a one-shot call would pay the write premium for a cache nothing reads |
494
+ | `AgentTurn.usage` / `AgentTurn.run` | per turn, and the running total: `cacheReadTokens`, `cacheWriteTokens`, and `run.cost` priced once from the summed usage |
495
+
496
+ `PROMPT_CACHE_RULES` (`src/prompt-cache.ts`, sources quoted in its header), first match wins:
497
+
498
+ | Wire | Host | Model prefix | Marks | `session` |
499
+ |---|---|---|---|---|
500
+ | Anthropic Messages | any | any | stable system block + last block of the last two messages (3 of 4 breakpoints) | — |
501
+ | chat completions | `openrouter.ai` | `anthropic/` | stable system part + the tail message's last part (2) | `x-session-id` header |
502
+ | chat completions | `openrouter.ai` | any other | none (implicit caching; a mark on Gemini is a paid write) | `x-session-id` header |
503
+ | chat completions | `api.openai.com` | any | none (automatic) | `prompt_cache_key` |
504
+ | chat completions | anything else | any | none | — |
505
+
506
+ Usage: the chat-completions reader takes `prompt_tokens_details.cached_tokens` and
507
+ `.cache_write_tokens` out of `prompt_tokens` (which includes both) and reports them as
508
+ `cacheReadTokens` / `cacheWriteTokens`; a missing field is zero. `costOf` prices them at the model
509
+ row's `cacheReadPerMillion` / `cacheWritePerMillion` (absent: 0.1x / 1.25x input). A token budget
510
+ counts all four counters, one token each. The gateway's pre-flight estimate prices the prompt as a
511
+ write only when a provider serving the model would mark it (`Provider.marksPromptCache`), at the
512
+ dearer of the write and input rates. No `provider` routing object and no `usage: { include }` is
513
+ ever sent.
514
+
515
+ | Limit | Detail |
516
+ |---|---|
517
+ | `run.cost` and the ledger | one contract: cost is computed exactly and rounded up once. `AgentTurn.run.cost` is the run's summed usage priced once; `gateway.spent()` / `BudgetLedger.report().cost` is the exact cost of every recorded call summed, rounded once when read (`costExact` beside it). They agree for a run on built-in providers; a `Provider` that states no `costExact` is summed from its rounded `cost`. Budgets are enforced against the ledger |
518
+ | the Anthropic row matches any host | an `anthropicProvider({ baseUrl })` behind a proxy still sends marks |
519
+ | more than about 9 tool calls in one turn, on OpenRouter | only the tail is marked there and the provider looks back 20 blocks, so that turn's transcript is rewritten, not read |
520
+ | a mark never lands on an empty or whitespace-only text block, and never makes a fifth | it moves back a block or is dropped; a raw caller's own marks are counted first |
521
+ | `session` | OpenRouter header: 1 to 256 printable ASCII, else `X_AI_REQUEST_INVALID`. OpenAI `prompt_cache_key`: over 64 characters it is sent as `h:` + 62 hex of its SHA-256 |
522
+
442
523
  ## `hive()` — many members, one action
443
524
 
444
525
  Fan an action out over many inputs. A factory over a primitive, like `llm()`, `backfill()` and
@@ -854,6 +935,8 @@ An in-app agent and an external MCP agent both end at the same `invoke` — `run
854
935
  carries it, and an action facade has no `.run` of its own. So they authorize identically. The
855
936
  actor comes from the request context, never from the model.
856
937
 
938
+ `inProcessTool({ name, description, input, execute })` is a tool that is this process's alone: no `mcp`, no policy of its own (the agent's decides), in no catalogue but the agent's. Its own typed refusal reads back as `{"error":{"code","cause","fix"}}`. [Agents → In-process tools](https://github.com/developerz-ai/ultimate/wiki/Agents#in-process-tools).
939
+
857
940
  A failed tool reads back as `CODE: cause (fix: …)` — except a 5xx code whose cause core's
858
941
  `hasPublicCause` does not declare public, which reads `CODE: ` plus a fixed sentence (and a
859
942
  `callerFix`, when the `UltimateError` declared one): the rule a production problem document follows,
@@ -882,13 +965,17 @@ fail-closed `terminal` default.
882
965
  | `X_LLM_OUTPUT_INVALID` | the model failed its `output` schema on the answer and on the repair turn |
883
966
  | `X_LLM_STREAM_INVALID` | a streamed answer failed its schema, and a stream cannot take a repair turn |
884
967
  | `X_AGENT_MAX_TURNS` | an `agent()` used every turn without answering |
885
- | `X_AGENT_TOOL_UNEXPOSED` | an `agent()` lists an action no MCP surface exposes |
968
+ | `X_AGENT_TOOL_UNEXPOSED` | an `agent()` lists a tool that is neither in-process nor an MCP-exposed action |
969
+ | `X_AGENT_TOOL_LOOP` | an `agent()` made the same tool calls and read the same results `maxRepeatedToolCalls` turns in a row |
970
+ | `X_AGENT_DEADLINE` | an `agent()` outlived its declared `deadlineMs` |
886
971
  | `X_EVAL_THRESHOLD` | an eval scored below its bar |
887
972
  | `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
888
973
  | `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
889
974
  | `X_VECTOR_UNSCOPED` | a store with no tenant bound was read inside a request acting for an org; `scope: UNSCOPED` is the named opt-out |
890
975
  | `X_NOT_IMPLEMENTED` | a remote driver with no key or transport; the fix names the env var |
891
976
 
977
+ A failure thrown after a provider call was started carries what the run was billed on `meta.billed` (`billedOf(error)` → `{ usage, cost, calls, unsettled }`); `unsettled` counts the calls whose bill cannot be known. [Agents → Unattended runs](https://github.com/developerz-ai/ultimate/wiki/Agents#unattended-runs).
978
+
892
979
  ### Error classes
893
980
 
894
981
  Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
@@ -896,7 +983,10 @@ a job boundary the class is gone and the `code` is what survives — match on th
896
983
 
897
984
  | Class | Code | Declared in |
898
985
  |---|---|---|
986
+ | `AgentDeadlineError` | `X_AGENT_DEADLINE` | `src/agent-errors.ts` |
899
987
  | `AgentMaxTurnsError` | `X_AGENT_MAX_TURNS` | `src/errors.ts` |
988
+ | `AgentToolLoopError` | `X_AGENT_TOOL_LOOP` | `src/agent-errors.ts` |
989
+ | `AgentTranscriptInvalidError` | `X_AGENT_TRANSCRIPT_INVALID` | `src/agent-errors.ts` |
900
990
  | `AgentToolUnexposedError` | `X_AGENT_TOOL_UNEXPOSED` | `src/errors.ts` |
901
991
  | `AiBudgetExceededError` | `X_AI_BUDGET_EXCEEDED` | `src/errors.ts` |
902
992
  | `AiContentUnsupportedError` | `X_AI_CONTENT_UNSUPPORTED` | `src/content-errors.ts` |
@@ -919,7 +1009,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
919
1009
  | `LlmOutputInvalidError` | `X_LLM_OUTPUT_INVALID` | `src/errors.ts` |
920
1010
  | `LlmRefusedError` | `X_LLM_REFUSED` | `src/errors.ts` |
921
1011
  | `LlmStreamInvalidError` | `X_LLM_STREAM_INVALID` | `src/errors.ts` |
922
- | `LlmTruncatedError` | `X_LLM_TRUNCATED` | `src/errors.ts` |
1012
+ | `LlmToolArgumentsError` | `X_LLM_TOOL_ARGUMENTS_INVALID` | `src/answer-errors.ts` |
1013
+ | `LlmTruncatedError` | `X_LLM_TRUNCATED` | `src/answer-errors.ts` |
923
1014
  | `VectorDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
924
1015
  | `VectorScopeWidenedError` | `X_VECTOR_SCOPE_WIDENED` | `src/errors.ts` |
925
1016
  | `VectorUnscopedError` | `X_VECTOR_UNSCOPED` | `src/vector-scope.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "27.8.1",
3
+ "version": "27.10.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,15 +32,15 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "27.8.1",
36
- "@ultimat3/cache": "27.8.1",
37
- "@ultimat3/core": "27.8.1",
38
- "@ultimat3/db": "27.8.1",
39
- "@ultimat3/http": "27.8.1",
40
- "@ultimat3/jobs": "27.8.1",
41
- "@ultimat3/money": "27.8.1",
42
- "@ultimat3/policy": "27.8.1",
43
- "@ultimat3/schema": "27.8.1",
44
- "@ultimat3/time": "27.8.1"
35
+ "@ultimat3/action": "27.10.0",
36
+ "@ultimat3/cache": "27.10.0",
37
+ "@ultimat3/core": "27.10.0",
38
+ "@ultimat3/db": "27.10.0",
39
+ "@ultimat3/http": "27.10.0",
40
+ "@ultimat3/jobs": "27.10.0",
41
+ "@ultimat3/money": "27.10.0",
42
+ "@ultimat3/policy": "27.10.0",
43
+ "@ultimat3/schema": "27.10.0",
44
+ "@ultimat3/time": "27.10.0"
45
45
  }
46
46
  }