@ultimat3/ai 2.0.0 → 4.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 +205 -4
- package/README.md +149 -4
- 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 +170 -61
- package/src/errors.ts +2 -0
- package/src/fetch-seam.ts +15 -0
- package/src/gateway.ts +25 -11
- 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 +15 -10
- package/src/llm-cache.ts +111 -0
- package/src/llm.ts +14 -66
- package/src/openai-provider.ts +21 -6
- package/src/openai-wire.ts +59 -25
- package/src/prompt.ts +6 -1
- package/src/provider.ts +34 -7
- package/src/rag.ts +66 -6
- package/src/remote-embedder.ts +3 -2
- package/src/runtime.ts +19 -5
- package/src/sse.ts +32 -1
- package/src/tools.ts +95 -8
- package/src/wire.ts +23 -13
package/CLAUDE.md
CHANGED
|
@@ -4,7 +4,12 @@ Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity pol
|
|
|
4
4
|
query jobs realtime`. **Never** `mcp manifest render pwa ui admin testing cli`.
|
|
5
5
|
|
|
6
6
|
Declared today: `action` (the primitive `llm()` returns), `cache` (semantic cache), `core`,
|
|
7
|
-
`db` (pgvector), `money`, `policy`, `schema`, `time`.
|
|
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.
|
|
8
13
|
|
|
9
14
|
`mcp` is the same tier, so the LLM-tool projection is restated structurally in `tools.ts`
|
|
10
15
|
rather than imported. Same contract, two wire formats — and the same *decision*: `toLlmTools` and
|
|
@@ -44,12 +49,20 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
44
49
|
| `pg-vector-sql.ts` | every pgvector statement: DDL, upsert, cosine, FTS, RRF fusion |
|
|
45
50
|
| `pg-vector.ts` | `PgVectorStore` — the production store |
|
|
46
51
|
| `rag.ts` | chunker, retriever, reranker, budgeted context assembler |
|
|
47
|
-
| `tools.ts` | action → LLM tool definition; `runLlmToolCall` |
|
|
52
|
+
| `tools.ts` | action → LLM tool definition; the `AgentTool` union and `asProjectableAction`; `runLlmToolCall` |
|
|
48
53
|
| `llm.ts` | `llm()` — the model call, declared as an `action`; and what a streamed answer must satisfy |
|
|
49
54
|
| `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
|
|
50
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` |
|
|
51
62
|
| `redaction.ts` | the one gate between `vars()` and the provider: a `Secret` never reaches a prompt |
|
|
52
63
|
| `eval-errors.ts` | the five `X_EVAL_*` classes; their codes and titles stay in `errors.ts` |
|
|
64
|
+
| `llm-cache.ts` | the semantic cache half of `llm()`: what a declaration may partition on, and the store it reaches |
|
|
65
|
+
| `llm-fixture.ts` | the harness `llm.test.ts` and `llm-cache.test.ts` share. Not shipped (`!src/**/*-fixture.ts`) |
|
|
53
66
|
| `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
|
|
54
67
|
| `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 |
|
|
55
68
|
|
|
@@ -67,6 +80,21 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
67
80
|
- Semantic scopes are separate cache INSTANCES, never a filter over a shared one — cosine
|
|
68
81
|
similarity has no notion of a tenant. The instance key carries the prompt hash too, which is
|
|
69
82
|
what makes a version bump invalidate the cache.
|
|
83
|
+
- **The default scope is the calling ACTOR, and `scope` receives `{ input, ctx }`** (`As of
|
|
84
|
+
2026-08`). It defaulted to the literal string `'global'` and took the bare `input`, so the rule
|
|
85
|
+
in the bullet above was contradicted by the very default that shipped: `cache: { semantic: { ttl:
|
|
86
|
+
'1h' } }` put every tenant in one store, and `lookup` is a nearest neighbour with no tenant
|
|
87
|
+
predicate — proven by execution, tenant B asking a prompt within 0.92 cosine of tenant A's
|
|
88
|
+
received A's completion verbatim. And with only `input` to decide from, the one thing a partition
|
|
89
|
+
may never be chosen by (a value the caller sends) was the only thing it could be chosen by, while
|
|
90
|
+
`vars()` on the same declaration already took the pair. The default is
|
|
91
|
+
`JSON.stringify([actor.kind, actor.id, actor.orgId ?? null])` — `@ultimat3/query`'s
|
|
92
|
+
`readAuthority` rule, verbatim: a declaration that says nothing gets the NARROWEST key, and
|
|
93
|
+
widening is a written statement about what the answers are (`scope: () => 'global'`). **Breaking**
|
|
94
|
+
in both halves. `semanticCacheFor`'s instance map is bounded as a consequence
|
|
95
|
+
(`MAX_SEMANTIC_CACHE_SCOPES`, which IS core's `MAX_CACHED_FORMATTERS` — one bounded FIFO map in
|
|
96
|
+
the framework, whose name is about its first caller and not its contract): one entry per actor in
|
|
97
|
+
a process that never restarts is a leak where one entry per process was not.
|
|
70
98
|
- A per-call budget `derive`s from the ambient ledger, so it can only TIGHTEN the actor and org
|
|
71
99
|
ceilings it runs inside. Widening them from a declaration would be a budget that is not one.
|
|
72
100
|
**A derived ledger reports back up the chain**: every debit and every recorded cost lands on it
|
|
@@ -92,6 +120,12 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
92
120
|
zero leaks the ceiling upward on every release.
|
|
93
121
|
- Cost is `Money` (integer minor units), rounded **up**. Never a float, never a division
|
|
94
122
|
that loses a fraction.
|
|
123
|
+
- **The gateway's two reads of a provider's throw are total.** A `Provider` is the APP's object, so
|
|
124
|
+
the value it rejects with is one the framework did not build: `isRetryable` indexes it (a getter,
|
|
125
|
+
or a `Proxy` trap) and fails closed if the read raises, and the failure line goes through core's
|
|
126
|
+
`renderThrowable` rather than `error.message` / `String(error)` — a renderer that throws replaces
|
|
127
|
+
`X_AI_PROVIDER_UNAVAILABLE` with a bare `TypeError` nothing catches by code, and it bounds a
|
|
128
|
+
provider's 1MB body out of the `cause`.
|
|
95
129
|
- Every non-2xx and every in-band `error` frame becomes `AiTransportError`, which carries a real
|
|
96
130
|
`status` field — that field IS the gateway's retry rule. A body parsed as a message would read
|
|
97
131
|
as an empty, successful answer, which is the one outcome nothing downstream can detect.
|
|
@@ -118,6 +152,15 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
118
152
|
*as* differences rather than flattening them.
|
|
119
153
|
- A stream that ends without `message_stop` throws. A truncated answer that returns `end_turn`
|
|
120
154
|
is a confidently wrong answer with no signal, which the budget rule already forbids.
|
|
155
|
+
- **`readSse` caps the unterminated buffer** (`MAX_FRAME_CHARS`, `As of 2026-08`) and refuses with
|
|
156
|
+
`AiTransportError`. A peer that never sends a frame boundary — an HTML error page, a proxy on the
|
|
157
|
+
model's port — grew it without limit and no read deadline interrupted it, because every read
|
|
158
|
+
SUCCEEDED. Same call `@ultimat3/mail`'s `createReplyParser` makes: coded failure > OOM. `provider`
|
|
159
|
+
is a required argument for that reason — a transport error names the endpoint it is about.
|
|
160
|
+
- **`llm()` forwards `ctx.signal` onto `GenerateRequest`**, `As of 2026-08`, the way `agent()`
|
|
161
|
+
always did. Without it a model call had no cancellation and no deadline: a caller that hung up
|
|
162
|
+
left the provider call in flight, billed and unread, and the repair turn bought a second one.
|
|
163
|
+
`.stream()` inherits it from the same `base`, which is where it matters most.
|
|
121
164
|
- A tool call is emitted whole. `input_json_delta` fragments are not arguments until the block
|
|
122
165
|
closes, so nothing partial reaches a caller.
|
|
123
166
|
- Thinking chunks are never appended to `text`. A consumer concatenating every chunk must not
|
|
@@ -200,7 +243,13 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
200
243
|
per-block stop event in this format.
|
|
201
244
|
- `isComplete()` accepts `[DONE]` **or** a finish reason: plenty of servers in the family close
|
|
202
245
|
the socket straight after the finish chunk, and a finish reason is the model saying why it
|
|
203
|
-
stopped, which a cut connection cannot produce.
|
|
246
|
+
stopped, which a cut connection cannot produce. **With one exception, `As of 2026-08`:
|
|
247
|
+
`[DONE]` while tool-call fragments are still pending is NOT complete.** `onFinish` is the only
|
|
248
|
+
drain of `pending` and the finish reason is the only close this format has, so the sentinel
|
|
249
|
+
alone cannot tell "the model finished asking" from "the connection died mid-arguments" —
|
|
250
|
+
reporting complete discarded a whole tool call and answered an empty, successful `end_turn`.
|
|
251
|
+
Refused rather than flushed, exactly as the Anthropic half refuses a missing `message_stop`:
|
|
252
|
+
emitting the fragments would run a tool's side effects from half a JSON object.
|
|
204
253
|
- `role: 'system'`, not `developer` — the newer role is OpenAI's alone and every other server in
|
|
205
254
|
the family knows only `system`.
|
|
206
255
|
- **Only three models are priced** (`gpt-5.6-sol` / `-terra` / `-luna`, list price read
|
|
@@ -261,15 +310,156 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
261
310
|
(`X_AGENT_MAX_TURNS`, never a partial answer), by `budget.tokensPerRun` (the ledger's `request`
|
|
262
311
|
scope, which accumulates across turns) and by `maxToolResultChars` — the transcript IS the
|
|
263
312
|
request, so an untruncated tool result is re-billed once per remaining turn.
|
|
313
|
+
- **`tools` takes the real `action()`, adapted at this package's edge — decided 2026-08 (issue
|
|
314
|
+
#124).** It took `ProjectableAction` alone, which no `action()` structurally satisfies (an
|
|
315
|
+
action is `as`/`tool`/`openapi`/`job`/`contract` and the callable, never `run`), so the shape
|
|
316
|
+
the README documents was a `TS2741` and every test here hand-built a stand-in — which is why
|
|
317
|
+
the suite was green over an API that did not compile. `AgentTool = AnyAction |
|
|
318
|
+
ProjectableAction` and `asProjectableAction` are the fix, the same union `@ultimat3/mcp`'s
|
|
319
|
+
`ListedPrimitive` already accepted. **Not** a `run` member on the action facade, which was the
|
|
320
|
+
obvious alternative and is wrong three times over: it duplicates `.as()` (axiom 1), it would
|
|
321
|
+
offer a tool named `''` for an action `registerActions` has not named yet, and it would NOT
|
|
322
|
+
collapse `mcp`'s adapter, whose `toWireSchema` narrows to the subset that server's arg
|
|
323
|
+
validator can enforce while this one publishes the Messages API's (`packages/mcp/src/
|
|
324
|
+
from-action.ts` header). Two wire formats, two projections, one `invoke`.
|
|
325
|
+
- Projection happens on the FIRST RUN, memoised — never at declaration. `agent()` is evaluated at
|
|
326
|
+
module scope and `registerAction` stamps a name at boot, so `actionName()` at declaration would
|
|
327
|
+
make the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
328
|
+
- **An `agent()` is a tool of another `agent()`** — it returns an action, and an action is what a
|
|
329
|
+
tool is. That is the supervisor/sub-agent shape, with no `hive()` and no ninth primitive; the
|
|
330
|
+
sub-agent runs under the same actor, through its own policy.
|
|
264
331
|
- A tool listed in `agent({ tools })` that is not `mcp: { expose: true }` is
|
|
265
332
|
`X_AGENT_TOOL_UNEXPOSED` **at declaration**, not filtered at the call: a silently dropped tool
|
|
266
333
|
reads as offered and is not. `isMcpExposed` is the one predicate, so an in-app agent and an
|
|
267
|
-
external MCP client see the same catalogue.
|
|
334
|
+
external MCP client see the same catalogue. Asked of the DECLARATION, not of the projection,
|
|
335
|
+
because exposure needs no name and the name does not exist yet.
|
|
336
|
+
- **`ctx.signal` is read, at three points.** `throwIfAborted` at the top of every turn and before
|
|
337
|
+
every tool batch, and `GenerateRequest.signal` forwarded into `fetch` by both providers. The
|
|
338
|
+
transcript IS the request, so a loop that keeps going after the caller disconnects re-sends it
|
|
339
|
+
once per remaining turn, runs every remaining tool's side effect and discards the answer —
|
|
340
|
+
eight provider calls for an answer nothing reads. `X_ABORTED` is core's, already shipped; no
|
|
341
|
+
new code. `signal` is deliberately absent from `cacheKeyFor` and from every estimate: it says
|
|
342
|
+
whether a call was ABANDONED, never what it asked for.
|
|
343
|
+
- **The tools of ONE turn run concurrently**, unbounded within the turn, results paired by index
|
|
344
|
+
so a fast tool cannot be matched to a slow tool's `tool_use` id. Serial cost 5x wall clock for a
|
|
345
|
+
turn that asked for five tools and nothing in the types or the docs said so. No second ceiling
|
|
346
|
+
here: the batch is what one model turn asked for, each entry is an action with its own `policy`
|
|
347
|
+
and `rateLimit`, and a tool that calls a model still queues on the ledger's root turnstile.
|
|
348
|
+
Guarantee is "no tool STARTS after the abort" — a tool already in flight unwinds through its
|
|
349
|
+
own handler's reading of `ctx.signal`, which is the action's to make.
|
|
350
|
+
- **`onTurn` is the per-turn observation, and `.stream()` on `agent()` is deliberately not shipped
|
|
351
|
+
yet.** A 90-second multi-turn run emitted nothing until it returned. `onTurn` reports facts
|
|
352
|
+
only — turn, model, tool names, stop reason, usage, that turn's cost — never the transcript and
|
|
353
|
+
never the actor, and the same facts land on the span as an `agent.turn` event so a run
|
|
354
|
+
declaring no hook is still readable in a trace. A throw from it FAILS the run: an observer that
|
|
355
|
+
quietly stopped working reads exactly like one that is fine. Tokens on a screen is a different
|
|
356
|
+
contract from turns in a loop, and half-shipping it would be the second path axiom 1 refuses.
|
|
268
357
|
- **No semantic cache on `agent()`.** Similar prompts do not have similar answers once the answer
|
|
269
358
|
depends on what `lookupOrder` returned this second.
|
|
359
|
+
- **Every `tool_use` block the transcript replays is answered by a `tool_result` in the very next
|
|
360
|
+
message** — the Messages API's own rule, and `agent-transcript.ts` owns both halves of it for
|
|
361
|
+
that reason. Two paths broke it, both through `respond`, which is filtered out of the calls
|
|
362
|
+
that RUN and replayed like any other block: a turn emitting a tool call AND `respond` together
|
|
363
|
+
(ordinary parallel tool use), and a `respond` whose input failed the output schema, followed by
|
|
364
|
+
a plain user message. Both were a 400 (`tool_use ids were found without tool_result blocks`),
|
|
365
|
+
i.e. `X_AI_PROVIDER_UNAVAILABLE` in place of a completed run, and `agent.test.ts` never mixed
|
|
366
|
+
the two so neither shipped visible. An unaccepted `respond` now comes back as its own
|
|
367
|
+
`tool_result`, `is_error`, saying why — the answer is SUPERSEDED, not wrong: it was written
|
|
368
|
+
before the results of the tools the same turn asked for existed, so the loop continues and the
|
|
369
|
+
model answers again with them in hand. Discarding the block instead would have been the other
|
|
370
|
+
legal fix and loses the record; USING the speculative answer would skip the tool results the
|
|
371
|
+
model itself asked for, after those tools already ran.
|
|
372
|
+
- **A tool result is rendered totally.** `runLlmToolCall` returns `content: string` and the loop
|
|
373
|
+
TRUNCATES it, so `JSON.stringify`'s other two answers both have to be handled: `undefined` for
|
|
374
|
+
an action that returns nothing (`'null'`), and a throw on a bigint, a cycle or a `toJSON` of
|
|
375
|
+
the value's own — reported as "the tool ran and its result is not JSON", never as a failure,
|
|
376
|
+
because a model told the tool failed calls it again and buys its side effects twice. The throw
|
|
377
|
+
it catches is read with `stringField`, never `typeof error.code === 'string'`: the value is an
|
|
378
|
+
app's, so the probe is a getter call or a `Proxy` trap inside the catch block.
|
|
270
379
|
- `AiMessage.content` widened to `string | readonly AiContentBlock[]` for this: a `tool_result`
|
|
271
380
|
has to name the `tool_use` it answers and a string has nowhere to put the id. The block field
|
|
272
381
|
names are the Messages API's, so `body()` passes them through untouched.
|
|
382
|
+
- **`hive()` is a fan-out, and the FOURTH instance of the factory rule** (after `llm()`,
|
|
383
|
+
`backfill()` and `agent()`) — it returns an `action`, never a ninth primitive. It exists because
|
|
384
|
+
the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that loop gets four
|
|
385
|
+
things wrong every time: the actor, the order, the difference between ran-and-failed and
|
|
386
|
+
never-ran, and the ceiling.
|
|
387
|
+
- **`HiveResult` is a SCHEMA, not an interface**, built from the member action's own `output` and
|
|
388
|
+
embedded in the `ok` arm. That is what makes a hive project to OpenAPI, the typed client, the
|
|
389
|
+
MCP `outputSchema` and the manifest like any other action; a hand-written interface would have
|
|
390
|
+
given the type and none of the six projections, which is the whole reason it is a factory.
|
|
391
|
+
- **Three arms — `ok` / `failed` / `skipped` — never two.** A member that ran and threw and a
|
|
392
|
+
member that never ran are different facts, and an aborted sibling is the second. Two arms make
|
|
393
|
+
"the hive stopped early" indistinguishable from "every remaining item is bad data", which is
|
|
394
|
+
the difference between retrying the tail and fixing the source. `skipped` gets its own counter
|
|
395
|
+
beside `ok` and `failed` for the same reason: three arms and two counters means every caller
|
|
396
|
+
writes `members.length - ok - failed` once, and writes it wrong once.
|
|
397
|
+
- **`members` is in SPLIT order, always**, filled by index rather than pushed on settle, with
|
|
398
|
+
`index` on every arm so a caller can join a result back to its row without depending on array
|
|
399
|
+
position surviving a filter.
|
|
400
|
+
- **The hive never names an actor.** `split` derives member inputs from `input` and `ctx` and
|
|
401
|
+
from nothing a model emitted; each member runs through its own callable, so `invoke` applies
|
|
402
|
+
the member's own `policy` with `ctx.actor` untouched. There is no `as(actor)` in the factory —
|
|
403
|
+
the same boundary `agent()` holds, and the reason both belong in the framework.
|
|
404
|
+
- **No hive-specific budget code, deliberately.** One derived ledger for the run, `withBudget`
|
|
405
|
+
around the pool, each member's `agent()` deriving again — and the ceiling holds under
|
|
406
|
+
parallelism because `reserve` DEBITS on the root's turnstile before the call. Three members
|
|
407
|
+
against a ceiling only one fits leave exactly one `ok`; that is asserted through the hive
|
|
408
|
+
rather than asserted about the ledger, because the ledger already promised it.
|
|
409
|
+
- **A member's throw is RECORDED, whatever it is.** `failureOf` reads it with `isThrownError` and
|
|
410
|
+
`stringField` from core, never `error instanceof Error` and `.message`: a member is an app's
|
|
411
|
+
action, so a `Proxy` makes `instanceof` run a `getPrototypeOf` trap, and a throw there takes
|
|
412
|
+
down the whole hive — the one outcome the three arms exist to prevent. `skipped` has two
|
|
413
|
+
reasons, because they are two facts: `SKIPPED_ABORTED` (a sibling failed under `'abort'`) and
|
|
414
|
+
`SKIPPED_NO_INPUT` (the split produced nothing at that index). One string for both sent a
|
|
415
|
+
caller to retry a tail that was never cut.
|
|
416
|
+
- **`onMemberError` is required.** `'abort'` stops and leaves the rest `skipped`; `'collect'`
|
|
417
|
+
harvests. Both are right for somebody, so neither may be inherited silently.
|
|
418
|
+
- `concurrency` defaults to 4 and `minMembers` to 2, and neither number is measured off any run —
|
|
419
|
+
the framework cannot know a provider's concurrency allowance. A below-floor split still runs
|
|
420
|
+
every input it produced, serially: dropping one would be silent data loss.
|
|
421
|
+
- An empty split is `X_HIVE_EMPTY`, never a successful run of zero members — "0 ok, 0 failed"
|
|
422
|
+
cannot be told apart from a query that returned no rows and nobody noticed.
|
|
423
|
+
- An aborted `ctx` unwinds the whole hive with `X_ABORTED`, which is a DIFFERENT event from
|
|
424
|
+
`onMemberError: 'abort'`: the latter is a completed run with a partial harvest worth returning,
|
|
425
|
+
the former has nobody left to hand it to.
|
|
426
|
+
- **`describeAgents()` publishes what an `ActionDescriptor` cannot.** An agent projects to the same
|
|
427
|
+
descriptor as any other action, and that descriptor knows nothing about turns, tools, models or
|
|
428
|
+
prompt hashes — so "how far can this loop and what may it call" had no answer outside the source.
|
|
429
|
+
Same shape as `describePrompts()` / `describeEvals()`, and deliberately NOT a new
|
|
430
|
+
`ActionDescriptor` field: `@ultimat3/action` is tier 3 and knows nothing about models.
|
|
431
|
+
The facts are a THUNK, resolved when asked: `agent()` runs at module scope beside the actions it
|
|
432
|
+
lists, and every name in a row is stamped by `registerAction` at boot. An agent still carrying no
|
|
433
|
+
name has no row — not a silent drop, but the absence of a capability: an action with no name
|
|
434
|
+
reaches no route, no tool catalogue and no queue. `named()` builds a TWIN where registration
|
|
435
|
+
names in place, so an agent renamed that way is absent for the same reason; register it instead.
|
|
436
|
+
- **`agentJob()` closes #125 for the agent case, by COMPOSING `job()`** — the returned value is one
|
|
437
|
+
`job()` seated in that package's own registry, so `.enqueue()`, the outbox, the worker's
|
|
438
|
+
cancellation, the dead-letter path, `x jobs show` and its manifest row arrive without a line here.
|
|
439
|
+
Never an imitation handle: `isJobHandle` needs `kind === 'job'` plus membership of a WeakMap only
|
|
440
|
+
`job()` writes, which is exactly what stops a second execution path existing.
|
|
441
|
+
- `name`, `tenant` and `retry` are REQUIRED, no defaults. `name` because a job name is the durable
|
|
442
|
+
queue key that queued, retrying and dead-lettered rows already carry — deriving it from the
|
|
443
|
+
export name would move delivery when somebody renames a variable. `tenant` and `retry` because
|
|
444
|
+
`jobs` states that every candidate default for `tenant` is a cross-tenant read waiting for the
|
|
445
|
+
first job that takes an org id in its input. Both are `TS2741` when omitted AND have runtime
|
|
446
|
+
backstops (`X_JOB_TENANT_REQUIRED`, the `retry.attempts` assert), for generated and JS callers.
|
|
447
|
+
- **Both reads of `target.job()` are LAZY**, and that is load-bearing: `actionName()` throws
|
|
448
|
+
`X_ACTION_UNREGISTERED` until boot stamps the export name, and `agentJob()` is evaluated at
|
|
449
|
+
module scope right beside the `agent()` it wraps. Same rule as `agent()`'s tool projection and
|
|
450
|
+
`describeAgents()`' thunk — third instance in this package.
|
|
451
|
+
- **The at-least-once trap is DOCUMENTED, not enforced, and that is a decision with evidence.**
|
|
452
|
+
`idempotencyKey` dedupes the ENQUEUE, never the ATTEMPT: a lost lease re-runs the agent from the
|
|
453
|
+
top, as does every page `backfill()` replays. So every tool an `agentJob()`'d agent may call has
|
|
454
|
+
to be idempotent. The framework cannot check it: `mutates` is not a fact an `action()` declares
|
|
455
|
+
— it exists only in `@ultimat3/mcp`, whose `projectable.ts` sets it to `true` for EVERY action —
|
|
456
|
+
so a read-only `lookupOrder` and a destructive `issueRefund` are indistinguishable, and
|
|
457
|
+
`ActionFacade`'s `Pick<>` carries no `idempotent` either (only `describe()` does, and that needs
|
|
458
|
+
a name). `isMutator` IS legible without a name, but `mutator()` is the local-first write
|
|
459
|
+
primitive: refusing only those would catch almost none of the risk while reading as if it caught
|
|
460
|
+
all of it. A wrong refusal is worse than a stated obligation, so the obligation is stated — in
|
|
461
|
+
`AgentJobOptions.idempotencyKey`'s doc comment and in the README, both naming the second refund.
|
|
462
|
+
|
|
273
463
|
- **`configureAi({ redact })` is the one seam between `vars()` and the provider.** `vars()` is the
|
|
274
464
|
one declared place a model call loads data, so it is the one place a redactor can see the row
|
|
275
465
|
before it leaves the process; the redactor sees the whole RENDERED prompt and the system prompt,
|
|
@@ -323,6 +513,17 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
323
513
|
recorded — one no test asserts, one whose `baseline:` is a cwd-relative string — would otherwise
|
|
324
514
|
satisfy `X_EVAL_MISSING` while gating on nothing.
|
|
325
515
|
- Retrieval is hybrid by default. Do not add a vector-only convenience path.
|
|
516
|
+
- **`chunk()` performs all three splits its header names — paragraph, sentence, HARD WRAP** (`As of
|
|
517
|
+
2026-08`). The wrap is what bounds a unit, and an oversized unit is one the size check can never
|
|
518
|
+
flush (it only fires when something is already in the buffer), so it re-seeded every following
|
|
519
|
+
chunk: a ~1,000-token document indexed as nine chunks totalling ~9,000, each carrying the same
|
|
520
|
+
sentence. The overlap carry stops at `buffer.length - 1` for the same reason — a flushed unit may
|
|
521
|
+
never become the whole of the next buffer.
|
|
522
|
+
- **A caller's string is never used as an object KEY.** A `Record` lookup on one answers
|
|
523
|
+
`constructor`, `toString` and `valueOf` off the prototype chain, and every read here is of a
|
|
524
|
+
string a provider, a template author or a test fixture chose: the two wire tables are `Map`s,
|
|
525
|
+
`prompt.render` uses `Object.hasOwn` (a `{{constructor}}` slot rendered JS source into a billed
|
|
526
|
+
prompt and hashed it into the cache key), and so does `EchoProvider`'s `replies`.
|
|
326
527
|
- `PgVectorStore` is the ONLY production vector path — pgvector and Postgres FTS in the app's own
|
|
327
528
|
Postgres, never a second datastore. `MemoryVectorStore` is the dev twin and enforces the same
|
|
328
529
|
envelope; a leak that only reproduces against real Postgres is a leak nobody finds.
|
package/README.md
CHANGED
|
@@ -108,6 +108,8 @@ for await (const chunk of ai.stream({ messages, maxTokens: 64_000 })) {
|
|
|
108
108
|
| A `tool-call` chunk arrives whole | `input_json_delta` fragments are not arguments until the block closes |
|
|
109
109
|
| `thinking` chunks never join `text` | concatenating every chunk must not ship the reasoning to the user |
|
|
110
110
|
| A stream cut before `message_stop` **throws** | a truncated answer reporting `end_turn` is wrong with no signal |
|
|
111
|
+
| `[DONE]` with a tool call still open **throws** | the OpenAI format has no per-call stop event, so the finish reason is the only close there is; the sentinel alone cannot tell "finished asking" from "cut mid-arguments" |
|
|
112
|
+
| A body with no frame boundary in it **throws** | an SSE peer that never completes a frame is an unbounded allocation no read deadline interrupts |
|
|
111
113
|
| An in-band `error` frame carries a status | `overloaded_error` mid-stream retries like a 529 on the handshake |
|
|
112
114
|
|
|
113
115
|
## Embeddings
|
|
@@ -249,7 +251,7 @@ export const summarize = llm({
|
|
|
249
251
|
output: t.object({ summary: t.string, tags: t.array(t.string) }),
|
|
250
252
|
prompt: summarizePrompt, // versioned artifact
|
|
251
253
|
vars: async ({ input, ctx }) => ({ body: await ctx.posts.body(input.postId) }),
|
|
252
|
-
cache: { semantic: { threshold: 0.97, ttl: '7d', scope
|
|
254
|
+
cache: { semantic: { threshold: 0.97, ttl: '7d' } }, // scope defaults to the ACTOR
|
|
253
255
|
budget: { tokensIn: 8_000, costPerCall: { minor: 5, currency: 'USD' } },
|
|
254
256
|
policy: can('post:read'),
|
|
255
257
|
});
|
|
@@ -265,7 +267,7 @@ summarize.contract(); // the contract tests
|
|
|
265
267
|
| `output` | projected into the one tool the model may answer through; prose with a fenced JSON block still parses |
|
|
266
268
|
| a schema failure | **one** repair turn naming the issues, then `X_LLM_OUTPUT_INVALID` |
|
|
267
269
|
| `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
|
|
268
|
-
| `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation |
|
|
270
|
+
| `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation. `scope` receives `{ input, ctx }` and **defaults to the calling actor** — the narrowest key, `@ultimat3/query`'s `readAuthority` rule; a shared store is `scope: () => 'global'`, written down |
|
|
269
271
|
| `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
|
|
270
272
|
| `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
273
|
|
|
@@ -300,23 +302,161 @@ export const support = agent({
|
|
|
300
302
|
output: t.object({ answer: t.string }),
|
|
301
303
|
prompt: supportPrompt,
|
|
302
304
|
vars: ({ input }) => ({ orderId: input.orderId }),
|
|
303
|
-
tools: [lookupOrder, issueRefund], // actions, each mcp.expose
|
|
305
|
+
tools: [lookupOrder, issueRefund], // real actions, each mcp.expose
|
|
304
306
|
maxTurns: 6,
|
|
305
307
|
maxToolResultChars: 4_000,
|
|
306
308
|
budget: { tokensPerRun: 200_000, costPerCall: { minor: 50, currency: 'USD' } },
|
|
307
309
|
policy: can('order:support'),
|
|
310
|
+
onTurn: ({ turn, toolCalls, cost }) => progress.push({ turn, toolCalls, cost }),
|
|
308
311
|
});
|
|
309
312
|
```
|
|
310
313
|
|
|
314
|
+
`tools` takes the `action()` an app already wrote — `[lookupOrder, issueRefund]`, the imports
|
|
315
|
+
themselves. `As of 2026-08`: it took a hand-shaped `ProjectableAction` until then, so the line
|
|
316
|
+
above was a `TS2741` against every real action (issue #124) and the only thing that satisfied it
|
|
317
|
+
was a stand-in written for a test.
|
|
318
|
+
|
|
319
|
+
An `agent()` returns an action, so **an agent is a tool of another agent** — a supervisor lists a
|
|
320
|
+
sub-agent in its own `tools` and the sub-agent runs under the same actor, through the same policy.
|
|
321
|
+
No `hive()`, no supervisor primitive: it falls out of the factory rule.
|
|
322
|
+
|
|
311
323
|
| Rule | Why |
|
|
312
324
|
|---|---|
|
|
313
325
|
| 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 |
|
|
326
|
+
| 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 |
|
|
327
|
+
| 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 |
|
|
328
|
+
| `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 |
|
|
314
329
|
| a tool that is not `mcp: { expose: true }` is `X_AGENT_TOOL_UNEXPOSED` **at declaration** | a silently dropped tool reads as offered and is not; `isMcpExposed` is the one predicate, so an in-app agent and an external MCP client see the same catalogue |
|
|
315
330
|
| running out of turns is `X_AGENT_MAX_TURNS`, never a partial answer | a half-finished transcript returned as a result is working notes presented as a decision |
|
|
316
331
|
| `budget.tokensPerRun` caps the **whole run** | a single call is bounded by `maxTokens`; a loop is bounded by nothing until this is set |
|
|
317
332
|
| a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
|
|
318
333
|
| **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
|
|
319
334
|
|
|
335
|
+
## `hive()` — many members, one action
|
|
336
|
+
|
|
337
|
+
Fan an action out over many inputs. The fourth factory over a primitive, after `llm()`,
|
|
338
|
+
`backfill()` and `agent()`: a fan-out is still one server-authoritative operation with an input
|
|
339
|
+
schema, an output schema and a policy.
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
import { action, t } from '@ultimat3/action';
|
|
343
|
+
import { hive } from '@ultimat3/ai';
|
|
344
|
+
import { allow } from '@ultimat3/policy';
|
|
345
|
+
|
|
346
|
+
const summarisePost = action({
|
|
347
|
+
input: t.object({ postId: t.uuid }),
|
|
348
|
+
output: t.object({ summary: t.string }),
|
|
349
|
+
policy: allow(),
|
|
350
|
+
mcp: { expose: true },
|
|
351
|
+
handle: ({ input }) => ({ summary: input.postId }),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
export const summariseBacklog = hive({
|
|
355
|
+
input: t.object({ postIds: t.array(t.uuid) }),
|
|
356
|
+
member: summarisePost,
|
|
357
|
+
split: ({ input }) => input.postIds.map((postId) => ({ postId })),
|
|
358
|
+
concurrency: 8,
|
|
359
|
+
minMembers: 2,
|
|
360
|
+
onMemberError: 'collect',
|
|
361
|
+
budget: { tokensPerRun: 500_000 },
|
|
362
|
+
policy: allow(),
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`member` is any action — most usefully an `agent()`, which makes a hive a **supervisor over
|
|
367
|
+
sub-agents** with no supervisor primitive anywhere.
|
|
368
|
+
|
|
369
|
+
| Rule | Why |
|
|
370
|
+
|---|---|
|
|
371
|
+
| `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 |
|
|
372
|
+
| 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" |
|
|
373
|
+
| `onMemberError` is **required** | `'abort'` stops and leaves the rest `skipped`; `'collect'` harvests the rest. Both are right for somebody, so neither is a default |
|
|
374
|
+
| 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 |
|
|
375
|
+
| `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 |
|
|
376
|
+
| 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 |
|
|
377
|
+
| `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 |
|
|
378
|
+
| 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 |
|
|
379
|
+
|
|
380
|
+
## `agentJob()` — an agent as durable background work
|
|
381
|
+
|
|
382
|
+
Run an agent over a million rows as resumable, retried, budgeted queue work. `As of 2026-08` this
|
|
383
|
+
is the only way an agent reaches a queue at all: `.job()` hands back `kind: 'action-job'`, and
|
|
384
|
+
`isJobHandle` needs `kind === 'job'` plus membership of a `WeakMap` only `job()` writes, so nothing
|
|
385
|
+
externally shaped has ever reached the registry, the worker or the dead-letter path (issue #125).
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { t } from '@ultimat3/action';
|
|
389
|
+
import { agent, agentJob, definePrompt } from '@ultimat3/ai';
|
|
390
|
+
import { allow } from '@ultimat3/policy';
|
|
391
|
+
|
|
392
|
+
const summarisePost = agent({
|
|
393
|
+
input: t.object({ postId: t.uuid, orgId: t.uuid }),
|
|
394
|
+
output: t.object({ summary: t.string }),
|
|
395
|
+
prompt: definePrompt<{ postId: string }>({
|
|
396
|
+
id: 'summarise-post',
|
|
397
|
+
version: '1.0.0',
|
|
398
|
+
template: 'Summarise post {{postId}}.',
|
|
399
|
+
}),
|
|
400
|
+
vars: ({ input }) => ({ postId: input.postId }),
|
|
401
|
+
tools: [],
|
|
402
|
+
policy: allow(),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
export const summariseBacklog = agentJob(summarisePost, {
|
|
406
|
+
name: 'summarise-backlog',
|
|
407
|
+
tenant: (input) => input.orgId,
|
|
408
|
+
retry: { attempts: 3, backoff: 'exponential' },
|
|
409
|
+
});
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
It composes `job()` rather than imitating a handle, so `.enqueue()`, the outbox, the worker's
|
|
413
|
+
cancellation, `x jobs show` and its manifest row all arrive for free. Pair it with `backfill()` for
|
|
414
|
+
the sweep and `hive()` for the fan-out inside one page.
|
|
415
|
+
|
|
416
|
+
| Rule | Why |
|
|
417
|
+
|---|---|
|
|
418
|
+
| `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 |
|
|
419
|
+
| `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 |
|
|
420
|
+
| 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` |
|
|
421
|
+
| 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 |
|
|
422
|
+
| 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 |
|
|
423
|
+
|
|
424
|
+
### The at-least-once trap, said plainly
|
|
425
|
+
|
|
426
|
+
**`idempotencyKey` dedupes the ENQUEUE, never the ATTEMPT.** Two enqueues with the same payload are
|
|
427
|
+
one row. One row that a worker claims, half-runs and loses the lease on is claimed again, and **the
|
|
428
|
+
agent runs a second time from the top** — as does every page a `backfill()` replays, since its
|
|
429
|
+
`handle` is at-least-once by construction.
|
|
430
|
+
|
|
431
|
+
So every tool the agent may call has to be idempotent: an `upsertAll`, an `updateWhere`, a statement
|
|
432
|
+
whose second run changes nothing. Otherwise a replayed attempt issues a second refund.
|
|
433
|
+
|
|
434
|
+
**The framework does not check this, and the reason is worth knowing.** `mutates` is not a fact an
|
|
435
|
+
`action()` declares — it exists only in `@ultimat3/mcp`, which sets it to `true` for *every* action
|
|
436
|
+
it projects — so a read-only `lookupOrder` and a destructive `issueRefund` are indistinguishable
|
|
437
|
+
here. A rule refusing every tool that has not declared `idempotent: true` would refuse the reads
|
|
438
|
+
too, and a wrong refusal is worse than a stated obligation. `isMutator` is legible, but `mutator()`
|
|
439
|
+
is the local-first write primitive and catches almost none of the risk while reading as if it
|
|
440
|
+
caught all of it. This is a contract you keep, not one the compiler keeps for you.
|
|
441
|
+
|
|
442
|
+
## `describeAgents()` — what the manifest can say
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
import { describeAgents } from '@ultimat3/ai';
|
|
446
|
+
|
|
447
|
+
describeAgents();
|
|
448
|
+
// [{ name: 'supportAgent', prompt: 'support@1.0.0', promptHash: '…', model: 'claude-opus-5',
|
|
449
|
+
// maxTurns: 6, maxToolResultChars: 4000, tools: ['issueRefund', 'lookupOrder'],
|
|
450
|
+
// budget: { tokensIn: null, tokensPerRun: 200000, costPerCall: { minor: 50, currency: 'USD' } },
|
|
451
|
+
// mcp: true }]
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
An agent projects to an `ActionDescriptor` like any other action, and that descriptor knows nothing
|
|
455
|
+
about turns or tools — so "how far can this loop, and what may it call" had no answer outside the
|
|
456
|
+
source. Names are read when you ask, not when the agent was declared: `registerAction` stamps them
|
|
457
|
+
at boot, long after `agent()` ran at module scope. An agent nothing registered has no row, because
|
|
458
|
+
an action with no name reaches no route, no tool catalogue and no queue.
|
|
459
|
+
|
|
320
460
|
## Redaction: one declared seam
|
|
321
461
|
|
|
322
462
|
`vars()` is the one place a model call loads data, so it is the one place anything can sit between
|
|
@@ -462,7 +602,12 @@ nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant
|
|
|
462
602
|
`X_VECTOR_SCOPE_WIDENED`, never a silent widening.
|
|
463
603
|
|
|
464
604
|
`chunk()` is token-aware with overlap and splits at paragraph, then sentence, then hard wrap
|
|
465
|
-
— a fact split across a boundary with no overlap is retrievable by neither chunk.
|
|
605
|
+
— a fact split across a boundary with no overlap is retrievable by neither chunk. All three
|
|
606
|
+
splits are load-bearing: the wrap is what bounds a UNIT (a base64 blob, a minified line, a CJK
|
|
607
|
+
paragraph the sentence alphabet cannot see), and a unit larger than `size` is one the size check
|
|
608
|
+
can never flush, so it rode every chunk after it — `As of 2026-08`, a ~1,000-token document
|
|
609
|
+
indexed as nine chunks of the same sentence. The overlap carries a tail forward and never the
|
|
610
|
+
whole buffer, for the same reason.
|
|
466
611
|
|
|
467
612
|
## Tools: the same projection as MCP
|
|
468
613
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,13 +32,14 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "
|
|
35
|
-
"@ultimat3/cache": "
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
35
|
+
"@ultimat3/action": "4.0.0",
|
|
36
|
+
"@ultimat3/cache": "4.0.0",
|
|
37
|
+
"@ultimat3/core": "4.0.0",
|
|
38
|
+
"@ultimat3/db": "4.0.0",
|
|
39
|
+
"@ultimat3/jobs": "4.0.0",
|
|
40
|
+
"@ultimat3/money": "4.0.0",
|
|
41
|
+
"@ultimat3/policy": "4.0.0",
|
|
42
|
+
"@ultimat3/schema": "4.0.0",
|
|
43
|
+
"@ultimat3/time": "4.0.0"
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -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
|
+
}
|