@ultimat3/ai 1.2.0 → 3.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 +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -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 +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- 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 +274 -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 +112 -38
- 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 +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# @ultimat3/ai — boundary
|
|
2
|
+
|
|
3
|
+
Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
|
|
4
|
+
query jobs realtime`. **Never** `mcp manifest render pwa ui admin testing cli`.
|
|
5
|
+
|
|
6
|
+
Declared today: `action` (the primitive `llm()` returns), `cache` (semantic cache), `core`,
|
|
7
|
+
`db` (pgvector), `jobs` (`agentJob()`), `money`, `policy`, `schema`, `time`.
|
|
8
|
+
|
|
9
|
+
**`jobs` was declared 2026-08, for `agentJob()`.** `packages/action/src/job-handle.ts`'s header
|
|
10
|
+
already named this package as the home: `isJobHandle` needs `kind === 'job'` plus membership of a
|
|
11
|
+
WeakMap only `job()` writes, and `action` and `jobs` are both tier 3, so the bridge has to live at
|
|
12
|
+
tier 4+. Downward edge, nothing new in the tier table.
|
|
13
|
+
|
|
14
|
+
`mcp` is the same tier, so the LLM-tool projection is restated structurally in `tools.ts`
|
|
15
|
+
rather than imported. Same contract, two wire formats — and the same *decision*: `toLlmTools` and
|
|
16
|
+
`runLlmToolCall` ask `isMcpExposed` from `@ultimat3/core` (tier 0, reachable by both), never a
|
|
17
|
+
local `=== true`. An in-app agent and an external one must be offered exactly the same tools.
|
|
18
|
+
|
|
19
|
+
**And the same NAME**: `toLlmTool` passes `action.name` through untouched, which is the export name
|
|
20
|
+
`@ultimat3/mcp` serves. Never derive one here. `llm()` and `agent()` return actions, so their
|
|
21
|
+
`.tool()` name is the verbatim export name too — it read `summarize_post` / `projecting_agent`
|
|
22
|
+
until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts`).
|
|
23
|
+
|
|
24
|
+
## Owns
|
|
25
|
+
|
|
26
|
+
| File | Job |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `models.ts` | the model REGISTRY: `registerModel`, limits, prices, the reasoning controls each one accepts |
|
|
29
|
+
| `provider.ts` | `Provider` interface, the request half, the money arithmetic, Anthropic + Echo |
|
|
30
|
+
| `wire.ts` | the response half: `usage` / `stop_reason` shapes, and the SSE `MessageStream` |
|
|
31
|
+
| `error-body.ts` | what a failure body SAYS (`detailOf`) and what must never survive into it (`withoutKey`) — one copy, both transports |
|
|
32
|
+
| `sse.ts` | Server-Sent Events framing — protocol only, knows nothing about Anthropic |
|
|
33
|
+
| `openai-provider.ts` | `openAiProvider()` — the socket, the credential and the errors for any endpoint speaking the OpenAI chat-completions FORMAT |
|
|
34
|
+
| `openai-messages.ts` | the format mapping's request half: `AiMessage` blocks → OpenAI messages, `LlmTool` → functions, `tool_choice` |
|
|
35
|
+
| `openai-body.ts` | one chat-completions body, and the per-model reasoning mapping |
|
|
36
|
+
| `openai-wire.ts` | its response half: one completion, and `ChatCompletionStream` (fragmented tool calls, trailing usage, `[DONE]`) |
|
|
37
|
+
| `openai-models.ts` | the OpenAI-format price rows, registered through the same `registerModel` |
|
|
38
|
+
| `gateway.ts` | routing, retries, cache, budget wiring |
|
|
39
|
+
| `budget.ts` | token ledgers per request/actor/org, ALS carrier |
|
|
40
|
+
| `prompt.ts` | `definePrompt`, content hashing, version registry |
|
|
41
|
+
| `embeddings.ts` | `Embedder`, `HashEmbedder`, cosine helpers |
|
|
42
|
+
| `remote-embedder.ts` | `RemoteEmbedder` — the production `/v1/embeddings` client |
|
|
43
|
+
| `evals.ts` | `defineEval`, the run, the baseline gate, prompt coverage |
|
|
44
|
+
| `eval-baseline.ts` | the recorded scores: path, read/write, what counts as a regression |
|
|
45
|
+
| `scorers.ts` | what a `Scorer` is, the built-in ones, and `llmJudge` |
|
|
46
|
+
| `vector.ts` | `VectorStore`, in-memory cosine + BM25, RRF hybrid |
|
|
47
|
+
| `pg-vector.live.test.ts` | the same store against a real pgvector — DDL, fusion, scope, plan |
|
|
48
|
+
| `vector-scope.ts` | the tenant + policy envelope, and the tighten-only derive rule |
|
|
49
|
+
| `pg-vector-sql.ts` | every pgvector statement: DDL, upsert, cosine, FTS, RRF fusion |
|
|
50
|
+
| `pg-vector.ts` | `PgVectorStore` — the production store |
|
|
51
|
+
| `rag.ts` | chunker, retriever, reranker, budgeted context assembler |
|
|
52
|
+
| `tools.ts` | action → LLM tool definition; the `AgentTool` union and `asProjectableAction`; `runLlmToolCall` |
|
|
53
|
+
| `llm.ts` | `llm()` — the model call, declared as an `action`; and what a streamed answer must satisfy |
|
|
54
|
+
| `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
|
|
55
|
+
| `agent.ts` | `agent()` — the tool loop, declared as an `action` |
|
|
56
|
+
| `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
|
|
57
|
+
| `agent-facts.ts` | `describeAgents()` — the agent registry and the row a manifest publishes |
|
|
58
|
+
| `agent-job.ts` | `agentJob()` — an agent as a real `JobHandle`, composed from `job()` |
|
|
59
|
+
| `hive.ts` | `hive()` — one action fanned out over many inputs, declared as an `action` |
|
|
60
|
+
| `hive-result.ts` | `HiveMember` / `HiveResult`, and the SCHEMA built from the member's own `output` |
|
|
61
|
+
| `hive-errors.ts` | the `X_HIVE_*` class; its code and title stay in `errors.ts` |
|
|
62
|
+
| `redaction.ts` | the one gate between `vars()` and the provider: a `Secret` never reaches a prompt |
|
|
63
|
+
| `eval-errors.ts` | the five `X_EVAL_*` classes; their codes and titles stay in `errors.ts` |
|
|
64
|
+
| `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
|
|
65
|
+
| `fix-line.ts` / `fix-line.evals.ts` / `fix-line.v1.baseline.json` / `fix-line.eval.test.ts` | the package's own dogfood eval — the first framework-level `*.eval.test.ts`, proving the `defineEval`/baseline convention actually fails a build |
|
|
66
|
+
|
|
67
|
+
## Invariants
|
|
68
|
+
|
|
69
|
+
- **`llm()` returns an `action`. It is not a ninth primitive** (root `CLAUDE.md`, 2026-08). It
|
|
70
|
+
never re-implements parse, authz or invoke — `action()` owns those and `invoke` runs them.
|
|
71
|
+
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an `llm` file imports one
|
|
72
|
+
package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
|
|
73
|
+
access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
|
74
|
+
- The model half is the only thing `llm.ts` adds: render the prompt, project `output` into the
|
|
75
|
+
one tool the model may answer through, reserve the budget, consult the semantic cache.
|
|
76
|
+
- One repair turn, then `X_LLM_OUTPUT_INVALID`. Two schema failures is a prompt/schema
|
|
77
|
+
disagreement; a third attempt only spends money.
|
|
78
|
+
- Semantic scopes are separate cache INSTANCES, never a filter over a shared one — cosine
|
|
79
|
+
similarity has no notion of a tenant. The instance key carries the prompt hash too, which is
|
|
80
|
+
what makes a version bump invalidate the cache.
|
|
81
|
+
- A per-call budget `derive`s from the ambient ledger, so it can only TIGHTEN the actor and org
|
|
82
|
+
ceilings it runs inside. Widening them from a declaration would be a budget that is not one.
|
|
83
|
+
**A derived ledger reports back up the chain**: every debit and every recorded cost lands on it
|
|
84
|
+
AND on every ledger it was derived from, and `reserve` checks the `request` scope of each one.
|
|
85
|
+
Without that link a child was a fresh counter with no parent — `llm()` derives one per call, so
|
|
86
|
+
`gateway.spent()` answered zero after a hundred calls and a `request` ceiling of 5,000 was
|
|
87
|
+
re-granted in full to each of them. The STORE is written once, by the ledger the call was made
|
|
88
|
+
on: a child shares its parent's store and keys, so writing through both bills the actor twice.
|
|
89
|
+
Reservations queue on the ROOT's turnstile for the same reason — a per-ledger queue serialises
|
|
90
|
+
nothing once every call has its own ledger.
|
|
91
|
+
- `cache.invalidates` from `docs/idea/05-caching.md` is **not** on `llm()` yet: `invalidateTags`
|
|
92
|
+
fans out to `CacheTier`s, and a `SemanticCache` is not one. Storing tags nothing visits would
|
|
93
|
+
read as wired and silently not be. Version bump + `ttl` is the invalidation today.
|
|
94
|
+
- The gateway is ambient (`configureAi`) because a declaration is evaluated at module scope,
|
|
95
|
+
long before a provider exists. Absent at call time is `X_AI_GATEWAY_MISSING`, never a default
|
|
96
|
+
provider — a silent fallback would spend real money on a boot mistake.
|
|
97
|
+
|
|
98
|
+
- **`BudgetStore` is where `actor` and `org` actually live, and the default is per PROCESS.**
|
|
99
|
+
`createGateway({ budgetStore })` is the one install point; omitted, it is `MemoryBudgetStore`,
|
|
100
|
+
so an `org` ceiling is multiplied by the replica count exactly as `jobs`' `LimitConfig` is.
|
|
101
|
+
`request` is unaffected — it is one call chain and never crosses a process. `add` takes a
|
|
102
|
+
**negative** `tokens` (releasing an unspent reservation is a credit), so a store that clamps at
|
|
103
|
+
zero leaks the ceiling upward on every release.
|
|
104
|
+
- Cost is `Money` (integer minor units), rounded **up**. Never a float, never a division
|
|
105
|
+
that loses a fraction.
|
|
106
|
+
- **The gateway's two reads of a provider's throw are total.** A `Provider` is the APP's object, so
|
|
107
|
+
the value it rejects with is one the framework did not build: `isRetryable` indexes it (a getter,
|
|
108
|
+
or a `Proxy` trap) and fails closed if the read raises, and the failure line goes through core's
|
|
109
|
+
`renderThrowable` rather than `error.message` / `String(error)` — a renderer that throws replaces
|
|
110
|
+
`X_AI_PROVIDER_UNAVAILABLE` with a bare `TypeError` nothing catches by code, and it bounds a
|
|
111
|
+
provider's 1MB body out of the `cause`.
|
|
112
|
+
- Every non-2xx and every in-band `error` frame becomes `AiTransportError`, which carries a real
|
|
113
|
+
`status` field — that field IS the gateway's retry rule. A body parsed as a message would read
|
|
114
|
+
as an empty, successful answer, which is the one outcome nothing downstream can detect.
|
|
115
|
+
- **The two wire formats answer the same question the same way, and `provider-parity.test.ts` is
|
|
116
|
+
what makes that a build error — added 2026-08.** Four rules were held by one format and not the
|
|
117
|
+
other, all of them in the RESPONSE half, and each one is a failure that reads as a success:
|
|
118
|
+
- an in-band `error` object in a **200 body** is `AiTransportError` on both. `parseMessage` read
|
|
119
|
+
one as an empty `end_turn` answer while `MessageStream` — the *same provider's* streamed half —
|
|
120
|
+
had always refused it. `throwInBandError` is `wire.ts`'s, exported, so one status table decides
|
|
121
|
+
the gateway's retry on both transports.
|
|
122
|
+
- a `stopDetails` of type `refusal` **forces** `stopReason: 'refusal'`, because `llm()` and
|
|
123
|
+
`agent()` branch on the reason and nothing reads the detail. `parseStopReason` answers
|
|
124
|
+
`end_turn` for a spelling this build has never seen, so a refusal in a new vocabulary arrived
|
|
125
|
+
as a complete answer that happened to be empty. The OpenAI-format read always forced it.
|
|
126
|
+
- a tool call's `input` is **parsed, never cast**: `asToolInput`, one copy. `(b['input'] ?? {}) as
|
|
127
|
+
Record<string, unknown>` put a string under that type, and `runLlmToolCall` indexes it.
|
|
128
|
+
- the **credential is scrubbed** out of `AiTransportError.detail` on both. `withoutKey` was the
|
|
129
|
+
OpenAI provider's alone, so a proxy echoing `x-api-key` into its 400 body put an Anthropic key
|
|
130
|
+
in an error — and an error reaches a log index, a span and a problem document. Both providers
|
|
131
|
+
now call `error-body.ts`'s pair; `detailOf` moved there from `provider.ts` with it (internal,
|
|
132
|
+
never in `src/index.ts`).
|
|
133
|
+
What is NOT parity: the two status tables (529 vs 503 for "overloaded") and the OpenAI-format
|
|
134
|
+
`estimatedUsage` fallback. Those are the formats differing, and the parity suite asserts them
|
|
135
|
+
*as* differences rather than flattening them.
|
|
136
|
+
- A stream that ends without `message_stop` throws. A truncated answer that returns `end_turn`
|
|
137
|
+
is a confidently wrong answer with no signal, which the budget rule already forbids.
|
|
138
|
+
- A tool call is emitted whole. `input_json_delta` fragments are not arguments until the block
|
|
139
|
+
closes, so nothing partial reaches a caller.
|
|
140
|
+
- Thinking chunks are never appended to `text`. A consumer concatenating every chunk must not
|
|
141
|
+
end up shipping the reasoning to the user.
|
|
142
|
+
- One `RemoteEmbedder` for every vendor: `baseUrl` selects the provider, the wire shape is the
|
|
143
|
+
same. Vectors are L2-normalised on arrival so `cosine` stays a dot product, and a width other
|
|
144
|
+
than the declared one is `X_VECTOR_DIM_MISMATCH` before anything reaches a store.
|
|
145
|
+
- A budget throws `X_AI_BUDGET_EXCEEDED` **before** the provider call. Never truncate.
|
|
146
|
+
- Anthropic body: no `temperature`/`top_p`/`top_k`, no `budget_tokens`, `effort` inside
|
|
147
|
+
`output_config`. All 400s otherwise.
|
|
148
|
+
- **`ModelId` is `string`, and the catalogue is an open registry — decided 2026-08.** The routing
|
|
149
|
+
seam (`Provider`, `createGateway`) was always open; the VOCABULARY was not, so a company's own
|
|
150
|
+
gateway serving `llama-internal-70b` could not be typed and the only way past `tsc` was to claim
|
|
151
|
+
a Claude id — after which `costOf` charged list price for a model nobody ran, `BudgetLedger`
|
|
152
|
+
reserved against the wrong number and the manifest recorded a model the company does not use.
|
|
153
|
+
What replaces the union as the guard is `modelSpec(id)`: an unregistered id is
|
|
154
|
+
`X_AI_MODEL_UNKNOWN` at the first read, naming the registered set. **The three built-ins register
|
|
155
|
+
through the same `registerModel` an app calls**, at the bottom of `models.ts`, so the default
|
|
156
|
+
path is the app's path and there is one way to put a model in the catalogue.
|
|
157
|
+
- **Re-registering an id REPLACES its spec and keeps its rung.** That is the negotiated-rate
|
|
158
|
+
mechanism, and the reason there is no `overrideModel`: an app whose contract prices
|
|
159
|
+
`claude-opus-5` below list registers it again with its own prices, and every `costOf`, every
|
|
160
|
+
reservation and every recorded cost is that number from then on. Boot runs after this module is
|
|
161
|
+
imported, so the app always wins.
|
|
162
|
+
- **Registration order IS the ladder within a `family`, most capable first, and `moreCapableThan`
|
|
163
|
+
is its only reader.** `ModelSpec.family` is what makes that true once more than one vendor's
|
|
164
|
+
list is registered: the built-ins are `anthropic` then `openai`, so without it the rung above
|
|
165
|
+
`gpt-5.6-sol` was `claude-haiku-4-5` — the cheapest model in the catalogue, from a vendor the
|
|
166
|
+
app's gateway may not serve, offered as an UPGRADE in `X_LLM_REFUSED`'s fix line. Absent is its
|
|
167
|
+
own family, so an app that registers its whole catalogue in one order still compares across all
|
|
168
|
+
of it. A refusal is worth retrying upward and nowhere else: `MODEL_IDS.find((id) => id !==
|
|
169
|
+
refused)` answered a refusal on the default model with the next entry DOWN, so `X_LLM_REFUSED`'s
|
|
170
|
+
fix line told an operator to buy the same refusal from a weaker model. When there is no rung
|
|
171
|
+
above — including for a model nobody registered — `alternative` is `undefined` and the fix line
|
|
172
|
+
drops the suggestion rather than inventing a downgrade. A model appended after the built-ins is
|
|
173
|
+
the least capable rung, because that is what appending to a most-capable-first list means; an app
|
|
174
|
+
wanting its own ladder registers its whole catalogue in order.
|
|
175
|
+
- `AnthropicProvider.models` is `ANTHROPIC_MODEL_IDS`, its OWN list, never the registry's — an
|
|
176
|
+
app's internal model must not be routed to Anthropic. `EchoProvider.models` is a getter over the
|
|
177
|
+
registry, because a test double has to serve whatever the test registered.
|
|
178
|
+
- **The reasoning half of the body is PER MODEL, and `models.ts` owns which model takes what.**
|
|
179
|
+
`output_config.effort` and adaptive thinking arrived with 4.6, so one body sent to the whole
|
|
180
|
+
catalogue is a guaranteed 400 on the oldest entry — which is how `claude-haiku-4-5` shipped
|
|
181
|
+
blessed and uncallable. A control the caller never asked for is omitted; a control they DID
|
|
182
|
+
ask for is refused locally with `X_AI_REQUEST_INVALID`, never dropped, because a declaration
|
|
183
|
+
reading `effort: 'max'` that quietly runs at the default is the failure nobody can see. Adding
|
|
184
|
+
a model is a row in `MODELS`, never an `if` in the request builder. Omission is literal: an
|
|
185
|
+
absent `thinking` sends no block at all, where `(thinking ?? 'adaptive')` sent an adaptive one
|
|
186
|
+
for every adaptive-capable model — harmless on the wire, since adaptive is the server default,
|
|
187
|
+
but it made a defaulted control indistinguishable from a declared one, which is the whole
|
|
188
|
+
distinction this rule draws.
|
|
189
|
+
- **`openAiProvider()` is a FORMAT, not a vendor — decided 2026-08.** Azure OpenAI, vLLM, Ollama,
|
|
190
|
+
LiteLLM, OpenRouter, Together and most company gateways speak the OpenAI chat-completions wire
|
|
191
|
+
format, so one provider plus `baseUrl` is what makes "point Ultimate at our internal gateway"
|
|
192
|
+
real. Never add a second class per vendor: `baseUrl`, `auth` and `headers` are the differences.
|
|
193
|
+
- **Structured output is the `respond` tool, never `response_format`.** `llm()` already projects
|
|
194
|
+
`output` into one tool and reads the answer out of `toolCalls`; `json_schema` + `strict` would
|
|
195
|
+
be a SECOND structured-output path (axiom 1), would need the provider to synthesise a
|
|
196
|
+
`respond` call out of a content string, and is the one feature most OpenAI-*compatible* servers
|
|
197
|
+
do not implement. Forcing the function is what buys the reliability instead: `tool_choice`
|
|
198
|
+
names the tool when the request offers **exactly one**, which is precisely `llm()`'s shape and
|
|
199
|
+
never `agent()`'s — forcing a name inside a tool loop decides the model's next step for it.
|
|
200
|
+
- **`strict: true` is derived from the schema, never forwarded.** `LlmTool.strict` is `true` on
|
|
201
|
+
every projection; on this wire the server CHECKS it, and one optional field (a key in
|
|
202
|
+
`properties` absent from `required`) is a 400. `satisfiesStrictMode` is the gate, recursive
|
|
203
|
+
because the server's check is.
|
|
204
|
+
- `max_completion_tokens`, never `max_tokens`: the old field is rejected outright by every
|
|
205
|
+
current reasoning model. No `temperature`/`top_p`, same rule as the Anthropic body.
|
|
206
|
+
- **`stream_options: { include_usage: true }` on every streamed request, and an ESTIMATE when
|
|
207
|
+
usage never arrives.** Usage comes once, in a trailing chunk with an empty `choices` array, and
|
|
208
|
+
only when that field was sent — a compatible server that ignores it would otherwise leave the
|
|
209
|
+
budget reconciling a real call against zero, refunding the whole reservation. Zero is wrong by
|
|
210
|
+
all of it; an estimate is wrong by a few percent in the safe direction.
|
|
211
|
+
- `prompt_tokens` INCLUDES the cached prefix here, where Anthropic's `input_tokens` excludes it.
|
|
212
|
+
Subtract `prompt_tokens_details.cached_tokens` out of the input count or the cached half is
|
|
213
|
+
billed twice — once at the input rate, once at the cache rate.
|
|
214
|
+
- Tool-call deltas are merged by `tool_calls[].index`. The id and the name arrive on the FIRST
|
|
215
|
+
fragment and on no other, so merging by array position builds one call per chunk and keeps only
|
|
216
|
+
the last slice of arguments. The call is emitted whole at the finish reason — there is no
|
|
217
|
+
per-block stop event in this format.
|
|
218
|
+
- `isComplete()` accepts `[DONE]` **or** a finish reason: plenty of servers in the family close
|
|
219
|
+
the socket straight after the finish chunk, and a finish reason is the model saying why it
|
|
220
|
+
stopped, which a cut connection cannot produce.
|
|
221
|
+
- `role: 'system'`, not `developer` — the newer role is OpenAI's alone and every other server in
|
|
222
|
+
the family knows only `system`.
|
|
223
|
+
- **Only three models are priced** (`gpt-5.6-sol` / `-terra` / `-luna`, list price read
|
|
224
|
+
2026-08-16). `gpt-4o` and the `o1` family cache at 0.5x input where `costOf` assumes 0.1x, and
|
|
225
|
+
the `pro` tiers publish no cached rate — a wrong price is worse than a missing one, because
|
|
226
|
+
`costOf` answers confidently either way and `X_AI_MODEL_UNKNOWN` at least says so.
|
|
227
|
+
- The specs register at module scope, like the Anthropic three — so a suite that calls
|
|
228
|
+
`resetModels()` drops them. `registerOpenAiModels()` is exported for exactly that, and every
|
|
229
|
+
`openai-*.test.ts` calls it in `beforeEach`.
|
|
230
|
+
- **No new `X_*` code.** A non-2xx and an in-band `error` object are `AiTransportError`, a missing
|
|
231
|
+
key is `X_AI_KEY_MISSING`, a control the endpoint has not got is `X_AI_REQUEST_INVALID` — the
|
|
232
|
+
failures are the same failures, and a second code per provider would be a vocabulary that grows
|
|
233
|
+
with the driver list. What DID change: `AiTransportError` now takes the provider's `envVar`,
|
|
234
|
+
because the 401 fix line was a hardcoded `ANTHROPIC_API_KEY` for every provider in the package.
|
|
235
|
+
- The key is revealed as late as possible, never stored on the instance, and scrubbed out of the
|
|
236
|
+
error `detail` — a proxy echoing request headers into its 4xx body is the one path by which a
|
|
237
|
+
key reaches an error, and an error reaches a log index, a span and a problem document.
|
|
238
|
+
- Model IDs are exact aliases. Never append a date suffix.
|
|
239
|
+
- **No fix line may name `x ai`.** That command is PLANNED and throws (`packages/cli/src/cmd-planned.ts`),
|
|
240
|
+
so a fix citing `x ai reindex` sends an operator to a wall — an axiom-4 violation. Two shipped;
|
|
241
|
+
both now name the app-code fix instead.
|
|
242
|
+
- **An eval is selected with `x test eval --filter <name>`, never `x test <name>`.** `x test`'s
|
|
243
|
+
positional is a `TestType` (`unit contract live job e2e eval`), so the eval's own name there is
|
|
244
|
+
`X_CLI_BAD_FLAG`. `X_EVAL_THRESHOLD` shipped that fix line until 2026-08; `eval-errors.test.ts`
|
|
245
|
+
now asserts every `x test <word>` these five classes emit is one of the six types. The
|
|
246
|
+
`errors` step's `fix-command.ts` resolves the *command*, not its positional, so nothing else
|
|
247
|
+
would have caught it.
|
|
248
|
+
- The introductory price on a model is deliberately not modelled. A price that lapses on a date
|
|
249
|
+
makes a recorded cost depend on when it was read, and under-reporting spend after the lapse is
|
|
250
|
+
a budget that is not one. List price over-reserves, which is the safe direction.
|
|
251
|
+
- `generate()` above `STREAM_ONLY_MAX_TOKENS` runs the STREAMING transport and assembles the
|
|
252
|
+
result, rather than refusing. The ceiling is the transport's, not the model's.
|
|
253
|
+
- **`llm()` streams through `.stream()`, and it is the SAME action — decided 2026-08.** The
|
|
254
|
+
invocation is an ordinary one, marked with an ambient sink; policy, input parse, budget scope,
|
|
255
|
+
semantic cache, span, audit and `.tool()` all still apply, because there is no second execution
|
|
256
|
+
path. Before it existed, the first feature needing tokens on a screen called `aiGateway()`
|
|
257
|
+
directly and lost every one of them.
|
|
258
|
+
- **Output schema:** a schema cannot be checked until the last token lands, so a stream yields
|
|
259
|
+
UNVALIDATED text and one final `done` carrying the value that DID satisfy `output`. **No repair
|
|
260
|
+
turn** — the consumer has already read the tokens, and a second answer over the top is two
|
|
261
|
+
answers to one question. One attempt, then `X_LLM_STREAM_INVALID`, whose fix is the
|
|
262
|
+
non-streaming call.
|
|
263
|
+
- **Budget:** unchanged and still reserved before the provider is touched. `Gateway.stream`
|
|
264
|
+
debits the worst-case estimate on the first pull and reconciles at `done`, releasing in a
|
|
265
|
+
`finally`. The whole stream is driven inside the handler, so reservation and reconciliation sit
|
|
266
|
+
on one async chain; abandoning the iterator stops delivery, never the accounting.
|
|
267
|
+
- A streamed call offers **no `respond` tool**: a tool call is emitted whole, so forcing one
|
|
268
|
+
leaves nothing to stream. The answer is prose, and its JSON parse is what a non-string `output`
|
|
269
|
+
validates. A semantic-cache hit yields `done` alone, with no text increments.
|
|
270
|
+
- `.stream()` is LAZY. Nothing is authorised, budgeted or sent until the first pull. `named()` is
|
|
271
|
+
re-narrowed for the same reason `stream` is assigned in place: `action()`'s `named` builds a
|
|
272
|
+
fresh twin that would silently not stream.
|
|
273
|
+
- **`agent()` is a job for the tool loop, and the third instance of the factory rule** (after
|
|
274
|
+
`llm()` and `backfill()`) — it returns an `action`, never a ninth primitive. It exists because
|
|
275
|
+
the alternative is a hand-rolled loop, and a hand-rolled loop is where the dangerous mistake
|
|
276
|
+
lives: **taking the actor from the model's output.** `ctx.actor` is read once and is the only
|
|
277
|
+
identity any tool runs as; nothing the model emits can reach it. Bounded by `maxTurns`
|
|
278
|
+
(`X_AGENT_MAX_TURNS`, never a partial answer), by `budget.tokensPerRun` (the ledger's `request`
|
|
279
|
+
scope, which accumulates across turns) and by `maxToolResultChars` — the transcript IS the
|
|
280
|
+
request, so an untruncated tool result is re-billed once per remaining turn.
|
|
281
|
+
- **`tools` takes the real `action()`, adapted at this package's edge — decided 2026-08 (issue
|
|
282
|
+
#124).** It took `ProjectableAction` alone, which no `action()` structurally satisfies (an
|
|
283
|
+
action is `as`/`tool`/`openapi`/`job`/`contract` and the callable, never `run`), so the shape
|
|
284
|
+
the README documents was a `TS2741` and every test here hand-built a stand-in — which is why
|
|
285
|
+
the suite was green over an API that did not compile. `AgentTool = AnyAction |
|
|
286
|
+
ProjectableAction` and `asProjectableAction` are the fix, the same union `@ultimat3/mcp`'s
|
|
287
|
+
`ListedPrimitive` already accepted. **Not** a `run` member on the action facade, which was the
|
|
288
|
+
obvious alternative and is wrong three times over: it duplicates `.as()` (axiom 1), it would
|
|
289
|
+
offer a tool named `''` for an action `registerActions` has not named yet, and it would NOT
|
|
290
|
+
collapse `mcp`'s adapter, whose `toWireSchema` narrows to the subset that server's arg
|
|
291
|
+
validator can enforce while this one publishes the Messages API's (`packages/mcp/src/
|
|
292
|
+
from-action.ts` header). Two wire formats, two projections, one `invoke`.
|
|
293
|
+
- Projection happens on the FIRST RUN, memoised — never at declaration. `agent()` is evaluated at
|
|
294
|
+
module scope and `registerAction` stamps a name at boot, so `actionName()` at declaration would
|
|
295
|
+
make the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
296
|
+
- **An `agent()` is a tool of another `agent()`** — it returns an action, and an action is what a
|
|
297
|
+
tool is. That is the supervisor/sub-agent shape, with no `hive()` and no ninth primitive; the
|
|
298
|
+
sub-agent runs under the same actor, through its own policy.
|
|
299
|
+
- A tool listed in `agent({ tools })` that is not `mcp: { expose: true }` is
|
|
300
|
+
`X_AGENT_TOOL_UNEXPOSED` **at declaration**, not filtered at the call: a silently dropped tool
|
|
301
|
+
reads as offered and is not. `isMcpExposed` is the one predicate, so an in-app agent and an
|
|
302
|
+
external MCP client see the same catalogue. Asked of the DECLARATION, not of the projection,
|
|
303
|
+
because exposure needs no name and the name does not exist yet.
|
|
304
|
+
- **`ctx.signal` is read, at three points.** `throwIfAborted` at the top of every turn and before
|
|
305
|
+
every tool batch, and `GenerateRequest.signal` forwarded into `fetch` by both providers. The
|
|
306
|
+
transcript IS the request, so a loop that keeps going after the caller disconnects re-sends it
|
|
307
|
+
once per remaining turn, runs every remaining tool's side effect and discards the answer —
|
|
308
|
+
eight provider calls for an answer nothing reads. `X_ABORTED` is core's, already shipped; no
|
|
309
|
+
new code. `signal` is deliberately absent from `cacheKeyFor` and from every estimate: it says
|
|
310
|
+
whether a call was ABANDONED, never what it asked for.
|
|
311
|
+
- **The tools of ONE turn run concurrently**, unbounded within the turn, results paired by index
|
|
312
|
+
so a fast tool cannot be matched to a slow tool's `tool_use` id. Serial cost 5x wall clock for a
|
|
313
|
+
turn that asked for five tools and nothing in the types or the docs said so. No second ceiling
|
|
314
|
+
here: the batch is what one model turn asked for, each entry is an action with its own `policy`
|
|
315
|
+
and `rateLimit`, and a tool that calls a model still queues on the ledger's root turnstile.
|
|
316
|
+
Guarantee is "no tool STARTS after the abort" — a tool already in flight unwinds through its
|
|
317
|
+
own handler's reading of `ctx.signal`, which is the action's to make.
|
|
318
|
+
- **`onTurn` is the per-turn observation, and `.stream()` on `agent()` is deliberately not shipped
|
|
319
|
+
yet.** A 90-second multi-turn run emitted nothing until it returned. `onTurn` reports facts
|
|
320
|
+
only — turn, model, tool names, stop reason, usage, that turn's cost — never the transcript and
|
|
321
|
+
never the actor, and the same facts land on the span as an `agent.turn` event so a run
|
|
322
|
+
declaring no hook is still readable in a trace. A throw from it FAILS the run: an observer that
|
|
323
|
+
quietly stopped working reads exactly like one that is fine. Tokens on a screen is a different
|
|
324
|
+
contract from turns in a loop, and half-shipping it would be the second path axiom 1 refuses.
|
|
325
|
+
- **No semantic cache on `agent()`.** Similar prompts do not have similar answers once the answer
|
|
326
|
+
depends on what `lookupOrder` returned this second.
|
|
327
|
+
- **Every `tool_use` block the transcript replays is answered by a `tool_result` in the very next
|
|
328
|
+
message** — the Messages API's own rule, and `agent-transcript.ts` owns both halves of it for
|
|
329
|
+
that reason. Two paths broke it, both through `respond`, which is filtered out of the calls
|
|
330
|
+
that RUN and replayed like any other block: a turn emitting a tool call AND `respond` together
|
|
331
|
+
(ordinary parallel tool use), and a `respond` whose input failed the output schema, followed by
|
|
332
|
+
a plain user message. Both were a 400 (`tool_use ids were found without tool_result blocks`),
|
|
333
|
+
i.e. `X_AI_PROVIDER_UNAVAILABLE` in place of a completed run, and `agent.test.ts` never mixed
|
|
334
|
+
the two so neither shipped visible. An unaccepted `respond` now comes back as its own
|
|
335
|
+
`tool_result`, `is_error`, saying why — the answer is SUPERSEDED, not wrong: it was written
|
|
336
|
+
before the results of the tools the same turn asked for existed, so the loop continues and the
|
|
337
|
+
model answers again with them in hand. Discarding the block instead would have been the other
|
|
338
|
+
legal fix and loses the record; USING the speculative answer would skip the tool results the
|
|
339
|
+
model itself asked for, after those tools already ran.
|
|
340
|
+
- **A tool result is rendered totally.** `runLlmToolCall` returns `content: string` and the loop
|
|
341
|
+
TRUNCATES it, so `JSON.stringify`'s other two answers both have to be handled: `undefined` for
|
|
342
|
+
an action that returns nothing (`'null'`), and a throw on a bigint, a cycle or a `toJSON` of
|
|
343
|
+
the value's own — reported as "the tool ran and its result is not JSON", never as a failure,
|
|
344
|
+
because a model told the tool failed calls it again and buys its side effects twice. The throw
|
|
345
|
+
it catches is read with `stringField`, never `typeof error.code === 'string'`: the value is an
|
|
346
|
+
app's, so the probe is a getter call or a `Proxy` trap inside the catch block.
|
|
347
|
+
- `AiMessage.content` widened to `string | readonly AiContentBlock[]` for this: a `tool_result`
|
|
348
|
+
has to name the `tool_use` it answers and a string has nowhere to put the id. The block field
|
|
349
|
+
names are the Messages API's, so `body()` passes them through untouched.
|
|
350
|
+
- **`hive()` is a fan-out, and the FOURTH instance of the factory rule** (after `llm()`,
|
|
351
|
+
`backfill()` and `agent()`) — it returns an `action`, never a ninth primitive. It exists because
|
|
352
|
+
the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that loop gets four
|
|
353
|
+
things wrong every time: the actor, the order, the difference between ran-and-failed and
|
|
354
|
+
never-ran, and the ceiling.
|
|
355
|
+
- **`HiveResult` is a SCHEMA, not an interface**, built from the member action's own `output` and
|
|
356
|
+
embedded in the `ok` arm. That is what makes a hive project to OpenAPI, the typed client, the
|
|
357
|
+
MCP `outputSchema` and the manifest like any other action; a hand-written interface would have
|
|
358
|
+
given the type and none of the six projections, which is the whole reason it is a factory.
|
|
359
|
+
- **Three arms — `ok` / `failed` / `skipped` — never two.** A member that ran and threw and a
|
|
360
|
+
member that never ran are different facts, and an aborted sibling is the second. Two arms make
|
|
361
|
+
"the hive stopped early" indistinguishable from "every remaining item is bad data", which is
|
|
362
|
+
the difference between retrying the tail and fixing the source. `skipped` gets its own counter
|
|
363
|
+
beside `ok` and `failed` for the same reason: three arms and two counters means every caller
|
|
364
|
+
writes `members.length - ok - failed` once, and writes it wrong once.
|
|
365
|
+
- **`members` is in SPLIT order, always**, filled by index rather than pushed on settle, with
|
|
366
|
+
`index` on every arm so a caller can join a result back to its row without depending on array
|
|
367
|
+
position surviving a filter.
|
|
368
|
+
- **The hive never names an actor.** `split` derives member inputs from `input` and `ctx` and
|
|
369
|
+
from nothing a model emitted; each member runs through its own callable, so `invoke` applies
|
|
370
|
+
the member's own `policy` with `ctx.actor` untouched. There is no `as(actor)` in the factory —
|
|
371
|
+
the same boundary `agent()` holds, and the reason both belong in the framework.
|
|
372
|
+
- **No hive-specific budget code, deliberately.** One derived ledger for the run, `withBudget`
|
|
373
|
+
around the pool, each member's `agent()` deriving again — and the ceiling holds under
|
|
374
|
+
parallelism because `reserve` DEBITS on the root's turnstile before the call. Three members
|
|
375
|
+
against a ceiling only one fits leave exactly one `ok`; that is asserted through the hive
|
|
376
|
+
rather than asserted about the ledger, because the ledger already promised it.
|
|
377
|
+
- **A member's throw is RECORDED, whatever it is.** `failureOf` reads it with `isThrownError` and
|
|
378
|
+
`stringField` from core, never `error instanceof Error` and `.message`: a member is an app's
|
|
379
|
+
action, so a `Proxy` makes `instanceof` run a `getPrototypeOf` trap, and a throw there takes
|
|
380
|
+
down the whole hive — the one outcome the three arms exist to prevent. `skipped` has two
|
|
381
|
+
reasons, because they are two facts: `SKIPPED_ABORTED` (a sibling failed under `'abort'`) and
|
|
382
|
+
`SKIPPED_NO_INPUT` (the split produced nothing at that index). One string for both sent a
|
|
383
|
+
caller to retry a tail that was never cut.
|
|
384
|
+
- **`onMemberError` is required.** `'abort'` stops and leaves the rest `skipped`; `'collect'`
|
|
385
|
+
harvests. Both are right for somebody, so neither may be inherited silently.
|
|
386
|
+
- `concurrency` defaults to 4 and `minMembers` to 2, and neither number is measured off any run —
|
|
387
|
+
the framework cannot know a provider's concurrency allowance. A below-floor split still runs
|
|
388
|
+
every input it produced, serially: dropping one would be silent data loss.
|
|
389
|
+
- An empty split is `X_HIVE_EMPTY`, never a successful run of zero members — "0 ok, 0 failed"
|
|
390
|
+
cannot be told apart from a query that returned no rows and nobody noticed.
|
|
391
|
+
- An aborted `ctx` unwinds the whole hive with `X_ABORTED`, which is a DIFFERENT event from
|
|
392
|
+
`onMemberError: 'abort'`: the latter is a completed run with a partial harvest worth returning,
|
|
393
|
+
the former has nobody left to hand it to.
|
|
394
|
+
- **`describeAgents()` publishes what an `ActionDescriptor` cannot.** An agent projects to the same
|
|
395
|
+
descriptor as any other action, and that descriptor knows nothing about turns, tools, models or
|
|
396
|
+
prompt hashes — so "how far can this loop and what may it call" had no answer outside the source.
|
|
397
|
+
Same shape as `describePrompts()` / `describeEvals()`, and deliberately NOT a new
|
|
398
|
+
`ActionDescriptor` field: `@ultimat3/action` is tier 3 and knows nothing about models.
|
|
399
|
+
The facts are a THUNK, resolved when asked: `agent()` runs at module scope beside the actions it
|
|
400
|
+
lists, and every name in a row is stamped by `registerAction` at boot. An agent still carrying no
|
|
401
|
+
name has no row — not a silent drop, but the absence of a capability: an action with no name
|
|
402
|
+
reaches no route, no tool catalogue and no queue. `named()` builds a TWIN where registration
|
|
403
|
+
names in place, so an agent renamed that way is absent for the same reason; register it instead.
|
|
404
|
+
- **`agentJob()` closes #125 for the agent case, by COMPOSING `job()`** — the returned value is one
|
|
405
|
+
`job()` seated in that package's own registry, so `.enqueue()`, the outbox, the worker's
|
|
406
|
+
cancellation, the dead-letter path, `x jobs show` and its manifest row arrive without a line here.
|
|
407
|
+
Never an imitation handle: `isJobHandle` needs `kind === 'job'` plus membership of a WeakMap only
|
|
408
|
+
`job()` writes, which is exactly what stops a second execution path existing.
|
|
409
|
+
- `name`, `tenant` and `retry` are REQUIRED, no defaults. `name` because a job name is the durable
|
|
410
|
+
queue key that queued, retrying and dead-lettered rows already carry — deriving it from the
|
|
411
|
+
export name would move delivery when somebody renames a variable. `tenant` and `retry` because
|
|
412
|
+
`jobs` states that every candidate default for `tenant` is a cross-tenant read waiting for the
|
|
413
|
+
first job that takes an org id in its input. Both are `TS2741` when omitted AND have runtime
|
|
414
|
+
backstops (`X_JOB_TENANT_REQUIRED`, the `retry.attempts` assert), for generated and JS callers.
|
|
415
|
+
- **Both reads of `target.job()` are LAZY**, and that is load-bearing: `actionName()` throws
|
|
416
|
+
`X_ACTION_UNREGISTERED` until boot stamps the export name, and `agentJob()` is evaluated at
|
|
417
|
+
module scope right beside the `agent()` it wraps. Same rule as `agent()`'s tool projection and
|
|
418
|
+
`describeAgents()`' thunk — third instance in this package.
|
|
419
|
+
- **The at-least-once trap is DOCUMENTED, not enforced, and that is a decision with evidence.**
|
|
420
|
+
`idempotencyKey` dedupes the ENQUEUE, never the ATTEMPT: a lost lease re-runs the agent from the
|
|
421
|
+
top, as does every page `backfill()` replays. So every tool an `agentJob()`'d agent may call has
|
|
422
|
+
to be idempotent. The framework cannot check it: `mutates` is not a fact an `action()` declares
|
|
423
|
+
— it exists only in `@ultimat3/mcp`, whose `projectable.ts` sets it to `true` for EVERY action —
|
|
424
|
+
so a read-only `lookupOrder` and a destructive `issueRefund` are indistinguishable, and
|
|
425
|
+
`ActionFacade`'s `Pick<>` carries no `idempotent` either (only `describe()` does, and that needs
|
|
426
|
+
a name). `isMutator` IS legible without a name, but `mutator()` is the local-first write
|
|
427
|
+
primitive: refusing only those would catch almost none of the risk while reading as if it caught
|
|
428
|
+
all of it. A wrong refusal is worse than a stated obligation, so the obligation is stated — in
|
|
429
|
+
`AgentJobOptions.idempotencyKey`'s doc comment and in the README, both naming the second refund.
|
|
430
|
+
|
|
431
|
+
- **`configureAi({ redact })` is the one seam between `vars()` and the provider.** `vars()` is the
|
|
432
|
+
one declared place a model call loads data, so it is the one place a redactor can see the row
|
|
433
|
+
before it leaves the process; the redactor sees the whole RENDERED prompt and the system prompt,
|
|
434
|
+
template as well as values. WHAT to remove is the app's (a PII classifier is a model choice —
|
|
435
|
+
axiom 8). The framework ships the seam, the `llm.redacted` span attribute, and the one rule it
|
|
436
|
+
can enforce structurally: **a `Secret` in `vars()` is `X_AI_PROMPT_SECRET`**, whether or not a
|
|
437
|
+
redactor is installed. Not a leak — `Secret` renders `[redacted]` by value — but a prompt that
|
|
438
|
+
reads fine, means something else, and costs full price.
|
|
439
|
+
- **Fallback is across PROVIDERS serving one model, never across models — decided 2026-08.** The
|
|
440
|
+
wiki's LLM-gateway table claimed an ordered model list; there never was one, and building one was
|
|
441
|
+
rejected: a silent model swap changes what answered, what it cost, and which eval baseline the
|
|
442
|
+
answer belongs to, and `X_LLM_REFUSED` already names a more capable model for the DECLARATION to
|
|
443
|
+
adopt. What was missing is the other half of the claim — "never silent" — so the gateway now
|
|
444
|
+
stamps `GenerateResult.provider` with the provider that actually answered and `llm()` puts it on
|
|
445
|
+
the span as `llm.provider`. Stamped by the gateway, not the provider: routing is a gateway
|
|
446
|
+
concept, and an app's own `Provider` cannot report on a decision it did not make.
|
|
447
|
+
- A **refusal is a 200 with no answer in it**, so it becomes `X_LLM_REFUSED` at the `llm()` seam,
|
|
448
|
+
before the output is parsed. Parsing it first reports a schema disagreement — wrong cause,
|
|
449
|
+
inapplicable fix — and spends a repair turn buying the same refusal again. A truncated answer
|
|
450
|
+
that also fails its schema is `X_LLM_TRUNCATED` for the same reason: the ceiling does not move
|
|
451
|
+
between attempts. `stopDetails.category` is carried, not dropped: it is the only thing that
|
|
452
|
+
says whether another model would answer.
|
|
453
|
+
- The gateway does not cache a refusal. Caching one keeps serving a classifier decision long
|
|
454
|
+
after the prompt that provoked it was fixed.
|
|
455
|
+
- Server-side `fallbacks` (beta) are deliberately NOT sent. The provider speaks the stable
|
|
456
|
+
`2023-06-01` surface, and a 1.0 package that promises semver cannot pin a beta wire contract;
|
|
457
|
+
the typed refusal plus the gateway's own model routing is the framework's answer instead.
|
|
458
|
+
- `definePrompt` refuses a re-registered version whose hash moved.
|
|
459
|
+
- Every eval result carries the prompt hash. A score without one is not a measurement.
|
|
460
|
+
- An eval gates on the DROP from its recorded baseline, never on an absolute score. An absolute
|
|
461
|
+
floor fails every eval at once the day a provider ships a slightly different model, which
|
|
462
|
+
teaches everyone to lower thresholds until they measure nothing.
|
|
463
|
+
- The run mean AND every case are compared. A mean that holds while one case collapses is the
|
|
464
|
+
regression an eval exists to catch.
|
|
465
|
+
- A baseline that has never been recorded is `X_EVAL_BASELINE_MISSING`, and a corrupt one is
|
|
466
|
+
`X_EVAL_BASELINE_INVALID` — never "absent, so pass". A step that cannot fail is not running.
|
|
467
|
+
- `baseline` is `import.meta.resolve('./…')`. A cwd-relative path resolves to a different file
|
|
468
|
+
depending on where the suite was started, which is how a gate silently stops gating.
|
|
469
|
+
- Every registered prompt must be named by an eval (`promptsWithoutEvals`, `X_EVAL_MISSING`).
|
|
470
|
+
Coverage is by prompt ID, not ref: old versions are retained, and an eval on the current one
|
|
471
|
+
evaluates that lineage.
|
|
472
|
+
- `ULTIMATE_EVAL_RECORD=1` writes baselines instead of gating on them. A test that deliberately
|
|
473
|
+
scores a worse model calls `run`, never `assert` — `assert` would re-record during that pass.
|
|
474
|
+
- **Recording and the gate are mutually exclusive.** `x verify` with that variable set is
|
|
475
|
+
`X_EVAL_RECORDING` and runs no eval suite at all. Recording passes by definition, so a gate run
|
|
476
|
+
that inherited the flag is green over numbers it wrote itself — and rewrites every committed
|
|
477
|
+
baseline on its way through, which is the half a red step would not undo. Hence refuse *before*
|
|
478
|
+
the suite, never after it.
|
|
479
|
+
- The gate asks whether an eval has a baseline, not only whether one is declared. `defineEval`
|
|
480
|
+
proves a prompt is named; it proves nothing measured it, and an eval whose numbers were never
|
|
481
|
+
recorded — one no test asserts, one whose `baseline:` is a cwd-relative string — would otherwise
|
|
482
|
+
satisfy `X_EVAL_MISSING` while gating on nothing.
|
|
483
|
+
- Retrieval is hybrid by default. Do not add a vector-only convenience path.
|
|
484
|
+
- `PgVectorStore` is the ONLY production vector path — pgvector and Postgres FTS in the app's own
|
|
485
|
+
Postgres, never a second datastore. `MemoryVectorStore` is the dev twin and enforces the same
|
|
486
|
+
envelope; a leak that only reproduces against real Postgres is a leak nobody finds.
|
|
487
|
+
- **It is proved against a real pgvector, not only against a recording client.**
|
|
488
|
+
`pg-vector.live.test.ts` runs the whole chain — `ddl()` -> a live server -> `upsert` -> cosine,
|
|
489
|
+
FTS and the RRF fusion -> decoded hit — and REFUSES to skip when `TEST_DATABASE_URL` names a
|
|
490
|
+
Postgres without the extension, because a suite that stands down reports green for the one
|
|
491
|
+
store that runs in front of real traffic. CI's service container is `pgvector/pgvector:pg17`
|
|
492
|
+
for that reason. Asserting statement *text* cannot catch a statement Postgres rejects, nor a
|
|
493
|
+
filter that compiles cleanly and excludes nothing: that is exactly how metadata shipped bound
|
|
494
|
+
`::jsonb`. A new operator, read path or scope rule is not done until it round-trips there.
|
|
495
|
+
- The distance ordering lives in a subquery, ascending and raw, because that is the only shape
|
|
496
|
+
hnsw answers — `order by 1 - (…) desc` is a sequential scan. Both halves are pinned by a plan
|
|
497
|
+
assertion in the live suite, since only a planner can say which one shipped.
|
|
498
|
+
- hnsw applies the scope AFTER the index scan, so an approximate index can return fewer rows
|
|
499
|
+
than asked for once a tenant filter is selective. The planner takes the exact path instead
|
|
500
|
+
when it has stats — which is why a bulk backfill that skips `analyze` is how a search that
|
|
501
|
+
used the index yesterday scans today. Assert the rows a scoped read returns, never the node.
|
|
502
|
+
- Tenant and policy filters go **in SQL**, on every statement, through `conditionsSql` — and on
|
|
503
|
+
BOTH halves of the fusion. Filtering after the rows are loaded is not filtering.
|
|
504
|
+
- `(tenant, id)` is the primary key. A cross-tenant overwrite is impossible at the storage layer
|
|
505
|
+
rather than conditional on every upsert remembering to check.
|
|
506
|
+
- `scoped()` only ever TIGHTENS: tenants are set once, allow-lists intersect. Widening is
|
|
507
|
+
`X_VECTOR_SCOPE_WIDENED`. Same rule as `budget.derive`, for the same reason.
|
|
508
|
+
- Metadata is bound `::text::jsonb`. A bound string cast straight to `::jsonb` is JSON-encoded
|
|
509
|
+
twice, reads back correctly, and makes every `metadata ->> key` filter match nothing.
|
|
510
|
+
|
|
511
|
+
## Commands
|
|
512
|
+
|
|
513
|
+
```
|
|
514
|
+
bun test packages/ai
|
|
515
|
+
bun run --filter @ultimat3/ai typecheck
|
|
516
|
+
|
|
517
|
+
# the live vector suite — needs the extension, not just a Postgres
|
|
518
|
+
docker run -d -e POSTGRES_PASSWORD=ultimate -p 5432:5432 pgvector/pgvector:pg17
|
|
519
|
+
TEST_DATABASE_URL=postgres://postgres:ultimate@localhost:5432/postgres \
|
|
520
|
+
bun test packages/ai/src/pg-vector.live.test.ts
|
|
521
|
+
```
|