@ultimat3/ai 30.8.0 → 31.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 +20 -11
- package/README.md +47 -22
- package/package.json +13 -12
- package/src/adapter.ts +21 -0
- package/src/agent-compact.ts +5 -2
- package/src/agent-continue.ts +4 -69
- package/src/agent-def.ts +6 -1
- package/src/agent-session.ts +66 -0
- package/src/agent-system.ts +1 -1
- package/src/agent-tools.ts +123 -0
- package/src/agent-turn.ts +14 -4
- package/src/agent.ts +43 -88
- package/src/budget-store.ts +82 -0
- package/src/budget.ts +7 -78
- package/src/embeddings.ts +1 -1
- package/src/errors.ts +5 -5
- package/src/eval-agent.ts +1 -1
- package/src/gateway-providers.ts +22 -10
- package/src/gateway.ts +20 -21
- package/src/index.ts +6 -71
- package/src/llm-answer.ts +125 -0
- package/src/llm-cache.ts +16 -2
- package/src/llm-def.ts +1 -1
- package/src/llm-instrument.ts +100 -0
- package/src/llm.ts +152 -241
- package/src/openai-provider.ts +2 -1
- package/src/openai-wire-stream.ts +243 -0
- package/src/openai-wire.ts +9 -236
- package/src/pg-vector-sql.ts +30 -2
- package/src/provider.ts +1 -1
- package/src/runtime.ts +83 -38
- package/src/sampling.ts +4 -4
- package/src/scorers.ts +1 -1
- package/src/tools.ts +12 -8
- package/src/vector.ts +3 -1
package/CLAUDE.md
CHANGED
|
@@ -32,10 +32,10 @@ Keep `policy` dev-only: tests and `*-fixture.ts` harnesses import `allow`. The
|
|
|
32
32
|
| `openai-provider.ts` | `openAiProvider()` — the socket, the credential and the errors for any endpoint speaking the OpenAI chat-completions FORMAT |
|
|
33
33
|
| `openai-messages.ts` | the format mapping's request half: `AiMessage` blocks → OpenAI messages, `LlmTool` → functions, `tool_choice` |
|
|
34
34
|
| `openai-body.ts` | one chat-completions body, and the per-model reasoning mapping |
|
|
35
|
-
| `openai-wire.ts` | its response half: one completion
|
|
35
|
+
| `openai-wire.ts` / `openai-wire-stream.ts` | its response half: one completion and the field readers; `ChatCompletionStream` (fragmented tool calls, trailing usage, `[DONE]`) |
|
|
36
36
|
| `gateway.ts` | routing, cache, budget wiring |
|
|
37
37
|
| `gateway-providers.ts` | the retry loop across providers, `backoffMs`, `isRetryable`, the breakers |
|
|
38
|
-
| `budget.ts` | token ledgers per request/actor/org, ALS carrier |
|
|
38
|
+
| `budget.ts` / `budget-store.ts` | token ledgers per request/actor/org, ALS carrier; the `BudgetStore` contract and `memoryBudgetStore()` |
|
|
39
39
|
| `prompt.ts` | `definePrompt`, `promptHash`, version registry |
|
|
40
40
|
| `prompt-fence.ts` | a template's data fences: which tag pairs enclose each slot (`promptFences`), and the closer-breaking `render` applies inside one |
|
|
41
41
|
| `embeddings.ts` | `Embedder`, `hashEmbedder()`, cosine helpers |
|
|
@@ -57,10 +57,11 @@ Keep `policy` dev-only: tests and `*-fixture.ts` harnesses import `allow`. The
|
|
|
57
57
|
| `tool-arguments.ts` / `answer-errors.ts` | one argument-string reader for both wires and what an unreadable one means (`assertToolArguments`); `X_LLM_TRUNCATED` + `X_LLM_TOOL_ARGUMENTS_INVALID` |
|
|
58
58
|
| `billed.ts` / `run-meter.ts` | `meta.billed` + `billedOf`; the meter every `llm()`/`agent()` provider call goes through, and `meteredRun`, its one public total (`RunMeter` stays internal) |
|
|
59
59
|
| `failure-disclosure.ts` | `discloseFailure` — the ONE rule for what of a throw a remote reader sees (tool result, hive member); the withheld half is logged, never its message or cause |
|
|
60
|
-
| `llm.ts` | `llm()` — the model call, declared as an `action`; and what a streamed answer must satisfy |
|
|
60
|
+
| `llm.ts` / `llm-answer.ts` | `llm()` — the model call, declared as an `action`; one answered turn's span, `onCall`, fit, and what a streamed answer must satisfy |
|
|
61
|
+
| `llm-instrument.ts` | what one `llm()` call reports when it settles: gen_ai usage on the span, `ai.llm` on the bus |
|
|
61
62
|
| `respond.ts` | the `respond` tool for one `output` (a non-object one wrapped in `{ value }`), its reader, and `parseJsonish` |
|
|
62
63
|
| `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
|
|
63
|
-
| `agent.ts` | `agent()` — the tool loop, declared as an `action
|
|
64
|
+
| `agent.ts` / `agent-tools.ts` | `agent()` — the tool loop, declared as an `action`; one turn's bounded tool fan-out |
|
|
64
65
|
| `agent-def.ts` / `agent-declared.ts` / `agent-system.ts` / `agent-usage.ts` | the declaration's types; its defaults, bound screen, limits and fact row; the system one run sends; its running usage |
|
|
65
66
|
| `llm-def.ts` / `cost.ts` | `llm()`'s declaration types and `LlmCall`; the money arithmetic (`costOf`, `exactCostOf`) |
|
|
66
67
|
| `agent-guards.ts` / `agent-errors.ts` | the repeated-turn detector and the run deadline; their two classes |
|
|
@@ -69,7 +70,7 @@ Keep `policy` dev-only: tests and `*-fixture.ts` harnesses import `allow`. The
|
|
|
69
70
|
| `agent-per-run.ts` / `agent-run-limits.ts` | one run's `offerTools()`/`model()`/`limits()`, narrow only; `costPerRun` |
|
|
70
71
|
| `agent-compact{,-def,-measure,-request,-text}.ts` | trigger, cut; `compact`'s option and event; the token ratio; the summary request, as text when too long |
|
|
71
72
|
| `agent-send.ts` / `context-overflow.ts` / `agent-checkpoint.ts` | a turn sent, an overflow compacted once; `X_AI_CONTEXT_OVERFLOW`'s phrases; `onCheckpoint` |
|
|
72
|
-
| `agent-continue.ts` / `agent-turn.ts` | `.run()` / `.continue()
|
|
73
|
+
| `agent-continue.ts` / `agent-session.ts` / `agent-turn.ts` | the transcript handle and its screen; `.run()` / `.continue()` and their session; one turn's report and refusals |
|
|
73
74
|
| `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
|
|
74
75
|
| `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish (its consumer has to be `cli`, tier 5) |
|
|
75
76
|
| `agent-job.ts` | `agentJob()` — an agent as a real `JobHandle`, composed from `job()` |
|
|
@@ -111,6 +112,12 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#bou
|
|
|
111
112
|
|
|
112
113
|
## Invariants — `llm()`
|
|
113
114
|
|
|
115
|
+
- **Telemetry is semconv's `gen_ai.*`, the rest `ultimate.llm.*`** — never a bare `llm.*` key.
|
|
116
|
+
`llm-instrument.ts` wraps the run meter (every provider call passes it) and settles ONCE per
|
|
117
|
+
`llm()` call — answered, cache hit or failed: the run's summed usage onto the `client` span and
|
|
118
|
+
`ai.llm` onto core's bus (`llm-instrument.test.ts`). `agent()` emits one `ai.llm` per MODEL CALL —
|
|
119
|
+
each turn and each compaction summary, through `reportedCall` — so the token metric counts agents.
|
|
120
|
+
|
|
114
121
|
Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm-invariants).
|
|
115
122
|
|
|
116
123
|
- **`llm()` returns an `action`**, never a ninth primitive; it adds only the model half: render the
|
|
@@ -124,7 +131,7 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
|
|
|
124
131
|
- **Semantic scopes are separate cache INSTANCES** (key: prompt hash AND `ctx.locale`); **the
|
|
125
132
|
default scope is the calling ACTOR**; widening is `scope: () => 'global'`; the map is bounded.
|
|
126
133
|
`cache.invalidates` is not on `llm()`: version bump + `ttl` is the invalidation.
|
|
127
|
-
- The gateway is
|
|
134
|
+
- The gateway is the `aiGateway` port: one slot, `installAiGateway` on `./adapter` only (`adapter.test.ts`), never `configureAi` (v31); absent at call time is `X_AI_GATEWAY_MISSING`.
|
|
128
135
|
- **`llm()` forwards `ctx.signal`** onto `GenerateRequest`; `.stream()` inherits it.
|
|
129
136
|
- **`.stream()` is the SAME action**, marked with an ambient sink: UNVALIDATED text, then one `done`
|
|
130
137
|
with the validated value; no repair turn (`X_LLM_STREAM_INVALID`), no `respond` tool. LAZY.
|
|
@@ -194,7 +201,7 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
|
|
|
194
201
|
- **`embedBatched` enforces the `Embedder` arity per batch** (`X_AI_EMBEDDER_INVALID` with both
|
|
195
202
|
counts). Full rule: `docs/history/ai.md`.
|
|
196
203
|
- **A caught value is read with `renderThrowable`** (`remote-embedder.ts`, `wire.ts`,
|
|
197
|
-
`openai-wire.ts`) — `scripts/error-render.ts` cannot see `catch` bindings.
|
|
204
|
+
`openai-wire-stream.ts`) — `scripts/error-render.ts` cannot see `catch` bindings.
|
|
198
205
|
- **A caller's string is never an object KEY**: the wire tables are `Map`s; `prompt.render` and
|
|
199
206
|
`echoProvider()`'s `replies` use `Object.hasOwn`.
|
|
200
207
|
|
|
@@ -207,12 +214,14 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
|
|
|
207
214
|
- `tools` takes a real `action()` (`AgentTool = AnyAction | ProjectableAction`,
|
|
208
215
|
`asProjectableAction`); projection happens on the FIRST RUN, memoised. An agent can be another
|
|
209
216
|
agent's tool.
|
|
210
|
-
- A tool is an
|
|
217
|
+
- A tool is an action core's `isAgentOffered` admits (exposed, or `agents: true`) or an
|
|
218
|
+
`inProcessTool()` (`isOfferedTool`); anything else is
|
|
211
219
|
`X_AGENT_TOOL_UNEXPOSED` at DECLARATION. An in-process tool is never an action, never projected
|
|
212
220
|
to MCP, has no policy (the agent's decides) and parses its own input.
|
|
213
221
|
- `ctx.signal` is read at the top of every turn, before every tool batch, and forwarded to
|
|
214
222
|
`fetch`; `X_ABORTED` is core's.
|
|
215
|
-
- One turn's tools run concurrently,
|
|
223
|
+
- One turn's tools run concurrently, at most `maxToolConcurrency` (default 8, `agent-tools.ts`),
|
|
224
|
+
results paired by index; no tool STARTS after an abort or the deadline.
|
|
216
225
|
- **`mcpConfirmations({ tools })` holds a tool here**, one pass each however it arrives
|
|
217
226
|
(`tools-gate.ts`). Proof: `scripts/ai-tool-confirmation.test.ts`.
|
|
218
227
|
- `onTurn` reports facts only (also an `agent.turn` span event); a throw from it fails the run.
|
|
@@ -263,13 +272,13 @@ retrieval is hybrid only; an unbound store read under an org actor is `X_VECTOR_
|
|
|
263
272
|
## Commands
|
|
264
273
|
|
|
265
274
|
```
|
|
266
|
-
bun test packages/ai
|
|
275
|
+
bun test ./packages/ai/
|
|
267
276
|
bun run --filter @ultimat3/ai typecheck
|
|
268
277
|
|
|
269
278
|
# the live vector suite — needs the extension, not just a Postgres
|
|
270
279
|
docker run -d -e POSTGRES_PASSWORD=ultimate -p 5432:5432 pgvector/pgvector:pg17
|
|
271
280
|
TEST_DATABASE_URL=postgres://postgres:ultimate@localhost:5432/postgres \
|
|
272
|
-
bun test packages/ai/src/pg-vector.live.test.ts
|
|
281
|
+
bun test ./packages/ai/src/pg-vector.live.test.ts
|
|
273
282
|
```
|
|
274
283
|
|
|
275
284
|
Why each rule above is shaped the way it is: [`docs/history/ai.md`](../../docs/history/ai.md).
|
package/README.md
CHANGED
|
@@ -15,7 +15,6 @@ example).
|
|
|
15
15
|
```ts
|
|
16
16
|
import {
|
|
17
17
|
budgetKeysFor,
|
|
18
|
-
configureAi,
|
|
19
18
|
providerGateway,
|
|
20
19
|
openAiProvider,
|
|
21
20
|
registerModel,
|
|
@@ -39,7 +38,8 @@ export const ai = providerGateway({
|
|
|
39
38
|
budget: { request: 40_000, actor: 500_000, org: 20_000_000 }, // tokens
|
|
40
39
|
cache: memoCache,
|
|
41
40
|
});
|
|
42
|
-
|
|
41
|
+
// apps/web/runtime.ts — the boot installs it: `aiGateway` is the one way a gateway arrives.
|
|
42
|
+
export const runtime = { aiGateway: ai };
|
|
43
43
|
|
|
44
44
|
// Budgets are scoped, and every nested call inside the scope shares one ledger. `llm()`, `agent()`
|
|
45
45
|
// and `hive()` open one keyed on their caller (`budgetKeysFor(ctx.actor)`) without being asked.
|
|
@@ -148,13 +148,13 @@ default store at `replicas: 6` is six ledgers of twenty million, which is a budg
|
|
|
148
148
|
| 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 |
|
|
149
149
|
| 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 |
|
|
150
150
|
| A local refusal is never collected into `X_AI_PROVIDER_UNAVAILABLE` | `X_AI_KEY_MISSING` and `X_AI_REQUEST_INVALID` are raised before the request leaves; retrying them across providers burns attempts on the same answer and discards the runnable `fix:`. `generate()` and `stream()` therefore answer the same misconfiguration the same way |
|
|
151
|
-
| 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 `
|
|
151
|
+
| 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 `gen_ai.provider.name`, so the fallback that does exist is never silent |
|
|
152
152
|
| 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` |
|
|
153
153
|
| `reserve()` **debits, then checks** | the in-memory scopes check and debit with no `await` between, the store's through its atomic `take`; a read-then-write let concurrent calls — one request per scope racing one org key — all read the same `spent()` and all pass. `record` reconciles and `release` gives it back |
|
|
154
154
|
| The cache key is every tool **whole**, through core's `fingerprint` | every `llm()` tool is named `respond`, so a key over names served an answer shaped for an old `output` schema; a cache `set` that throws is logged, never turned into a failure of a call already paid for |
|
|
155
155
|
| A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
|
|
156
156
|
| A cache hit reads zero `usage`, `cost` and `costExact`, in the same currency | it is not debited, so a meter summing the stored figures read more than the budget beside it |
|
|
157
|
-
|
|
|
157
|
+
| `retry` is **core's `RetryPolicy`**; the default is full jitter (`DEFAULT_GATEWAY_RETRY`: `{ attempts: 3, base: 500, max: 8_000, jitter: 'full' }`) | synchronised retries from N workers reproduce the rate limit. `backoffMs` is `@ultimat3/core`'s `backoffDelay` over the policy, `timeBudgetMs` bounds the whole call across providers, and the roll is `providerGateway({ random })` — injectable, so the schedule is a unit test rather than a range |
|
|
158
158
|
| **One breaker per provider**, on by default: `{ failures: 5, cooldownMs: 30_000 }` (`DEFAULT_GATEWAY_BREAKER`) | a provider that ran out its retries five passes in a row is skipped, no call and no sleep, until one probe after the cooldown answers; without it every call re-spent every attempt on a dead provider. `providerGateway({ breaker })` takes `GatewayBreakerOption`, `((provider) => Breaker) \| 'off'`: your own core `breaker({ … })` per provider, or `'off'` for the old behaviour. A stream goes to the first provider whose breaker admits it |
|
|
159
159
|
| A 4xx is never retried **except 408, 409 and 425** | the same body gets the same rejection and burns the budget — but a request the server stopped reading (408), a round a concurrent writer won (409) and a handshake that had not finished (425) are transient by construction, and core's `isRetryableStatus` is the one table that says so |
|
|
160
160
|
|
|
@@ -216,7 +216,7 @@ registerModel({
|
|
|
216
216
|
reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
export const runtime = { aiGateway: providerGateway({ providers: [new InternalGatewayProvider()] }) };
|
|
220
220
|
```
|
|
221
221
|
|
|
222
222
|
Two optional per-model rows: `sampling: { temperature: true, seed: true }` (the model takes the `temperature` / `seed` an `llm()` or `agent()` declares — absent, they are not sent) and `forcedToolChoice: false` (the model rejects a forced `tool_choice`, so `llm()`'s one `respond` tool is offered under the endpoint's default).
|
|
@@ -318,7 +318,7 @@ openAiProvider({
|
|
|
318
318
|
apiKey: env.GATEWAY_TOKEN,
|
|
319
319
|
baseUrl: 'https://llm.acme.internal/v1',
|
|
320
320
|
models: ['llama-internal-70b'],
|
|
321
|
-
name: 'acme-gateway', // what `result.provider` and `
|
|
321
|
+
name: 'acme-gateway', // what `result.provider` and `gen_ai.provider.name` will say
|
|
322
322
|
headers: { 'x-team': 'platform' },
|
|
323
323
|
});
|
|
324
324
|
|
|
@@ -448,7 +448,7 @@ sub-agent in its own `tools` and the sub-agent runs under the same actor, throug
|
|
|
448
448
|
|---|---|
|
|
449
449
|
| 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 |
|
|
450
450
|
| 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 |
|
|
451
|
-
| 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
|
|
451
|
+
| the tools of **one turn** run concurrently, at most `maxToolConcurrency` at once (default 8), results paired by `tool_use` id | a turn asking for five tools cost 5x wall clock and nothing said so; a turn asking for fifty — a confused or injected model — was fifty concurrent invocations and pool checkouts. Order is positional, never by completion; a call waiting for a slot never starts after an abort or the deadline. `maxToolConcurrency: 1` runs a turn serially |
|
|
452
452
|
| `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 |
|
|
453
453
|
| a tool is an `mcp: { expose: true }` action or an `inProcessTool()`; anything else is `X_AGENT_TOOL_UNEXPOSED` **at declaration** | a silently dropped tool reads as offered and is not; `isMcpExposed` is the one predicate for ACTIONS, so an in-app agent and an external MCP client see the same catalogue of them — and a tool no MCP client may see is declared as one that is this process's alone |
|
|
454
454
|
| a tool in `mcpConfirmations({ tools })` answers `X_MCP_CONFIRMATION_PENDING` until a person approves | one list for both surfaces, so no option an author can forget. The decision action declares no `mcp.expose`, so listing it is `X_AGENT_TOOL_UNEXPOSED`: **exposing it hands an in-app agent, whose actor is the user, its own approval** |
|
|
@@ -780,7 +780,7 @@ the sweep and `hive()` for the fan-out inside one page.
|
|
|
780
780
|
|---|---|
|
|
781
781
|
| `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 |
|
|
782
782
|
| `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 |
|
|
783
|
-
| the action projection is read **lazily** | `agentJob()` runs at module scope beside the `agent()` it wraps, and names are stamped by `
|
|
783
|
+
| the action projection is read **lazily** | `agentJob()` runs at module scope beside the `agent()` it wraps, and names are stamped by `defineApi` at boot — reading `.job()` eagerly makes that ordinary file `X_ACTION_UNREGISTERED` |
|
|
784
784
|
| 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 |
|
|
785
785
|
| the actor is the worker context's, never the model's | the org comes from the job's declared `tenant`; nothing a model emits can reach the identity. In a served app the worker's actor is the **anonymous** one, so a member policy refuses it `X_UNAUTHENTICATED` — declare `actor` |
|
|
786
786
|
| `actor({ input, ctx })` resolves who the run acts FOR, on every attempt | the jobs rule — a job acting for a user takes the user's id in its input and re-authorises it in the body — for a body that is the agent's. Load the member under the job's tenant; the swap is core's `impersonate`, so the worker stays on the record as `onBehalfOf`. An actor in another org than `tenant` is `X_JOB_TENANT_MISMATCH` before the agent starts — terminal, so it dead-letters on attempt 1 |
|
|
@@ -831,7 +831,7 @@ the rows are real; wait for `x manifest` to carry them and you will wait. Same f
|
|
|
831
831
|
|
|
832
832
|
An agent projects to an `ActionDescriptor` like any other action, and that descriptor knows nothing
|
|
833
833
|
about turns or tools — so "how far can this loop, and what may it call" had no answer outside the
|
|
834
|
-
source. Names are read when you ask, not when the agent was declared: `
|
|
834
|
+
source. Names are read when you ask, not when the agent was declared: `defineApi` stamps them
|
|
835
835
|
at boot, long after `agent()` ran at module scope. An agent nothing registered has no row, because
|
|
836
836
|
an action with no name reaches no route, no tool catalogue and no queue.
|
|
837
837
|
|
|
@@ -842,25 +842,58 @@ runs — and the redactor sits on both, between the row and a third-party endpoi
|
|
|
842
842
|
`vars()` alone until 2026-09-23, so an agent's tool results left the process unredacted.
|
|
843
843
|
|
|
844
844
|
```ts
|
|
845
|
-
configureAi({
|
|
845
|
+
configureAi({ redact: (text) => scrubPatientIdentifiers(text) });
|
|
846
846
|
```
|
|
847
847
|
|
|
848
848
|
The redactor sees the whole rendered prompt and the system prompt — template as well as values,
|
|
849
849
|
because a redactor shown only the values cannot tell a name in a data slot from the same name in an
|
|
850
|
-
instruction. Whether it changed anything is on the span as `llm.redacted`.
|
|
850
|
+
instruction. Whether it changed anything is on the span as `ultimate.llm.redacted`.
|
|
851
851
|
|
|
852
852
|
**What** to remove is yours: a PII classifier is a model choice, so the framework ships the seam
|
|
853
853
|
and not the classifier. The one rule it does enforce, redactor or not: a `Secret` among the
|
|
854
854
|
variables is `X_AI_PROMPT_SECRET`. Not a leak — `Secret` renders `[redacted]` by value — but a
|
|
855
855
|
prompt that reads fine, means something else, and costs full price.
|
|
856
856
|
|
|
857
|
+
### The `aiGateway` port
|
|
858
|
+
|
|
857
859
|
The gateway is ambient, installed once at boot — a declaration is evaluated at module scope,
|
|
858
|
-
long before a provider exists:
|
|
860
|
+
long before a provider exists. One way in (31.0.0): `aiGateway` in `apps/<app>/runtime.ts`.
|
|
859
861
|
|
|
860
862
|
```ts
|
|
861
|
-
|
|
863
|
+
// apps/web/runtime.ts
|
|
864
|
+
export const runtime = {
|
|
865
|
+
aiGateway: providerGateway({ providers: [yourProvider], defaultModel: 'house-large' }),
|
|
866
|
+
};
|
|
862
867
|
```
|
|
863
868
|
|
|
869
|
+
The boot installs it through `installAiGateway(gateway): () => void`, on `@ultimat3/ai/adapter`
|
|
870
|
+
only — with the `Gateway` interface, its request/answer types and the errors a gateway driver
|
|
871
|
+
throws (`AiProviderUnavailableError`, `AiKeyMissingError`, `AiModelUnknownError`,
|
|
872
|
+
`AiRequestInvalidError`). A test swaps the gateway the same way and calls the release to put the
|
|
873
|
+
previous one back.
|
|
874
|
+
|
|
875
|
+
The embedder and the semantic cache are ports the same way — `embedder` (an `Embedder`; default
|
|
876
|
+
`hashEmbedder()`, offline and lexical) and `semanticCache` (a `SemanticCacheFactory`,
|
|
877
|
+
`(scope) => SemanticCache`; default a `memorySemanticCache()` per scope), installed with
|
|
878
|
+
`installAiEmbedder` / `installSemanticCache` from `@ultimat3/ai/adapter`. Each install clears the
|
|
879
|
+
per-scope caches: vectors from two embedders are not comparable. Prove one with
|
|
880
|
+
`embedderConformance` / `semanticCacheFactoryConformance` (`@ultimat3/testing/conformance`).
|
|
881
|
+
|
|
882
|
+
```ts
|
|
883
|
+
// apps/web/runtime.ts
|
|
884
|
+
import { remoteEmbedder } from '@ultimat3/ai';
|
|
885
|
+
import { memorySemanticCache } from '@ultimat3/cache';
|
|
886
|
+
|
|
887
|
+
export const runtime = {
|
|
888
|
+
embedder: remoteEmbedder({ name: 'voyage-3', dimension: 1_024 }), // EMBEDDINGS_API_KEY
|
|
889
|
+
// One cache per scope; a pgvector-backed factory of your own takes the same shape.
|
|
890
|
+
semanticCache: () => memorySemanticCache({ threshold: 0.95 }),
|
|
891
|
+
};
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
`configureAi` configures the rest — `redact` only — and takes no `gateway`, `embedder` or
|
|
895
|
+
`semanticCache` (31.0.0).
|
|
896
|
+
|
|
864
897
|
Missing at call time is `X_AI_GATEWAY_MISSING`, never a silent default provider.
|
|
865
898
|
|
|
866
899
|
## Evals are a test type
|
|
@@ -1124,7 +1157,7 @@ fail-closed `terminal` default.
|
|
|
1124
1157
|
|---|---|
|
|
1125
1158
|
| `X_AI_PROVIDER_UNAVAILABLE` | every provider for the model was unreachable; lists what each said. A TRANSPORT failure only — a coded refusal raised before the socket opens (`X_AI_KEY_MISSING`, `X_AI_REQUEST_INVALID`) reaches the caller as itself, `As of 2026-08-23`, because the same rejection waits on every provider and every attempt and its `fix:` is the whole point of it |
|
|
1126
1159
|
| `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains |
|
|
1127
|
-
| `X_AI_GATEWAY_MISSING` | an `llm()` action ran
|
|
1160
|
+
| `X_AI_GATEWAY_MISSING` | an `llm()` action ran with no `aiGateway` installed |
|
|
1128
1161
|
| `X_AI_PROMPT_VERSION` | version drift, or a render missing a declared variable |
|
|
1129
1162
|
| `X_AI_MODEL_UNKNOWN` | a model id nothing called `registerModel` for; names the registered set |
|
|
1130
1163
|
| `X_AI_CONTENT_UNSUPPORTED` | an image or document block the role, the model's `input` row or the wire format cannot take; names which, refused before the socket |
|
|
@@ -1162,23 +1195,15 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
1162
1195
|
| `AgentToolUnexposedError` | `X_AGENT_TOOL_UNEXPOSED` | `src/errors.ts` |
|
|
1163
1196
|
| `AiBudgetExceededError` | `X_AI_BUDGET_EXCEEDED` | `src/errors.ts` |
|
|
1164
1197
|
| `AiContentUnsupportedError` | `X_AI_CONTENT_UNSUPPORTED` | `src/content-errors.ts` |
|
|
1165
|
-
| `AiGatewayMissingError` | `X_AI_GATEWAY_MISSING` | `src/errors.ts` |
|
|
1166
|
-
| `AiKeyMissingError` | `X_AI_KEY_MISSING` | `src/errors.ts` |
|
|
1167
|
-
| `AiModelUnknownError` | `X_AI_MODEL_UNKNOWN` | `src/errors.ts` |
|
|
1168
|
-
| `AiPromptRenderError` | `X_AI_PROMPT_VERSION` | `src/errors.ts` |
|
|
1169
|
-
| `AiPromptSecretError` | `X_AI_PROMPT_SECRET` | `src/errors.ts` |
|
|
1170
|
-
| `AiPromptVersionError` | `X_AI_PROMPT_VERSION` | `src/errors.ts` |
|
|
1171
1198
|
| `AiProviderUnavailableError` | `X_AI_PROVIDER_UNAVAILABLE` | `src/errors.ts` |
|
|
1172
1199
|
| `AiRequestInvalidError` | `X_AI_REQUEST_INVALID` | `src/errors.ts` |
|
|
1173
1200
|
| `AiTransportError` | `X_AI_PROVIDER_UNAVAILABLE` | `src/errors.ts` |
|
|
1174
1201
|
| `EmbedderDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
|
|
1175
|
-
| `EvalBaselineInvalidError` | `X_EVAL_BASELINE_INVALID` | `src/eval-errors.ts` |
|
|
1176
1202
|
| `EvalBaselineMissingError` | `X_EVAL_BASELINE_MISSING` | `src/eval-errors.ts` |
|
|
1177
1203
|
| `EvalMissingError` | `X_EVAL_MISSING` | `src/eval-errors.ts` |
|
|
1178
1204
|
| `EvalRecordingError` | `X_EVAL_RECORDING` | `src/eval-errors.ts` |
|
|
1179
1205
|
| `EvalThresholdError` | `X_EVAL_THRESHOLD` | `src/eval-errors.ts` |
|
|
1180
1206
|
| `EvalToolUndoubledError` | `X_EVAL_TOOL_UNDOUBLED` | `src/eval-errors.ts` |
|
|
1181
|
-
| `HiveEmptyError` | `X_HIVE_EMPTY` | `src/hive-errors.ts` |
|
|
1182
1207
|
| `LlmOutputInvalidError` | `X_LLM_OUTPUT_INVALID` | `src/errors.ts` |
|
|
1183
1208
|
| `LlmRefusedError` | `X_LLM_REFUSED` | `src/errors.ts` |
|
|
1184
1209
|
| `LlmStreamInvalidError` | `X_LLM_STREAM_INVALID` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.0.0",
|
|
4
4
|
"description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"provenance": true
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
29
|
-
".": "./src/index.ts"
|
|
29
|
+
".": "./src/index.ts",
|
|
30
|
+
"./adapter": "./src/adapter.ts"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"src",
|
|
@@ -44,17 +45,17 @@
|
|
|
44
45
|
"test": "bun test"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"@ultimat3/action": "
|
|
48
|
-
"@ultimat3/cache": "
|
|
49
|
-
"@ultimat3/core": "
|
|
50
|
-
"@ultimat3/db": "
|
|
51
|
-
"@ultimat3/http": "
|
|
52
|
-
"@ultimat3/jobs": "
|
|
53
|
-
"@ultimat3/money": "
|
|
54
|
-
"@ultimat3/schema": "
|
|
55
|
-
"@ultimat3/time": "
|
|
48
|
+
"@ultimat3/action": "31.0.0",
|
|
49
|
+
"@ultimat3/cache": "31.0.0",
|
|
50
|
+
"@ultimat3/core": "31.0.0",
|
|
51
|
+
"@ultimat3/db": "31.0.0",
|
|
52
|
+
"@ultimat3/http": "31.0.0",
|
|
53
|
+
"@ultimat3/jobs": "31.0.0",
|
|
54
|
+
"@ultimat3/money": "31.0.0",
|
|
55
|
+
"@ultimat3/schema": "31.0.0",
|
|
56
|
+
"@ultimat3/time": "31.0.0"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
|
-
"@ultimat3/policy": "
|
|
59
|
+
"@ultimat3/policy": "31.0.0"
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// `@ultimat3/ai/adapter` — the `aiGateway`, `embedder` and `semanticCache` ports: the interfaces
|
|
2
|
+
// an adapter implements, the shapes a gateway speaks, the errors a driver throws, and the boot's
|
|
3
|
+
// installers. Installers live here and never on the `.` barrel; the boot fills them from
|
|
4
|
+
// `apps/<app>/runtime.ts`.
|
|
5
|
+
|
|
6
|
+
export type { SemanticCache } from '@ultimat3/cache';
|
|
7
|
+
export type { BudgetKeys, BudgetLedger } from './budget';
|
|
8
|
+
export type { Embedder } from './embeddings';
|
|
9
|
+
export {
|
|
10
|
+
AiEmbedderInvalidError,
|
|
11
|
+
AiKeyMissingError,
|
|
12
|
+
AiModelUnknownError,
|
|
13
|
+
AiProviderUnavailableError,
|
|
14
|
+
AiRequestInvalidError,
|
|
15
|
+
} from './errors';
|
|
16
|
+
export type { Gateway } from './gateway';
|
|
17
|
+
export type { ModelId } from './models';
|
|
18
|
+
export type { GenerateRequest, GenerateResult, StreamChunk } from './provider';
|
|
19
|
+
export type { SemanticCacheFactory } from './runtime';
|
|
20
|
+
export { installAiEmbedder, installAiGateway, installSemanticCache } from './runtime';
|
|
21
|
+
export type { SamplingWire } from './sampling';
|
package/src/agent-compact.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { blocksOf, COMPACTED_MARK, summaryCall } from './agent-compact-request';
|
|
|
22
22
|
import { AgentContextExhaustedError } from './agent-errors';
|
|
23
23
|
import type { RunDeadline } from './agent-guards';
|
|
24
24
|
import type { Gateway } from './gateway';
|
|
25
|
+
import { reportedCall } from './llm-instrument';
|
|
25
26
|
import type { ModelId } from './models';
|
|
26
27
|
import { modelSpec } from './models';
|
|
27
28
|
import type { AiMessage, GenerateRequest, GenerateResult, TokenUsage } from './provider';
|
|
@@ -118,7 +119,7 @@ export function compactor(input: CompactorInput): Compactor {
|
|
|
118
119
|
'agent.compact.clipped_turns': event.clipped ?? 0,
|
|
119
120
|
'agent.compact.omitted_turns': event.omitted ?? 0,
|
|
120
121
|
}),
|
|
121
|
-
...(event.cost === undefined ? {} : { 'llm.cost.minor': event.cost.minor }),
|
|
122
|
+
...(event.cost === undefined ? {} : { 'ultimate.llm.cost.minor': event.cost.minor }),
|
|
122
123
|
});
|
|
123
124
|
await input.onCompact?.(event);
|
|
124
125
|
};
|
|
@@ -193,7 +194,9 @@ export function compactor(input: CompactorInput): Compactor {
|
|
|
193
194
|
try {
|
|
194
195
|
// Its own meter, inside the run's: the bill reaches the run as a nested call's does — on the
|
|
195
196
|
// same ledger, in `tokensPerRun` and `costPerRun`, priced at the summary model's own rates.
|
|
196
|
-
result = await input.deadline.guard(
|
|
197
|
+
result = await input.deadline.guard(
|
|
198
|
+
runMeter().call(reportedCall(request.model ?? writer, input.gateway.generate(request))),
|
|
199
|
+
);
|
|
197
200
|
} catch (error) {
|
|
198
201
|
throwIfAborted(input.ctx);
|
|
199
202
|
const code = stringField(error, 'code');
|
package/src/agent-continue.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: continuing an `agent()` run — the transcript handle a finished run
|
|
2
|
-
// leaves, the screen it passes when an app hands it back, the one user turn a continuation
|
|
3
|
-
// appends
|
|
2
|
+
// leaves, the screen it passes when an app hands it back, and the one user turn a continuation
|
|
3
|
+
// appends. `.run()` / `.continue()` themselves are ./agent-session.
|
|
4
4
|
//
|
|
5
5
|
// It exists because the alternative is a whole new run: repairing one section of an article
|
|
6
6
|
// re-pays the transcript and every tool result, and the model starts from nothing. A continuation
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
|
|
15
15
|
import type { Action, InvokeOptions } from '@ultimat3/action';
|
|
16
16
|
import {
|
|
17
|
-
asyncContext,
|
|
18
17
|
CURSOR_SECRET_FIX,
|
|
19
18
|
CURSOR_SECRET_KEY,
|
|
20
19
|
canonicalJson,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
hmac,
|
|
24
23
|
isProduction,
|
|
25
24
|
isUltimateError,
|
|
25
|
+
timingSafeEqual,
|
|
26
26
|
usesDevCursorSecret,
|
|
27
27
|
} from '@ultimat3/core';
|
|
28
28
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
@@ -136,63 +136,6 @@ export interface AgentAction<TInput extends StandardSchemaV1, TOutput extends St
|
|
|
136
136
|
named(name: string): AgentAction<TInput, TOutput>;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/** One `.run()` / `.continue()` in flight: what to resume from, and where the loop leaves its run. */
|
|
140
|
-
export interface AgentSession {
|
|
141
|
-
/**
|
|
142
|
-
* Which DECLARATION this session was opened for. The session rides the async context, so every
|
|
143
|
-
* agent that runs underneath — a tool that calls another agent — can see it; only the one whose
|
|
144
|
-
* token this is may take it, and only once.
|
|
145
|
-
*/
|
|
146
|
-
readonly owner: object;
|
|
147
|
-
claimed: boolean;
|
|
148
|
-
readonly resume: AgentContinuation | undefined;
|
|
149
|
-
finished: Omit<AgentRun<unknown>, 'output'> | undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Core's lazy seam, like `llm-stream.ts`'s sink: the mark rides the invocation, so the action a
|
|
153
|
-
// continuation calls is the one object the registry, the policy and the audit record know.
|
|
154
|
-
const sessions = asyncContext<AgentSession>('an agent session');
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* The session of THIS run, taken — or `undefined` for a plain call, and for any other agent
|
|
158
|
-
* running under somebody else's `.run()` / `.continue()`: a nested agent starts fresh.
|
|
159
|
-
*/
|
|
160
|
-
export function claimAgentSession(owner: object): AgentSession | undefined {
|
|
161
|
-
const session = sessions.get();
|
|
162
|
-
if (session === undefined || session.owner !== owner || session.claimed) return undefined;
|
|
163
|
-
session.claimed = true;
|
|
164
|
-
return session;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** `.run()` and `.continue()` for one action: each invokes THAT action inside a session. */
|
|
168
|
-
export function sessionCalls<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
169
|
-
target: Action<TInput, TOutput>,
|
|
170
|
-
/** The declaration's own token: `named()` twins share it, no other agent has it. */
|
|
171
|
-
owner: object,
|
|
172
|
-
): Pick<AgentAction<TInput, TOutput>, 'run' | 'continue'> {
|
|
173
|
-
const invoke = async (
|
|
174
|
-
input: InferInput<TInput>,
|
|
175
|
-
resume: AgentContinuation | undefined,
|
|
176
|
-
opts: InvokeOptions | undefined,
|
|
177
|
-
): Promise<AgentRun<InferOutput<TOutput>>> => {
|
|
178
|
-
const session: AgentSession = { owner, claimed: false, resume, finished: undefined };
|
|
179
|
-
const output = await sessions.run(session, () => target(input, opts ?? {}));
|
|
180
|
-
const finished = session.finished;
|
|
181
|
-
if (finished === undefined) {
|
|
182
|
-
// The action answered without the loop running: something wrapped it and short-circuited.
|
|
183
|
-
throw new AgentTranscriptInvalidError({
|
|
184
|
-
agent: target.name ?? 'agent',
|
|
185
|
-
reason: 'the action returned without running the agent loop, so there is no transcript',
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
return { output, ...finished };
|
|
189
|
-
};
|
|
190
|
-
return {
|
|
191
|
-
run: (input, opts) => invoke(input, undefined, opts),
|
|
192
|
-
continue: (input, resume, opts) => invoke(input, resume, opts),
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
139
|
/**
|
|
197
140
|
* The transcript with ONE user turn appended. Every `tool_use` the last assistant turn left open is
|
|
198
141
|
* answered first — the accepted `respond` call among them, which the API insists on seeing
|
|
@@ -321,7 +264,7 @@ export function screenTranscript(binding: TranscriptBinding, raw: unknown): Agen
|
|
|
321
264
|
} catch {
|
|
322
265
|
return refuse('it is not JSON');
|
|
323
266
|
}
|
|
324
|
-
if (typeof sig !== 'string' || !
|
|
267
|
+
if (typeof sig !== 'string' || !timingSafeEqual(sig, expected)) {
|
|
325
268
|
refuse('its signature does not match — it was edited, or the signing secret rotated');
|
|
326
269
|
}
|
|
327
270
|
if (handle['agent'] !== agent) refuse(`it was written by agent "${String(handle['agent'])}"`);
|
|
@@ -393,14 +336,6 @@ export function screenTranscript(binding: TranscriptBinding, raw: unknown): Agen
|
|
|
393
336
|
};
|
|
394
337
|
}
|
|
395
338
|
|
|
396
|
-
/** Constant-time over equal lengths: a signature check must not say how much of it matched. */
|
|
397
|
-
function sameText(a: string, b: string): boolean {
|
|
398
|
-
if (a.length !== b.length) return false;
|
|
399
|
-
let diff = 0;
|
|
400
|
-
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
401
|
-
return diff === 0;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
339
|
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
405
340
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
406
341
|
? (value as Record<string, unknown>)
|
package/src/agent-def.ts
CHANGED
|
@@ -127,6 +127,11 @@ export interface AgentDef<
|
|
|
127
127
|
* `X_AGENT_TOOL_LOOP`. Default 3.
|
|
128
128
|
*/
|
|
129
129
|
readonly maxRepeatedToolCalls?: number;
|
|
130
|
+
/**
|
|
131
|
+
* Tool calls of ONE turn in flight at once; the rest wait for a slot, and results keep the order
|
|
132
|
+
* the model emitted them. Default 8 (`DEFAULT_TOOL_CONCURRENCY`); `1` runs a turn's tools serially.
|
|
133
|
+
*/
|
|
134
|
+
readonly maxToolConcurrency?: number;
|
|
130
135
|
/**
|
|
131
136
|
* Wall-clock ceiling for the whole run, in milliseconds. Passing it aborts the model call in
|
|
132
137
|
* flight and is `X_AGENT_DEADLINE`. No default: a timeout nobody declared is a lost answer.
|
|
@@ -147,7 +152,7 @@ export interface AgentDef<
|
|
|
147
152
|
* `temperature` (0 to 2) and `seed` (a whole number), for a call that should answer the same way
|
|
148
153
|
* twice — a verifier, a classifier. Sent only to a model whose row takes them
|
|
149
154
|
* (`registerModel({ sampling })`); to any other they are NOT sent, because a reasoning model
|
|
150
|
-
* answers one with a 400 — the `llm.sampling.ignored` span attribute names what was left out.
|
|
155
|
+
* answers one with a 400 — the `ultimate.llm.sampling.ignored` span attribute names what was left out.
|
|
151
156
|
* `seed` exists on the chat-completions wire only, and is best effort where it exists.
|
|
152
157
|
*/
|
|
153
158
|
readonly temperature?: number;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Single responsibility: `.run()` / `.continue()` on an agent's action — the ambient session a call
|
|
2
|
+
// opens, the one claim the declaring agent may make on it, and the two calls themselves. Split
|
|
3
|
+
// from ./agent-continue (the handle) and ./agent (the loop) at their line ceilings.
|
|
4
|
+
|
|
5
|
+
import type { Action, InvokeOptions } from '@ultimat3/action';
|
|
6
|
+
import { asyncContext } from '@ultimat3/core';
|
|
7
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AgentAction, AgentContinuation, AgentRun } from './agent-continue';
|
|
9
|
+
import { AgentTranscriptInvalidError } from './agent-errors';
|
|
10
|
+
|
|
11
|
+
/** One `.run()` / `.continue()` in flight: what to resume from, and where the loop leaves its run. */
|
|
12
|
+
export interface AgentSession {
|
|
13
|
+
/**
|
|
14
|
+
* Which DECLARATION this session was opened for. The session rides the async context, so every
|
|
15
|
+
* agent that runs underneath — a tool that calls another agent — can see it; only the one whose
|
|
16
|
+
* token this is may take it, and only once.
|
|
17
|
+
*/
|
|
18
|
+
readonly owner: object;
|
|
19
|
+
claimed: boolean;
|
|
20
|
+
readonly resume: AgentContinuation | undefined;
|
|
21
|
+
finished: Omit<AgentRun<unknown>, 'output'> | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Core's lazy seam, like `llm-stream.ts`'s sink: the mark rides the invocation, so the action a
|
|
25
|
+
// continuation calls is the one object the registry, the policy and the audit record know.
|
|
26
|
+
const sessions = asyncContext<AgentSession>('an agent session');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The session of THIS run, taken — or `undefined` for a plain call, and for any other agent
|
|
30
|
+
* running under somebody else's `.run()` / `.continue()`: a nested agent starts fresh.
|
|
31
|
+
*/
|
|
32
|
+
export function claimAgentSession(owner: object): AgentSession | undefined {
|
|
33
|
+
const session = sessions.get();
|
|
34
|
+
if (session === undefined || session.owner !== owner || session.claimed) return undefined;
|
|
35
|
+
session.claimed = true;
|
|
36
|
+
return session;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `.run()` and `.continue()` for one action: each invokes THAT action inside a session. */
|
|
40
|
+
export function sessionCalls<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
41
|
+
target: Action<TInput, TOutput>,
|
|
42
|
+
/** The declaration's own token: `named()` twins share it, no other agent has it. */
|
|
43
|
+
owner: object,
|
|
44
|
+
): Pick<AgentAction<TInput, TOutput>, 'run' | 'continue'> {
|
|
45
|
+
const invoke = async (
|
|
46
|
+
input: InferInput<TInput>,
|
|
47
|
+
resume: AgentContinuation | undefined,
|
|
48
|
+
opts: InvokeOptions | undefined,
|
|
49
|
+
): Promise<AgentRun<InferOutput<TOutput>>> => {
|
|
50
|
+
const session: AgentSession = { owner, claimed: false, resume, finished: undefined };
|
|
51
|
+
const output = await sessions.run(session, () => target(input, opts ?? {}));
|
|
52
|
+
const finished = session.finished;
|
|
53
|
+
if (finished === undefined) {
|
|
54
|
+
// The action answered without the loop running: something wrapped it and short-circuited.
|
|
55
|
+
throw new AgentTranscriptInvalidError({
|
|
56
|
+
agent: target.name ?? 'agent',
|
|
57
|
+
reason: 'the action returned without running the agent loop, so there is no transcript',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return { output, ...finished };
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
run: (input, opts) => invoke(input, undefined, opts),
|
|
64
|
+
continue: (input, resume, opts) => invoke(input, resume, opts),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/agent-system.ts
CHANGED
|
@@ -32,6 +32,6 @@ export function systemOf(
|
|
|
32
32
|
return fixed === undefined && stable === '' ? undefined : stable;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Whether two composed prompts are the same text — what `llm.redacted` compares. */
|
|
35
|
+
/** Whether two composed prompts are the same text — what `ultimate.llm.redacted` compares. */
|
|
36
36
|
export const sameSystem = (a: SystemPrompt | undefined, b: SystemPrompt | undefined): boolean =>
|
|
37
37
|
JSON.stringify(a) === JSON.stringify(b);
|