@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/README.md
CHANGED
|
@@ -25,6 +25,46 @@ const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async
|
|
|
25
25
|
});
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
## Budgets — and which of the three is fleet-wide
|
|
29
|
+
|
|
30
|
+
`request` is one call chain. `actor` and `orgs` are counters across calls, so where they live
|
|
31
|
+
decides what they mean:
|
|
32
|
+
|
|
33
|
+
| `budgetStore` | `actor` / `org` counts | Right for |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| omitted — `MemoryBudgetStore` (the default) | **per process**, and reset on every deploy | `x dev`, tests, a single-replica app |
|
|
36
|
+
| your own `BudgetStore` | fleet-wide | anything with more than one replica |
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { AnthropicProvider, type BudgetStore, createGateway } from '@ultimat3/ai';
|
|
40
|
+
|
|
41
|
+
declare const redis: {
|
|
42
|
+
incrby(key: string, by: number): Promise<number>;
|
|
43
|
+
del(key: string): Promise<unknown>;
|
|
44
|
+
flushdb(): Promise<unknown>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const sharedBudget: BudgetStore = {
|
|
48
|
+
spent: (key) => redis.incrby(key, 0),
|
|
49
|
+
add: async (key, tokens) => {
|
|
50
|
+
await redis.incrby(key, tokens);
|
|
51
|
+
},
|
|
52
|
+
reset: async (key) => {
|
|
53
|
+
await (key === undefined ? redis.flushdb() : redis.del(key));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const sharedGateway = createGateway({
|
|
58
|
+
providers: [new AnthropicProvider()],
|
|
59
|
+
budget: { request: 40_000, actor: 500_000, org: 20_000_000 },
|
|
60
|
+
budgetStore: sharedBudget,
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Three methods, and `add` takes a **negative** `tokens` — releasing a reservation the call never
|
|
65
|
+
spent is a credit, so a store that clamps at zero leaks the ceiling. `org: 20_000_000` on the
|
|
66
|
+
default store at `replicas: 6` is six ledgers of twenty million, which is a budget that is not one.
|
|
67
|
+
|
|
28
68
|
## Rules the gateway enforces
|
|
29
69
|
|
|
30
70
|
| Rule | Why |
|
|
@@ -38,6 +78,10 @@ const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async
|
|
|
38
78
|
| A control the model lacks is **refused**, never dropped | a declaration reading `effort: 'max'` that quietly runs at the default is the failure nobody can see |
|
|
39
79
|
| A control nobody asked for is **omitted**, never defaulted | a default sent as a request is indistinguishable on the wire from one that was declared |
|
|
40
80
|
| A refusal is `X_LLM_REFUSED`, not a schema failure | it is a 200 with no answer in it, and a repair turn buys the same refusal again |
|
|
81
|
+
| The refusal's `alternative` is only ever a **more capable** model | registration order is most-capable-first and `moreCapableThan` walks it upward; retrying a refusal on a weaker model is the one retry that cannot help, so an unbeatable model gets no suggestion at all |
|
|
82
|
+
| Fallback is across **providers serving one model**, never across models | a silent model swap changes what answered, what it cost and which eval baseline the answer belongs to; the gateway stamps `result.provider`, and `llm()` puts it on the span as `llm.provider`, so the fallback that does exist is never silent |
|
|
83
|
+
| The repair turn replays the tool call's arguments, never an empty `text` | an answer through the `respond` tool leaves `text` empty, and an empty text block is a 400 — the repair came back as `X_AI_PROVIDER_UNAVAILABLE` |
|
|
84
|
+
| `reserve()` **debits** the estimate and takes a turn | three concurrent calls otherwise read the same `spent()`, all pass, and all three record against a ceiling only one of them fitted; `record` reconciles and `release` gives it back |
|
|
41
85
|
| A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
|
|
42
86
|
| Retries use **full jitter** | synchronised retries from N workers reproduce the rate limit |
|
|
43
87
|
| A 4xx is never retried | the same body gets the same rejection and burns the budget |
|
|
@@ -80,7 +124,36 @@ Vectors are L2-normalised on arrival, so `cosine` stays a dot product. A width o
|
|
|
80
124
|
declared `dimension` is `X_VECTOR_DIM_MISMATCH` **before** anything reaches a store — a store
|
|
81
125
|
half-written at the wrong width has no error to report, only worse answers.
|
|
82
126
|
|
|
83
|
-
|
|
127
|
+
## The model catalogue is open
|
|
128
|
+
|
|
129
|
+
`ModelId` is a **`string`**, and the catalogue is a registry. Your own gateway, Bedrock, Azure,
|
|
130
|
+
Vertex, a fine-tune, a negotiated rate — all expressible, none needing a fork.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
registerModel({
|
|
134
|
+
id: 'llama-internal-70b',
|
|
135
|
+
contextWindow: 128_000,
|
|
136
|
+
maxOutput: 8_192,
|
|
137
|
+
inputPerMillion: { minor: 20, currency: 'USD' }, // YOUR price, integer minor units
|
|
138
|
+
outputPerMillion: { minor: 40, currency: 'USD' },
|
|
139
|
+
cacheMinimumTokens: 0,
|
|
140
|
+
reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
configureAi({ gateway: createGateway({ providers: [new InternalGatewayProvider()] }) });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**The three built-ins register through this same call.** There is one way to put a model in the
|
|
147
|
+
catalogue, and the default path is the app's path. Re-registering an id replaces its spec and keeps
|
|
148
|
+
its rung — which is how a negotiated enterprise rate is expressed, and why there is no second
|
|
149
|
+
`overrideModel` call. An id nothing registered is `X_AI_MODEL_UNKNOWN` at the first read, naming
|
|
150
|
+
the registered set; that check is what replaced the closed union, so a wrong id is still caught
|
|
151
|
+
without making a right one inexpressible.
|
|
152
|
+
|
|
153
|
+
Registration order is the capability ladder, most capable first — `moreCapableThan` is its only
|
|
154
|
+
reader, and `X_LLM_REFUSED`'s fix line the only thing that acts on it.
|
|
155
|
+
|
|
156
|
+
Built in, `As of 2026-08`:
|
|
84
157
|
|
|
85
158
|
| Model | Context | Max output | Input / MTok | Output / MTok | `effort` | adaptive thinking |
|
|
86
159
|
|---|---|---|---|---|---|---|
|
|
@@ -90,6 +163,76 @@ Models, `As of 2026-08`:
|
|
|
90
163
|
|
|
91
164
|
The last two columns are data on the spec, not prose: `body()` builds the reasoning half from
|
|
92
165
|
them, so a downgrade for price cannot become a request the provider rejects.
|
|
166
|
+
`AnthropicProvider.models` is its own list, never the registry's — your internal model is not
|
|
167
|
+
routed to Anthropic.
|
|
168
|
+
|
|
169
|
+
## The OpenAI **format** — Azure, vLLM, Ollama, your own gateway
|
|
170
|
+
|
|
171
|
+
`openAiProvider()` speaks the OpenAI chat-completions **wire format**, not one vendor. Azure
|
|
172
|
+
OpenAI, vLLM, Ollama, LiteLLM, OpenRouter, Together and most self-hosted company gateways serve
|
|
173
|
+
that format, so "point Ultimate at our internal model gateway" is a `baseUrl` and a `models` list.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { openAiProvider, OPENAI_MODEL_IDS, createGateway, configureAi } from '@ultimat3/ai';
|
|
177
|
+
|
|
178
|
+
// OpenAI itself. `apiKey` takes a `Secret`; OPENAI_API_KEY is read when it is omitted.
|
|
179
|
+
openAiProvider({ apiKey: env.OPENAI_API_KEY, models: [...OPENAI_MODEL_IDS] });
|
|
180
|
+
|
|
181
|
+
// Azure OpenAI — the deployment URL as written, api-version query and all. `models` are
|
|
182
|
+
// DEPLOYMENT names on Azure, and the key rides in `api-key`, not `Authorization`.
|
|
183
|
+
openAiProvider({
|
|
184
|
+
apiKey: env.AZURE_OPENAI_KEY,
|
|
185
|
+
auth: 'api-key',
|
|
186
|
+
baseUrl: 'https://acme.openai.azure.com/openai/deployments/prod?api-version=2026-05-01',
|
|
187
|
+
models: ['prod'],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// vLLM / your own gateway, on the cluster. Register the model first — nothing can price an id
|
|
191
|
+
// the catalogue has never heard of.
|
|
192
|
+
openAiProvider({
|
|
193
|
+
apiKey: env.GATEWAY_TOKEN,
|
|
194
|
+
baseUrl: 'https://llm.acme.internal/v1',
|
|
195
|
+
models: ['llama-internal-70b'],
|
|
196
|
+
name: 'acme-gateway', // what `result.provider` and `llm.provider` will say
|
|
197
|
+
headers: { 'x-team': 'platform' },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Ollama, on a laptop. The key is required and ignored, exactly as Ollama's own docs have it.
|
|
201
|
+
openAiProvider({ apiKey: 'ollama', baseUrl: 'http://localhost:11434/v1', models: ['qwen3'] });
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Priced built-ins — list price from `developers.openai.com/api/docs/pricing`, read **2026-08-16**:
|
|
205
|
+
|
|
206
|
+
| Model | Context | Max output | Input / MTok | Output / MTok | `reasoning_effort` |
|
|
207
|
+
|---|---|---|---|---|---|
|
|
208
|
+
| `gpt-5.6-sol` | 1.05M | 128K | $5 | $30 | yes |
|
|
209
|
+
| `gpt-5.6-terra` | 1.05M | 128K | $2 | $12 | yes |
|
|
210
|
+
| `gpt-5.6-luna` | 1.05M | 128K | $0.20 | $1.20 | yes |
|
|
211
|
+
|
|
212
|
+
Three, and no more, on purpose: `gpt-4o` and the `o1` family cache at **0.5x** input where `costOf`
|
|
213
|
+
assumes 0.1x, and the `pro` tiers publish no cached rate at all. A wrong price is worse than a
|
|
214
|
+
missing one — `costOf` answers confidently either way, and the missing entry says so with
|
|
215
|
+
`X_AI_MODEL_UNKNOWN`. Register those yourself, at the rate your own contract names.
|
|
216
|
+
|
|
217
|
+
| Rule | Why |
|
|
218
|
+
|---|---|
|
|
219
|
+
| **Structured output is the `respond` tool**, never `response_format` | `llm()` already projects `output` into one tool and reads the answer out of the tool call; `json_schema` + `strict` would be a second structured-output path (axiom 1) and is the one feature most OpenAI-*compatible* servers do not implement |
|
|
220
|
+
| `tool_choice` is forced when the request offers **exactly one** tool | one tool is nothing to choose between, and that is precisely `llm()`'s shape. A tool loop (`agent()`) is never forced — that would decide the model's next step for it |
|
|
221
|
+
| `strict: true` is claimed only when the schema **can keep the promise** | on this wire `strict` is checked by the server: one optional field and the request is a 400. The flag is derived from the projected schema, never forwarded |
|
|
222
|
+
| `max_completion_tokens`, never `max_tokens` | the old field is rejected outright by every current reasoning model |
|
|
223
|
+
| `stream_options: { include_usage: true }` on every streamed call | without it the final chunk carries no `usage`, and the budget reconciles a real call against nothing |
|
|
224
|
+
| Usage absent anyway → **estimated**, never zero | a compatible server that ignores `stream_options` would otherwise refund the whole reservation |
|
|
225
|
+
| `prompt_tokens` minus `cached_tokens` is the input count | this format counts the cached prefix inside `prompt_tokens`; Anthropic's excludes it, and reporting it as-is bills the cached half twice |
|
|
226
|
+
| Tool-call deltas are merged by `tool_calls[].index` | id and name arrive on the first fragment only — merging by array position builds one call per chunk |
|
|
227
|
+
| A tool call is emitted **whole**, at the finish reason | there is no per-block stop event here, and a fragment is not an argument list |
|
|
228
|
+
| `role: 'system'`, not `developer` | every other server in the family knows only `system`, and OpenAI accepts it |
|
|
229
|
+
| A refusal (`message.refusal`, or `finish_reason: 'content_filter'`) is `X_LLM_REFUSED` | it is a 200 with no answer in it, exactly as on the Anthropic path |
|
|
230
|
+
| The API key is revealed as late as possible, and scrubbed out of error detail | a proxy that echoes request headers into its 4xx body is the one path by which a key reaches a log index |
|
|
231
|
+
|
|
232
|
+
`thinking` maps onto the one field this format has: `'disabled'` is `reasoning_effort: 'none'`,
|
|
233
|
+
`effort` is `reasoning_effort` as written, and asking for both is `X_AI_REQUEST_INVALID` rather
|
|
234
|
+
than a silent pick. A model registered with `reasoning: { effort: false }` refuses both locally, so
|
|
235
|
+
a llama behind vLLM never gets a field it would reject.
|
|
93
236
|
|
|
94
237
|
## `llm()` — a model call, declared as an action
|
|
95
238
|
|
|
@@ -124,7 +267,211 @@ summarize.contract(); // the contract tests
|
|
|
124
267
|
| `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
|
|
125
268
|
| `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation |
|
|
126
269
|
| `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
|
|
127
|
-
| `vars` | the one declared place a model call loads data, so a reader can see what was sent |
|
|
270
|
+
| `vars` | the one declared place a model call loads data, so a reader can see what was sent — and the one place a redactor sees it, and where a `Secret` is refused |
|
|
271
|
+
|
|
272
|
+
### Streaming is the same action
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
for await (const chunk of summarize.stream({ postId }, { ctx })) {
|
|
276
|
+
if (chunk.type === 'text') write(chunk.text);
|
|
277
|
+
if (chunk.type === 'done') save(chunk.value); // validated against `output`
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Policy, input parse, budget scope, semantic cache, span, audit and `.tool()` all still apply: the
|
|
282
|
+
invocation is an ordinary one, marked so the model half streams. Two consequences worth knowing:
|
|
283
|
+
|
|
284
|
+
| Decision | Why |
|
|
285
|
+
|---|---|
|
|
286
|
+
| the `done` chunk carries the validated value; text increments are **unvalidated** | a schema cannot be checked until the last token has landed |
|
|
287
|
+
| **no repair turn** — a bad shape is `X_LLM_STREAM_INVALID` | the consumer has already read the tokens; a second answer over the top is two answers to one question. The fix names the non-streaming call |
|
|
288
|
+
| the budget is reserved **before the first token** and reconciled at `done` | unchanged from `generate()`; a stream that throws or is abandoned releases in a `finally` |
|
|
289
|
+
| no `respond` tool is offered | a tool call is emitted whole, so forcing one leaves nothing to stream — the answer is prose, and its JSON parse is what a non-string `output` validates |
|
|
290
|
+
| lazy | nothing is authorised, budgeted or sent until the first pull |
|
|
291
|
+
|
|
292
|
+
## `agent()` — the tool loop, also an action
|
|
293
|
+
|
|
294
|
+
The second half of "no ninth primitive": a tool-using run is still one server-authoritative
|
|
295
|
+
operation with an input schema, an output schema and a policy.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
export const support = agent({
|
|
299
|
+
input: t.object({ orderId: t.string }),
|
|
300
|
+
output: t.object({ answer: t.string }),
|
|
301
|
+
prompt: supportPrompt,
|
|
302
|
+
vars: ({ input }) => ({ orderId: input.orderId }),
|
|
303
|
+
tools: [lookupOrder, issueRefund], // real actions, each mcp.expose
|
|
304
|
+
maxTurns: 6,
|
|
305
|
+
maxToolResultChars: 4_000,
|
|
306
|
+
budget: { tokensPerRun: 200_000, costPerCall: { minor: 50, currency: 'USD' } },
|
|
307
|
+
policy: can('order:support'),
|
|
308
|
+
onTurn: ({ turn, toolCalls, cost }) => progress.push({ turn, toolCalls, cost }),
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`tools` takes the `action()` an app already wrote — `[lookupOrder, issueRefund]`, the imports
|
|
313
|
+
themselves. `As of 2026-08`: it took a hand-shaped `ProjectableAction` until then, so the line
|
|
314
|
+
above was a `TS2741` against every real action (issue #124) and the only thing that satisfied it
|
|
315
|
+
was a stand-in written for a test.
|
|
316
|
+
|
|
317
|
+
An `agent()` returns an action, so **an agent is a tool of another agent** — a supervisor lists a
|
|
318
|
+
sub-agent in its own `tools` and the sub-agent runs under the same actor, through the same policy.
|
|
319
|
+
No `hive()`, no supervisor primitive: it falls out of the factory rule.
|
|
320
|
+
|
|
321
|
+
| Rule | Why |
|
|
322
|
+
|---|---|
|
|
323
|
+
| the actor is **`ctx.actor`**, read once, never from the model | this is the mistake a hand-rolled loop ships, and the reason the loop belongs in the framework |
|
|
324
|
+
| 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 |
|
|
325
|
+
| 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 |
|
|
326
|
+
| `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 |
|
|
327
|
+
| 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 |
|
|
328
|
+
| 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 |
|
|
329
|
+
| `budget.tokensPerRun` caps the **whole run** | a single call is bounded by `maxTokens`; a loop is bounded by nothing until this is set |
|
|
330
|
+
| a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
|
|
331
|
+
| **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
|
|
332
|
+
|
|
333
|
+
## `hive()` — many members, one action
|
|
334
|
+
|
|
335
|
+
Fan an action out over many inputs. The fourth factory over a primitive, after `llm()`,
|
|
336
|
+
`backfill()` and `agent()`: a fan-out is still one server-authoritative operation with an input
|
|
337
|
+
schema, an output schema and a policy.
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
import { action, t } from '@ultimat3/action';
|
|
341
|
+
import { hive } from '@ultimat3/ai';
|
|
342
|
+
import { allow } from '@ultimat3/policy';
|
|
343
|
+
|
|
344
|
+
const summarisePost = action({
|
|
345
|
+
input: t.object({ postId: t.uuid }),
|
|
346
|
+
output: t.object({ summary: t.string }),
|
|
347
|
+
policy: allow(),
|
|
348
|
+
mcp: { expose: true },
|
|
349
|
+
handle: ({ input }) => ({ summary: input.postId }),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
export const summariseBacklog = hive({
|
|
353
|
+
input: t.object({ postIds: t.array(t.uuid) }),
|
|
354
|
+
member: summarisePost,
|
|
355
|
+
split: ({ input }) => input.postIds.map((postId) => ({ postId })),
|
|
356
|
+
concurrency: 8,
|
|
357
|
+
minMembers: 2,
|
|
358
|
+
onMemberError: 'collect',
|
|
359
|
+
budget: { tokensPerRun: 500_000 },
|
|
360
|
+
policy: allow(),
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`member` is any action — most usefully an `agent()`, which makes a hive a **supervisor over
|
|
365
|
+
sub-agents** with no supervisor primitive anywhere.
|
|
366
|
+
|
|
367
|
+
| Rule | Why |
|
|
368
|
+
|---|---|
|
|
369
|
+
| `members` comes back in **split order**, with `index` on every arm | a hand-rolled `Promise.all` reports in completion order, so joining a result back to the row it came from silently depends on nothing having failed |
|
|
370
|
+
| three arms — `ok`, `failed`, `skipped` — never two | *ran and threw* and *never ran* are different facts, and an aborted sibling is the second. Collapsing them makes "the hive stopped early" read as "every remaining item is bad data" |
|
|
371
|
+
| `onMemberError` is **required** | `'abort'` stops and leaves the rest `skipped`; `'collect'` harvests the rest. Both are right for somebody, so neither is a default |
|
|
372
|
+
| the hive **never names an actor** | `split` derives member inputs from `input` and `ctx` and from nothing a model emitted; each member runs through its own callable, so `invoke` applies the member's own policy with `ctx.actor` untouched |
|
|
373
|
+
| `concurrency` bounds the fan-out; one derived ledger bounds the spend | the ceiling holds under parallelism because the budget's root turnstile debits before the call, so three members against a ceiling only one fits leave exactly one `ok` — no hive-specific budget code exists |
|
|
374
|
+
| an empty split is `X_HIVE_EMPTY` | "0 ok, 0 failed" cannot be told apart from a query that returned no rows and nobody noticed |
|
|
375
|
+
| `minMembers` (default 2) stops fanning out, and **drops nothing** | a member's fixed cost dominates trivial work; below the floor every input still runs, serially |
|
|
376
|
+
| an aborted `ctx` unwinds the whole hive with `X_ABORTED` | distinct from `onMemberError: 'abort'`, which is a completed run with a partial harvest worth returning — here there is nobody left to hand it to |
|
|
377
|
+
|
|
378
|
+
## `agentJob()` — an agent as durable background work
|
|
379
|
+
|
|
380
|
+
Run an agent over a million rows as resumable, retried, budgeted queue work. `As of 2026-08` this
|
|
381
|
+
is the only way an agent reaches a queue at all: `.job()` hands back `kind: 'action-job'`, and
|
|
382
|
+
`isJobHandle` needs `kind === 'job'` plus membership of a `WeakMap` only `job()` writes, so nothing
|
|
383
|
+
externally shaped has ever reached the registry, the worker or the dead-letter path (issue #125).
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { t } from '@ultimat3/action';
|
|
387
|
+
import { agent, agentJob, definePrompt } from '@ultimat3/ai';
|
|
388
|
+
import { allow } from '@ultimat3/policy';
|
|
389
|
+
|
|
390
|
+
const summarisePost = agent({
|
|
391
|
+
input: t.object({ postId: t.uuid, orgId: t.uuid }),
|
|
392
|
+
output: t.object({ summary: t.string }),
|
|
393
|
+
prompt: definePrompt<{ postId: string }>({
|
|
394
|
+
id: 'summarise-post',
|
|
395
|
+
version: '1.0.0',
|
|
396
|
+
template: 'Summarise post {{postId}}.',
|
|
397
|
+
}),
|
|
398
|
+
vars: ({ input }) => ({ postId: input.postId }),
|
|
399
|
+
tools: [],
|
|
400
|
+
policy: allow(),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
export const summariseBacklog = agentJob(summarisePost, {
|
|
404
|
+
name: 'summarise-backlog',
|
|
405
|
+
tenant: (input) => input.orgId,
|
|
406
|
+
retry: { attempts: 3, backoff: 'exponential' },
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
It composes `job()` rather than imitating a handle, so `.enqueue()`, the outbox, the worker's
|
|
411
|
+
cancellation, `x jobs show` and its manifest row all arrive for free. Pair it with `backfill()` for
|
|
412
|
+
the sweep and `hive()` for the fan-out inside one page.
|
|
413
|
+
|
|
414
|
+
| Rule | Why |
|
|
415
|
+
|---|---|
|
|
416
|
+
| `name` is required, and is the queue key | a job name is what queued, retrying and dead-lettered rows already carry, so renaming an export must not move where they are delivered |
|
|
417
|
+
| `tenant` and `retry` are required, no default | `jobs` states it: every candidate default for `tenant` is a cross-tenant read waiting for the first job that takes an org id in its input. `tenant: 'none'` is the explicit statement that it touches no scoped table |
|
|
418
|
+
| the action projection is read **lazily** | `agentJob()` runs at module scope beside the `agent()` it wraps, and names are stamped by `registerAction` at boot — reading `.job()` eagerly makes that ordinary file `X_ACTION_UNREGISTERED` |
|
|
419
|
+
| one execution path, and it is the action's | `run` is `invoke(agent, input, { surface: 'job', ctx })`, so the agent's policy, input parse, budget scope and span all apply — and the `ctx` is the worker's, so an attempt timing out aborts the agent's turn loop |
|
|
420
|
+
| the actor is the worker context's, never the model's | the job body runs with system authority and the org comes from the job's declared `tenant`; nothing a model emits can reach either |
|
|
421
|
+
|
|
422
|
+
### The at-least-once trap, said plainly
|
|
423
|
+
|
|
424
|
+
**`idempotencyKey` dedupes the ENQUEUE, never the ATTEMPT.** Two enqueues with the same payload are
|
|
425
|
+
one row. One row that a worker claims, half-runs and loses the lease on is claimed again, and **the
|
|
426
|
+
agent runs a second time from the top** — as does every page a `backfill()` replays, since its
|
|
427
|
+
`handle` is at-least-once by construction.
|
|
428
|
+
|
|
429
|
+
So every tool the agent may call has to be idempotent: an `upsertAll`, an `updateWhere`, a statement
|
|
430
|
+
whose second run changes nothing. Otherwise a replayed attempt issues a second refund.
|
|
431
|
+
|
|
432
|
+
**The framework does not check this, and the reason is worth knowing.** `mutates` is not a fact an
|
|
433
|
+
`action()` declares — it exists only in `@ultimat3/mcp`, which sets it to `true` for *every* action
|
|
434
|
+
it projects — so a read-only `lookupOrder` and a destructive `issueRefund` are indistinguishable
|
|
435
|
+
here. A rule refusing every tool that has not declared `idempotent: true` would refuse the reads
|
|
436
|
+
too, and a wrong refusal is worse than a stated obligation. `isMutator` is legible, but `mutator()`
|
|
437
|
+
is the local-first write primitive and catches almost none of the risk while reading as if it
|
|
438
|
+
caught all of it. This is a contract you keep, not one the compiler keeps for you.
|
|
439
|
+
|
|
440
|
+
## `describeAgents()` — what the manifest can say
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { describeAgents } from '@ultimat3/ai';
|
|
444
|
+
|
|
445
|
+
describeAgents();
|
|
446
|
+
// [{ name: 'supportAgent', prompt: 'support@1.0.0', promptHash: '…', model: 'claude-opus-5',
|
|
447
|
+
// maxTurns: 6, maxToolResultChars: 4000, tools: ['issueRefund', 'lookupOrder'],
|
|
448
|
+
// budget: { tokensIn: null, tokensPerRun: 200000, costPerCall: { minor: 50, currency: 'USD' } },
|
|
449
|
+
// mcp: true }]
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
An agent projects to an `ActionDescriptor` like any other action, and that descriptor knows nothing
|
|
453
|
+
about turns or tools — so "how far can this loop, and what may it call" had no answer outside the
|
|
454
|
+
source. Names are read when you ask, not when the agent was declared: `registerAction` stamps them
|
|
455
|
+
at boot, long after `agent()` ran at module scope. An agent nothing registered has no row, because
|
|
456
|
+
an action with no name reaches no route, no tool catalogue and no queue.
|
|
457
|
+
|
|
458
|
+
## Redaction: one declared seam
|
|
459
|
+
|
|
460
|
+
`vars()` is the one place a model call loads data, so it is the one place anything can sit between
|
|
461
|
+
the row and a third-party endpoint.
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
configureAi({ gateway, redact: (text) => scrubPatientIdentifiers(text) });
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
The redactor sees the whole rendered prompt and the system prompt — template as well as values,
|
|
468
|
+
because a redactor shown only the values cannot tell a name in a data slot from the same name in an
|
|
469
|
+
instruction. Whether it changed anything is on the span as `llm.redacted`.
|
|
470
|
+
|
|
471
|
+
**What** to remove is yours: a PII classifier is a model choice, so the framework ships the seam
|
|
472
|
+
and not the classifier. The one rule it does enforce, redactor or not: a `Secret` among the
|
|
473
|
+
variables is `X_AI_PROMPT_SECRET`. Not a leak — `Secret` renders `[redacted]` by value — but a
|
|
474
|
+
prompt that reads fine, means something else, and costs full price.
|
|
128
475
|
|
|
129
476
|
The gateway is ambient, installed once at boot — a declaration is evaluated at module scope,
|
|
130
477
|
long before a provider exists:
|
|
@@ -186,7 +533,7 @@ X_EVAL_THRESHOLD: an eval scored below its tolerance
|
|
|
186
533
|
cause: eval "summarize" scored 0.667 against a recorded baseline of 1.000
|
|
187
534
|
(tolerance 0.050) on prompt version summarize@1.0.0 (a3f1…);
|
|
188
535
|
regressed: overall 0.67 ← 1.00, refund 0.00 ← 1.00
|
|
189
|
-
fix: x test summarize to see per-case scores, then fix the prompt — or
|
|
536
|
+
fix: x test eval --filter summarize to see per-case scores, then fix the prompt — or
|
|
190
537
|
ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff
|
|
191
538
|
```
|
|
192
539
|
|
|
@@ -229,7 +576,14 @@ RRF fuses by *rank*, so the two score scales never have to be reconciled.
|
|
|
229
576
|
`PgVectorStore` is the production path: pgvector cosine (`<=>`, HNSW) and Postgres FTS
|
|
230
577
|
(`websearch_to_tsquery` + `ts_rank_cd`, GIN) in **the same Postgres**, fused by `1/(k+rank)` in
|
|
231
578
|
one statement. `MemoryVectorStore` is the dev twin — BM25 instead of `ts_rank_cd`, the same RRF,
|
|
232
|
-
the same envelope.
|
|
579
|
+
the same envelope.
|
|
580
|
+
|
|
581
|
+
`store.ddl()` returns one string: `create extension if not exists vector`, the table, and the
|
|
582
|
+
three indexes (hnsw on `embedding`, GIN on `tsv`, GIN on `metadata`). **No command emits it,
|
|
583
|
+
`As of 2026-08`** — `x db gen <name>` diffs `describeEntities()`, a vector store is not an
|
|
584
|
+
`entity()`, and no CLI file references `PgVectorStore` or `ddl()` at all. Split it and paste each
|
|
585
|
+
statement into its own file under `packages/db/migrations/`, exactly as `AUTH_TABLES` is applied,
|
|
586
|
+
then `x db migrate`.
|
|
233
587
|
|
|
234
588
|
### The scope is the leak-proofing
|
|
235
589
|
|
|
@@ -251,12 +605,14 @@ nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant
|
|
|
251
605
|
## Tools: the same projection as MCP
|
|
252
606
|
|
|
253
607
|
```ts
|
|
608
|
+
// `ProjectableAction` — `{ name, mcp?, inputJsonSchema?, run }`, the projection SEAM.
|
|
254
609
|
const tools = toLlmTools([publishPost, suspendUser]); // only those with mcp.expose
|
|
255
610
|
const result = await runLlmToolCall(actions, call, actor);
|
|
256
611
|
```
|
|
257
612
|
|
|
258
|
-
An in-app agent and an external MCP agent both end at `
|
|
259
|
-
|
|
613
|
+
An in-app agent and an external MCP agent both end at the same `invoke` — `run` is the seam that
|
|
614
|
+
carries it, and an action facade has no `.run` of its own. So they authorize identically. The
|
|
615
|
+
actor comes from the request context, never from the model.
|
|
260
616
|
|
|
261
617
|
## Errors
|
|
262
618
|
|
|
@@ -266,7 +622,12 @@ identically. The actor comes from the request context, never from the model.
|
|
|
266
622
|
| `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains |
|
|
267
623
|
| `X_AI_GATEWAY_MISSING` | an `llm()` action ran before `configureAi` |
|
|
268
624
|
| `X_AI_PROMPT_VERSION` | version drift, or a render missing a declared variable |
|
|
625
|
+
| `X_AI_MODEL_UNKNOWN` | a model id nothing called `registerModel` for; names the registered set |
|
|
626
|
+
| `X_AI_PROMPT_SECRET` | `vars()` returned a `Secret`, which would render `[redacted]` into the prompt |
|
|
269
627
|
| `X_LLM_OUTPUT_INVALID` | the model failed its `output` schema on the answer and on the repair turn |
|
|
628
|
+
| `X_LLM_STREAM_INVALID` | a streamed answer failed its schema, and a stream cannot take a repair turn |
|
|
629
|
+
| `X_AGENT_MAX_TURNS` | an `agent()` used every turn without answering |
|
|
630
|
+
| `X_AGENT_TOOL_UNEXPOSED` | an `agent()` lists an action no MCP surface exposes |
|
|
270
631
|
| `X_EVAL_THRESHOLD` | an eval scored below its bar |
|
|
271
632
|
| `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
|
|
272
633
|
| `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,13 +31,14 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/action": "
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/
|
|
38
|
-
"@ultimat3/
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
34
|
+
"@ultimat3/action": "3.0.0",
|
|
35
|
+
"@ultimat3/cache": "3.0.0",
|
|
36
|
+
"@ultimat3/core": "3.0.0",
|
|
37
|
+
"@ultimat3/db": "3.0.0",
|
|
38
|
+
"@ultimat3/jobs": "3.0.0",
|
|
39
|
+
"@ultimat3/money": "3.0.0",
|
|
40
|
+
"@ultimat3/policy": "3.0.0",
|
|
41
|
+
"@ultimat3/schema": "3.0.0",
|
|
42
|
+
"@ultimat3/time": "3.0.0"
|
|
41
43
|
}
|
|
42
44
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// What an `agent()` IS, published for the manifest — turns, tools, budget, model, prompt hash.
|
|
2
|
+
//
|
|
3
|
+
// Nothing agent-shaped was visible anywhere before this: an agent projects to `ActionDescriptor`
|
|
4
|
+
// like every other action, and that descriptor deliberately knows nothing about turns or tools,
|
|
5
|
+
// so "which agents does this app have, how far can each one loop, and what may it call" had no
|
|
6
|
+
// answer outside reading the source. Same shape as `describePrompts()` / `describeEvals()`, and
|
|
7
|
+
// deliberately NOT a new `ActionDescriptor` field: @ultimat3/action is tier 3 and knows nothing
|
|
8
|
+
// about models.
|
|
9
|
+
|
|
10
|
+
import type { AnyAction } from '@ultimat3/action';
|
|
11
|
+
import type { Money } from '@ultimat3/money';
|
|
12
|
+
|
|
13
|
+
/** The declared ceilings, flattened so a manifest row is plain JSON. `null` is "not declared". */
|
|
14
|
+
export interface AgentBudgetFact {
|
|
15
|
+
readonly tokensIn: number | null;
|
|
16
|
+
readonly tokensPerRun: number | null;
|
|
17
|
+
readonly costPerCall: Money | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentFact {
|
|
21
|
+
/** The export name registration stamped — the same name `.tool()` and `tools/call` answer to. */
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly prompt: string;
|
|
24
|
+
readonly promptId: string;
|
|
25
|
+
/**
|
|
26
|
+
* The prompt's content hash. An agent's behaviour is its prompt, so a row without one records
|
|
27
|
+
* which agent ran and not which agent it was — the same reason every eval result carries it.
|
|
28
|
+
*/
|
|
29
|
+
readonly promptHash: string;
|
|
30
|
+
readonly model: string;
|
|
31
|
+
readonly maxTurns: number;
|
|
32
|
+
readonly maxToolResultChars: number;
|
|
33
|
+
/** Tool names, sorted — the catalogue this agent may call, which is its blast radius. */
|
|
34
|
+
readonly tools: readonly string[];
|
|
35
|
+
readonly budget: AgentBudgetFact;
|
|
36
|
+
/** Whether the agent itself is offered as a tool, so a supervisor could call it. */
|
|
37
|
+
readonly mcp: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keyed by the action, and the facts are a THUNK: every name in a row — the agent's and its
|
|
42
|
+
* tools' — is stamped by `registerAction` at boot, long after `agent()` ran at module scope.
|
|
43
|
+
* Reading them here rather than at declaration is what makes a row name what an app can call.
|
|
44
|
+
*/
|
|
45
|
+
const registry = new Map<AnyAction, () => Omit<AgentFact, 'name'>>();
|
|
46
|
+
|
|
47
|
+
export function registerAgentFact(target: AnyAction, facts: () => Omit<AgentFact, 'name'>): void {
|
|
48
|
+
registry.set(target, facts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every registered agent, by name.
|
|
53
|
+
*
|
|
54
|
+
* An agent still carrying no name is left out, and that is not a silent drop: a name is stamped by
|
|
55
|
+
* `registerAction`, an action without one reaches no route, no tool catalogue and no queue, so
|
|
56
|
+
* there is no capability for a row to describe. `named()` builds a TWIN rather than naming in
|
|
57
|
+
* place — registration names in place — so an agent renamed that way is absent for the same
|
|
58
|
+
* reason. Register it instead: `registerAction('supportAgent', support)`.
|
|
59
|
+
*/
|
|
60
|
+
export function describeAgents(): readonly AgentFact[] {
|
|
61
|
+
return [...registry.entries()]
|
|
62
|
+
.filter(([target]) => target.name !== '')
|
|
63
|
+
.map(([target, facts]) => ({ name: target.name, ...facts() }))
|
|
64
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Test-only reset. A module-level registry otherwise leaks between test files. */
|
|
68
|
+
export function resetAgents(): void {
|
|
69
|
+
registry.clear();
|
|
70
|
+
}
|
package/src/agent-job.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentJob()` — an agent as durable, resumable, budgeted background work.
|
|
3
|
+
*
|
|
4
|
+
* The bridge `packages/action/src/job-handle.ts` names in its own header and could not build:
|
|
5
|
+
* `isJobHandle` needs `kind === 'job'` PLUS membership of a WeakMap only `job()` writes, so no
|
|
6
|
+
* externally-shaped object reaches the registry, the queue or the worker — and `action` and `jobs`
|
|
7
|
+
* are both tier 3, so neither may import the other. This package is tier 4 and may import both,
|
|
8
|
+
* which is exactly where that header says the adapter belongs.
|
|
9
|
+
*
|
|
10
|
+
* It composes `job()` rather than re-implementing a handle: the returned value IS one `job()`
|
|
11
|
+
* seated, so `.enqueue()`, the outbox, the worker's cancellation, the dead-letter path,
|
|
12
|
+
* `x jobs show` and its manifest row all arrive without a line here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Action, ActionJobHandle } from '@ultimat3/action';
|
|
16
|
+
import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
|
|
17
|
+
import { job } from '@ultimat3/jobs';
|
|
18
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
|
+
|
|
20
|
+
export interface AgentJobOptions<I> {
|
|
21
|
+
/**
|
|
22
|
+
* The durable queue key. REQUIRED and never derived from the agent's export name: a job name is
|
|
23
|
+
* what queued, retrying and dead-lettered rows already carry, so renaming an export must not
|
|
24
|
+
* move where they are delivered.
|
|
25
|
+
*/
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/**
|
|
28
|
+
* REQUIRED, no default, and the org this run's body acts under. `jobs` states why: every
|
|
29
|
+
* candidate default is a cross-tenant read waiting for the first job that takes an org id in its
|
|
30
|
+
* input. `tenant: 'none'` is the explicit statement that this agent touches no tenant-scoped
|
|
31
|
+
* table, and then every scoped read inside it fails closed.
|
|
32
|
+
*/
|
|
33
|
+
readonly tenant: JobTenant<I>;
|
|
34
|
+
/** REQUIRED, no default. A model call fails transiently; how many times is nobody else's guess. */
|
|
35
|
+
readonly retry: RetryPolicy;
|
|
36
|
+
readonly queue?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Defaults to the action projection's own key — `action:<name>:<fingerprint of input>` — which
|
|
39
|
+
* is stable across retries and derived from the payload alone.
|
|
40
|
+
*
|
|
41
|
+
* **It dedupes the ENQUEUE, never the ATTEMPT.** Two `enqueue` calls with the same payload are
|
|
42
|
+
* one row; one row that a worker claims, half-runs and loses the lease on is claimed again, and
|
|
43
|
+
* the agent runs a second time from the top. Combined with `backfill()`, whose `handle` is at
|
|
44
|
+
* least once by construction, a replayed page re-runs every agent on it.
|
|
45
|
+
*
|
|
46
|
+
* What that means for `tools`: **every tool the agent may call has to be idempotent** — an
|
|
47
|
+
* `upsertAll`, an `updateWhere`, a statement whose second run changes nothing — because a
|
|
48
|
+
* replayed attempt issues a second `issueRefund` otherwise. The framework does NOT check this and
|
|
49
|
+
* cannot: `mutates` is not a fact an `action()` declares (`@ultimat3/mcp` sets it to `true` for
|
|
50
|
+
* every action it projects), so a read-only `lookupOrder` and a destructive `issueRefund` are
|
|
51
|
+
* indistinguishable here, and a rule refusing both would be a wrong refusal. See the README.
|
|
52
|
+
*/
|
|
53
|
+
idempotencyKey?(input: I): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wrap an `agent()` — or any action — as a real job handle.
|
|
58
|
+
*
|
|
59
|
+
* Both reads of the underlying projection are LAZY, and that is load-bearing: `target.job()` calls
|
|
60
|
+
* `actionName()`, which throws `X_ACTION_UNREGISTERED` until `registerAction` stamps the export
|
|
61
|
+
* name at boot — and `agentJob()` is evaluated at module scope, right beside the `agent()` it
|
|
62
|
+
* wraps. The queue key comes from `options.name` for the same reason it is required.
|
|
63
|
+
*/
|
|
64
|
+
export function agentJob<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
65
|
+
target: Action<TInput, TOutput>,
|
|
66
|
+
options: AgentJobOptions<InferOutput<TInput>>,
|
|
67
|
+
): JobHandle<InferOutput<TInput>> {
|
|
68
|
+
let projected: ActionJobHandle<TInput, TOutput> | undefined;
|
|
69
|
+
const bridge = (): ActionJobHandle<TInput, TOutput> => {
|
|
70
|
+
if (projected === undefined) projected = target.job();
|
|
71
|
+
return projected;
|
|
72
|
+
};
|
|
73
|
+
return job<InferOutput<TInput>>({
|
|
74
|
+
name: options.name,
|
|
75
|
+
input: target.input as StandardSchemaV1<unknown, InferOutput<TInput>>,
|
|
76
|
+
tenant: options.tenant,
|
|
77
|
+
retry: options.retry,
|
|
78
|
+
...(options.queue === undefined ? {} : { queue: options.queue }),
|
|
79
|
+
idempotencyKey: (input) =>
|
|
80
|
+
options.idempotencyKey?.(input) ?? bridge().idempotencyKey(asInput<TInput>(input)),
|
|
81
|
+
// ONE execution path, and it is the action's. `ActionJobHandle.invoke` is `invoke(target,
|
|
82
|
+
// input, { surface: 'job', ctx })`, so the agent's policy, its input parse, its budget scope
|
|
83
|
+
// and its span all apply — and `ctx` is the WORKER's, so `ctx.signal` aborting at the attempt
|
|
84
|
+
// timeout reaches the agent's turn loop.
|
|
85
|
+
run: ({ input, ctx }) => bridge().invoke(asInput<TInput>(input), ctx),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The job parsed with the action's OWN schema, so what it hands back is a value that schema
|
|
91
|
+
* accepts — `invoke` re-parses it regardless, which is what makes this safe rather than merely
|
|
92
|
+
* convenient. The cast exists because `InferOutput` and `InferInput` are different types wherever a
|
|
93
|
+
* field has a default, and nothing at this seam can prove they meet.
|
|
94
|
+
*/
|
|
95
|
+
function asInput<TInput extends StandardSchemaV1>(value: InferOutput<TInput>): InferInput<TInput> {
|
|
96
|
+
return value as InferInput<TInput>;
|
|
97
|
+
}
|