@ultimat3/ai 27.8.0 → 27.9.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 +57 -53
- package/README.md +75 -0
- package/package.json +11 -11
- package/src/agent-def.ts +111 -0
- package/src/agent-errors.ts +50 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-usage.ts +27 -0
- package/src/agent.ts +65 -89
- package/src/budget.ts +3 -2
- package/src/errors-retry.ts +6 -0
- package/src/errors.ts +4 -0
- package/src/gateway.ts +9 -2
- package/src/index.ts +19 -1
- package/src/llm.ts +11 -0
- package/src/openai-body.ts +50 -1
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +15 -3
- package/src/openai-wire.ts +16 -7
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +39 -11
package/CLAUDE.md
CHANGED
|
@@ -28,7 +28,7 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
28
28
|
|---|---|
|
|
29
29
|
| `models.ts` | the model REGISTRY: `registerModel`, limits, prices, the reasoning controls each one accepts — and NO row of its own |
|
|
30
30
|
| `model-resolve.ts` | `resolveModel` / `modelForCall` — declaration → prompt → gateway `defaultModel`, none is `X_AI_MODEL_UNRESOLVED` naming all three; `describedModel` for a published fact |
|
|
31
|
-
| `model-fixture.ts` | the rows this package's own suites register (`useFixtureModels()`)
|
|
31
|
+
| `model-fixture.ts` | the rows this package's own suites register (`useFixtureModels()`). Not shipped |
|
|
32
32
|
| `provider.ts` | `Provider` interface, the request half, the money arithmetic, `anthropicProvider()` |
|
|
33
33
|
| `echo-provider.ts` | `echoProvider()`, the deterministic double — split from `provider.ts` at its line ceiling |
|
|
34
34
|
| `content-blocks.ts` | the `image` / `document` block shapes, the screen every request runs them through, their token estimate |
|
|
@@ -62,6 +62,10 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
62
62
|
| `respond.ts` | the `respond` tool for one `output` (a non-object one wrapped in `{ value }`), its reader, and `parseJsonish` |
|
|
63
63
|
| `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
|
|
64
64
|
| `agent.ts` | `agent()` — the tool loop, declared as an `action` |
|
|
65
|
+
| `agent-def.ts` / `agent-system.ts` / `agent-usage.ts` | the declaration's types; the system one run sends; its running usage |
|
|
66
|
+
| `agent-guards.ts` / `agent-errors.ts` | the repeated-turn detector and the run deadline; their two classes |
|
|
67
|
+
| `prompt-cache.ts` | PROVIDER prompt caching: `SystemParts`, `PromptCacheHint`, `PROMPT_CACHE_RULES` (sources in its header) |
|
|
68
|
+
| `prompt-cache-blocks.ts` | the Anthropic wire's marks (chat-completions': `openai-messages.ts`) |
|
|
65
69
|
| `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
|
|
66
70
|
| `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish: nothing reads it yet, because `manifest` is tier 4 like this package and the consumer has to be `cli` at tier 5 |
|
|
67
71
|
| `agent-job.ts` | `agentJob()` — an agent as a real `JobHandle`, composed from `job()` |
|
|
@@ -71,37 +75,30 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
71
75
|
| `redaction.ts` | the one gate between `vars()` and the provider: a `Secret` never reaches a prompt |
|
|
72
76
|
| `eval-errors.ts` | the five `X_EVAL_*` classes; their codes and titles stay in `errors.ts` |
|
|
73
77
|
| `llm-cache.ts` | the semantic cache half of `llm()`: what a declaration may partition on, and the store it reaches |
|
|
74
|
-
| `llm-fixture.ts` | the
|
|
75
|
-
| `bounds-fixture.ts` | the one `refusal`/`asyncRefusal` every numeric-bound suite here asserts through. Not shipped, same rule |
|
|
76
|
-
| `agent-bounds.test.ts` | the agent loop's ceilings (split from `agent.test.ts`, at its line ceiling) |
|
|
78
|
+
| `llm-fixture.ts` / `bounds-fixture.ts` | the `llm()` harness; the one `refusal`/`asyncRefusal` of every bound suite. Not shipped (`!src/**/*-fixture.ts`) |
|
|
77
79
|
| `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
|
|
78
|
-
| `fix-line
|
|
80
|
+
| `fix-line*` | the package's dogfood eval — proof the baseline convention fails a build |
|
|
79
81
|
|
|
80
82
|
## Invariants — bounds and budget
|
|
81
83
|
|
|
82
|
-
- **Every numeric option is screened; `??` is not the screen
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`llm()`/`agent()` screen theirs at DECLARATION.
|
|
84
|
+
- **Every numeric option is screened; `??` is not the screen** (`finiteOption`/`finiteCount`;
|
|
85
|
+
`bun run finite-bounds` is the ratchet, blind to a required option, a default parameter and a
|
|
86
|
+
cross-field product). **`maxTokens` is the worst**: it IS the budget's pre-flight estimate, and a
|
|
87
|
+
`NaN` switches every ceiling off — the gateway, `registerModel` (`maxOutput`) and `llm()`/`agent()`
|
|
88
|
+
(at DECLARATION) each screen theirs.
|
|
88
89
|
- **A bound is screened under the key the DECLARATION uses** (`budget.tokensPerRun` in `limitsOf`
|
|
89
90
|
before the ledger's `request`; `retrieve()`'s own `k`; `remoteEmbedder()`'s `maxResponseBytes`).
|
|
90
91
|
- A per-call budget `derive`s from the ambient ledger and only TIGHTENS. **A derived ledger reports
|
|
91
92
|
back up the chain** (every debit on every ancestor; `reserve` checks each `request` scope); the
|
|
92
93
|
STORE is written once, by the ledger the call was made on.
|
|
93
|
-
- **`reserve` debits, then checks — never a read-then-write across an `await`.** The
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
every call carries keys, so a write per key grew the default store by one entry per caller.
|
|
99
|
-
`memoryBudgetStore()` has no window and no eviction (eviction is a ceiling bypass); a counter back
|
|
100
|
-
at zero is deleted.
|
|
94
|
+
- **`reserve` debits, then checks — never a read-then-write across an `await`.** The store's scopes
|
|
95
|
+
go through `BudgetStore.take(key, tokens, limit)`: REQUIRED, atomic (never `spent` + `add`). A
|
|
96
|
+
refusal at a later scope gives back what the earlier ones took.
|
|
97
|
+
- **The store is touched only for a scope whose ceiling is DECLARED** (`takeScope`, `meteredKeys`).
|
|
98
|
+
`memoryBudgetStore()` has no window and no eviction (a ceiling bypass); a zero counter is deleted.
|
|
101
99
|
- **`llm()`, `agent()`, `hive()` root as `currentBudget() ?? gateway.callLedger(budgetKeysFor(ctx.actor))`**
|
|
102
|
-
— the gateway's ceilings, keyed `actor:<kind>:<id>` / `org:<orgId>` (none for an
|
|
103
|
-
|
|
104
|
-
`Gateway`; `hive()` asks `installedGateway()` so a hive of plain actions needs none.
|
|
100
|
+
— the gateway's ceilings, keyed `actor:<kind>:<id>` / `org:<orgId>` (none for an empty org).
|
|
101
|
+
`callLedger` is required on `Gateway`; `hive()` asks `installedGateway()`.
|
|
105
102
|
- **`BudgetStore` is where `actor` and `org` live; the default is per PROCESS**
|
|
106
103
|
(`providerGateway({ budgetStore })`). `add` takes a negative `tokens` for a release — a store that
|
|
107
104
|
clamps at zero leaks the ceiling.
|
|
@@ -131,10 +128,9 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
131
128
|
- The gateway is ambient (`configureAi`); absent at call time is `X_AI_GATEWAY_MISSING`, never a
|
|
132
129
|
default provider.
|
|
133
130
|
- **`llm()` forwards `ctx.signal`** onto `GenerateRequest`; `.stream()` inherits it.
|
|
134
|
-
- **`.stream()` is the SAME action**, marked with an ambient sink:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
`respond` tool; a cache hit yields `done` alone. LAZY. `named()` is re-narrowed so the twin still streams.
|
|
131
|
+
- **`.stream()` is the SAME action**, marked with an ambient sink: UNVALIDATED text, then one `done`
|
|
132
|
+
with the validated value; no repair turn (`X_LLM_STREAM_INVALID`), no `respond` tool; budget
|
|
133
|
+
reserved before the provider, reconciled at `done`. LAZY.
|
|
138
134
|
- **`configureAi({ redact })` is the one seam between `vars()` and the provider**; a `Secret` in
|
|
139
135
|
`vars()` is `X_AI_PROMPT_SECRET` regardless. The `llm.redacted` span attribute records it.
|
|
140
136
|
- **Fallback is across PROVIDERS serving one model, never across models**; `GenerateResult.provider`
|
|
@@ -145,7 +141,7 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
145
141
|
each hold half) — never deletes, never touches a template-only closer. The
|
|
146
142
|
fences are read once at `definePrompt`; no hash moves.
|
|
147
143
|
- `definePrompt` refuses a re-registered version whose hash moved; the hash is over core's
|
|
148
|
-
`canonicalJson
|
|
144
|
+
`canonicalJson`; an absent schema hashes as `''`.
|
|
149
145
|
|
|
150
146
|
## Invariants — providers and the gateway
|
|
151
147
|
|
|
@@ -160,18 +156,14 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
160
156
|
- **The gateway's reads of a provider's throw are total**: `isRetryable` fails closed; the line goes
|
|
161
157
|
through `renderThrowable`.
|
|
162
158
|
- **`X_AI_PROVIDER_UNAVAILABLE` is the one code classified `retryable`** (`AI_ERROR_RETRY`);
|
|
163
|
-
`AiProviderUnavailableError({ unserved: true })` carries a per-instance `terminal`. Every other
|
|
164
|
-
stays UNREGISTERED on purpose.
|
|
159
|
+
`AiProviderUnavailableError({ unserved: true })` carries a per-instance `terminal`. Every other
|
|
160
|
+
code but the two agent stops stays UNREGISTERED on purpose.
|
|
165
161
|
- **The retry SCHEDULE is core's (`backoffDelay`, `isRetryableStatus`); the LOOP is this package's** —
|
|
166
|
-
core's `retry()` retries unclassified throws,
|
|
167
|
-
object, so it would retry a 400. `gateway-backoff.test.ts`. `RetryPolicy`/`DEFAULT_GATEWAY_RETRY` keep the
|
|
168
|
-
field names an app writes.
|
|
162
|
+
core's `retry()` retries unclassified throws, so it would retry a 400 (`gateway-backoff.test.ts`).
|
|
169
163
|
- Every non-2xx and every in-band `error` frame is `AiTransportError` with a real `status`.
|
|
170
164
|
- **Both wire formats answer the same question the same way** (`provider-parity.test.ts`): an in-band
|
|
171
|
-
`error` in a 200
|
|
172
|
-
|
|
173
|
-
from `detail` (`error-body.ts`'s `withoutKey`/`detailOf`). The status tables and the OpenAI
|
|
174
|
-
`estimatedUsage` fallback are asserted as differences.
|
|
165
|
+
`error` in a 200 throws (`throwInBandError`); a `refusal` detail forces `stopReason: 'refusal'`; a
|
|
166
|
+
tool call's `input` is parsed (`asToolInput`); the credential is scrubbed from `detail`.
|
|
175
167
|
- A stream ending without `message_stop` throws. **`readSse` caps the unterminated buffer**
|
|
176
168
|
(`MAX_FRAME_CHARS`); `provider` is required so a transport error names its endpoint.
|
|
177
169
|
- A tool call is emitted whole. Thinking chunks are never appended to `text`.
|
|
@@ -188,11 +180,9 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
188
180
|
the body builders, before a socket or a reservation. **Re-registering REPLACES the spec and keeps
|
|
189
181
|
its rung** (negotiated rates). **Registration order is the ladder within a `family`**, read only by
|
|
190
182
|
`moreCapableThan`, over the APP's rows; `X_LLM_REFUSED`'s fix names a rung ABOVE or nothing.
|
|
191
|
-
- **Every provider serves the app's list**: `anthropicProvider({ models })`
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
call naming none is `X_AI_MODEL_UNRESOLVED` from all three — the list is what it serves, never a
|
|
195
|
-
fourth place (`vendor-neutral.test.ts`, one parity case).
|
|
183
|
+
- **Every provider serves the app's list**: `anthropicProvider({ models })` / `openAiProvider({ models })`
|
|
184
|
+
require a non-empty one (`X_AI_REQUEST_INVALID`); `echoProvider().models` reads the registry. **No
|
|
185
|
+
provider has a model of its own**: a direct call naming none is `X_AI_MODEL_UNRESOLVED` from all three.
|
|
196
186
|
- **The reasoning controls are PER MODEL (`models.ts`)**: an unasked control is omitted; an asked
|
|
197
187
|
control the model lacks is `X_AI_REQUEST_INVALID`, never dropped. Adding a model is a row the app
|
|
198
188
|
registers. `disableThinkingUpTo: 'never'` refuses `thinking: 'disabled'` at every effort;
|
|
@@ -228,7 +218,8 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
228
218
|
|
|
229
219
|
- **`agent()` returns an action** (listed in `PRIMITIVE_FACTORIES`). `ctx.actor` is read once and is
|
|
230
220
|
the only identity any tool runs as — never taken from the model's output. Bounded by `maxTurns`
|
|
231
|
-
(`X_AGENT_MAX_TURNS`), `budget.tokensPerRun`
|
|
221
|
+
(`X_AGENT_MAX_TURNS`), `budget.tokensPerRun`, `maxToolResultChars`, `maxRepeatedToolCalls`
|
|
222
|
+
(`X_AGENT_TOOL_LOOP`) and `deadlineMs` (`X_AGENT_DEADLINE`).
|
|
232
223
|
- `tools` takes a real `action()` (`AgentTool = AnyAction | ProjectableAction`,
|
|
233
224
|
`asProjectableAction`); projection happens on the FIRST RUN, memoised. An agent can be another
|
|
234
225
|
agent's tool.
|
|
@@ -249,12 +240,11 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
249
240
|
rule a hive member's `reason` follows too. Never a second copy.
|
|
250
241
|
- **`hive()` is a fan-out action** (`PRIMITIVE_FACTORIES`): `HiveResult` is a SCHEMA from the
|
|
251
242
|
member's `output`; three arms (`ok`/`failed`/`skipped`) and three counters; `members` in SPLIT
|
|
252
|
-
order with `index`; the hive never names an actor; no hive budget code
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
unwinds with `X_ABORTED`.
|
|
243
|
+
order with `index`; the hive never names an actor; no hive budget code; a member's throw goes
|
|
244
|
+
through `discloseFailure` (`HIDDEN_MEMBER_CAUSE`);
|
|
245
|
+
`skipped` reasons `SKIPPED_ABORTED` / `SKIPPED_NO_INPUT`; `onMemberError` is required;
|
|
246
|
+
`concurrency` 4, `minMembers` 2 (a below-floor split runs serially); an empty split is
|
|
247
|
+
`X_HIVE_EMPTY`; an aborted `ctx` is `X_ABORTED`.
|
|
258
248
|
- **`describeAgents()` / `registeredModels()` are offered, not published** — their only legal consumer
|
|
259
249
|
is `@ultimat3/cli`. The facts are a THUNK; an unnamed agent has no row.
|
|
260
250
|
- **`agentJob()` composes `job()`** (never an imitation). `name`, `tenant`, `retry` are
|
|
@@ -263,16 +253,30 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
|
|
|
263
253
|
idempotent (unenforceable). **`actor`** re-resolves who it acts FOR each attempt (a served worker:
|
|
264
254
|
ANONYMOUS) via `impersonate`; off-tenant is TERMINAL `X_JOB_TENANT_MISMATCH`.
|
|
265
255
|
|
|
256
|
+
## Invariants — provider prompt caching
|
|
257
|
+
|
|
258
|
+
- **`GenerateRequest.promptCache` is the one switch; what it sends is a ROW in `PROMPT_CACHE_RULES`**
|
|
259
|
+
(wire, host, model prefix → marks, session carrier), never an `if`. An unnamed endpoint gets
|
|
260
|
+
NOTHING. At most 3 `cache_control`, a caller's own counted first (cap 4), never on a blank text
|
|
261
|
+
block; never a `provider` routing object.
|
|
262
|
+
- **`agent()` defaults `'auto'`, `llm()` `'off'`.** The stable system part (`prompt.system` +
|
|
263
|
+
`system().stable`) is sent FIRST; the mark follows the tail. The hint is not in `promptCacheKey`.
|
|
264
|
+
- **Usage**: chat-completions `prompt_tokens` INCLUDES read and written tokens; both are subtracted,
|
|
265
|
+
clamped. A budget debits all four counters; the estimate prices a WRITE only where a provider
|
|
266
|
+
would mark (`marksPromptCache`), at the dearer rate.
|
|
267
|
+
- **`maxRepeatedToolCalls` compares calls AND results over cycles of 1–3 turns**; `deadlineMs` has no
|
|
268
|
+
default and rides the request's AND each tool's signal; both stops are `terminal`
|
|
269
|
+
(`errors-retry.ts`).
|
|
270
|
+
|
|
266
271
|
## Invariants — evals, retrieval, fix lines
|
|
267
272
|
|
|
268
273
|
- **No fix line may name `x ai`** (planned). An eval is selected with `x test eval --filter <name>`;
|
|
269
274
|
`eval-errors.test.ts` asserts every `x test <word>` is one of the six types.
|
|
270
275
|
- Every eval result carries the prompt hash. An eval gates on the DROP from its recorded baseline,
|
|
271
|
-
mean AND per case, both sides rounded as the baseline is written
|
|
272
|
-
`baseline` is `import.meta.resolve('./…')`. Every prompt is named by an
|
|
273
|
-
|
|
274
|
-
`assert`; `x verify` with the flag is `X_EVAL_RECORDING` and runs no eval.
|
|
275
|
-
recorded baseline, not only a declaration.
|
|
276
|
+
mean AND per case, both sides rounded as the baseline is written (`X_EVAL_BASELINE_MISSING` /
|
|
277
|
+
`X_EVAL_BASELINE_INVALID`). `baseline` is `import.meta.resolve('./…')`. Every prompt is named by an
|
|
278
|
+
eval (`X_EVAL_MISSING`). `ULTIMATE_EVAL_RECORD=1` records; a deliberately-worse test calls `run`,
|
|
279
|
+
never `assert`; `x verify` with the flag is `X_EVAL_RECORDING` and runs no eval.
|
|
276
280
|
- Retrieval is hybrid by default; no vector-only path. **`chunk()` hard-wraps**, the overlap
|
|
277
281
|
carry stops at `buffer.length - 1` and yields to a next unit it does not fit beside (no chunk over
|
|
278
282
|
`size`); `metadata.source` is written AFTER the caller's spread. `VectorStore.prune` is required.
|
package/README.md
CHANGED
|
@@ -435,10 +435,81 @@ sub-agent in its own `tools` and the sub-agent runs under the same actor, throug
|
|
|
435
435
|
| `onTurn` reports each completed turn as it happens (and an `agent.turn` span event, always) | a 90-second run emitted nothing until it returned. Observation only — it cannot steer the loop, see the transcript or reach the actor — and a throw from it fails the run rather than being swallowed |
|
|
436
436
|
| a tool that is not `mcp: { expose: true }` is `X_AGENT_TOOL_UNEXPOSED` **at declaration** | a silently dropped tool reads as offered and is not; `isMcpExposed` is the one predicate, so an in-app agent and an external MCP client see the same catalogue |
|
|
437
437
|
| 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 |
|
|
438
|
+
| a cycle of 1 to 3 tool turns — same calls, arguments, results and failures — gone round `maxRepeatedToolCalls` (default 3) times is `X_AGENT_TOOL_LOOP` | such a turn can only be followed by another like it; a changed result is progress; a repair turn neither counts nor resets |
|
|
439
|
+
| `deadlineMs` (no default) is the run's wall-clock ceiling: `X_AGENT_DEADLINE` | the in-flight model call is aborted on the socket and every tool's `ctx.signal` fires; a tool that ignores it still finishes late. A caller that leaves first is still `X_ABORTED` |
|
|
440
|
+
| both stops are registered `terminal` | an `agentJob()` dead-letters on attempt one instead of buying the same run per attempt |
|
|
441
|
+
| every turn asks the provider to cache its prefix (`promptCache`, default `'auto'`) | the transcript IS the request, so an uncached run pays for its stable prefix once per turn — [below](#provider-prompt-caching) |
|
|
438
442
|
| `budget.tokensPerRun` caps the **whole run** | a single call is bounded by `maxTokens`; a loop is bounded by nothing until this is set |
|
|
439
443
|
| a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
|
|
440
444
|
| **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
|
|
441
445
|
|
|
446
|
+
### Provider prompt caching
|
|
447
|
+
|
|
448
|
+
Not the gateway's response cache and not `llm()`'s semantic one: this asks the **provider** to keep
|
|
449
|
+
a request's prefix, so the next request reads it at the cache rate.
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
import { agent, definePrompt, t } from '@ultimat3/ai';
|
|
453
|
+
import { can } from '@ultimat3/policy';
|
|
454
|
+
|
|
455
|
+
const kbPrompt = definePrompt<{ question: string }>({
|
|
456
|
+
id: 'kb',
|
|
457
|
+
version: '1.0.0',
|
|
458
|
+
system: 'Answer from the knowledge base only.', // starts the stable block
|
|
459
|
+
template: 'Question: {{question}}',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
export const kbAgent = agent({
|
|
463
|
+
input: t.object({ question: t.string, index: t.string }),
|
|
464
|
+
output: t.object({ answer: t.string }),
|
|
465
|
+
prompt: kbPrompt,
|
|
466
|
+
vars: ({ input }) => ({ question: input.question }),
|
|
467
|
+
// `stable` joins prompt.system in the block every run shares; `run` follows it
|
|
468
|
+
system: ({ input }) => ({ stable: input.index, run: `Question length: ${input.question.length}` }),
|
|
469
|
+
promptCache: { session: 'kb-agent' }, // 'auto' (default) | 'off' | { session }
|
|
470
|
+
tools: [],
|
|
471
|
+
policy: can('kb:ask'),
|
|
472
|
+
onTurn: ({ usage, run }) => {
|
|
473
|
+
console.log(usage.cacheReadTokens, usage.cacheWriteTokens, run.cost.minor);
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
| Shape | Meaning |
|
|
479
|
+
|---|---|
|
|
480
|
+
| `GenerateRequest.system: string \| { stable, run? }` | `stable` is byte-identical across runs and is sent first, as its own block; `run` follows it |
|
|
481
|
+
| `GenerateRequest.promptCache?: { session? }` | present: mark the stable system part and the transcript's tail, per `PROMPT_CACHE_RULES`. Absent: nothing is marked |
|
|
482
|
+
| `agent({ promptCache })` | default `'auto'` — every turn carries the hint |
|
|
483
|
+
| `llm({ promptCache })` | default `'off'` — a one-shot call would pay the write premium for a cache nothing reads |
|
|
484
|
+
| `AgentTurn.usage` / `AgentTurn.run` | per turn, and the running total: `cacheReadTokens`, `cacheWriteTokens`, and `run.cost` priced once from the summed usage |
|
|
485
|
+
|
|
486
|
+
`PROMPT_CACHE_RULES` (`src/prompt-cache.ts`, sources quoted in its header), first match wins:
|
|
487
|
+
|
|
488
|
+
| Wire | Host | Model prefix | Marks | `session` |
|
|
489
|
+
|---|---|---|---|---|
|
|
490
|
+
| Anthropic Messages | any | any | stable system block + last block of the last two messages (3 of 4 breakpoints) | — |
|
|
491
|
+
| chat completions | `openrouter.ai` | `anthropic/` | stable system part + the tail message's last part (2) | `x-session-id` header |
|
|
492
|
+
| chat completions | `openrouter.ai` | any other | none (implicit caching; a mark on Gemini is a paid write) | `x-session-id` header |
|
|
493
|
+
| chat completions | `api.openai.com` | any | none (automatic) | `prompt_cache_key` |
|
|
494
|
+
| chat completions | anything else | any | none | — |
|
|
495
|
+
|
|
496
|
+
Usage: the chat-completions reader takes `prompt_tokens_details.cached_tokens` and
|
|
497
|
+
`.cache_write_tokens` out of `prompt_tokens` (which includes both) and reports them as
|
|
498
|
+
`cacheReadTokens` / `cacheWriteTokens`; a missing field is zero. `costOf` prices them at the model
|
|
499
|
+
row's `cacheReadPerMillion` / `cacheWritePerMillion` (absent: 0.1x / 1.25x input). A token budget
|
|
500
|
+
counts all four counters, one token each. The gateway's pre-flight estimate prices the prompt as a
|
|
501
|
+
write only when a provider serving the model would mark it (`Provider.marksPromptCache`), at the
|
|
502
|
+
dearer of the write and input rates. No `provider` routing object and no `usage: { include }` is
|
|
503
|
+
ever sent.
|
|
504
|
+
|
|
505
|
+
| Limit | Detail |
|
|
506
|
+
|---|---|
|
|
507
|
+
| `run.cost` vs the ledger | `AgentTurn.run.cost` is summed usage priced once; `gateway.spent()` adds each turn's cost, rounded up per turn (input and output halves), so it can be higher by under 2 minor units per turn. Budgets are enforced on the ledger; report `run.cost` |
|
|
508
|
+
| the Anthropic row matches any host | an `anthropicProvider({ baseUrl })` behind a proxy still sends marks |
|
|
509
|
+
| more than about 9 tool calls in one turn, on OpenRouter | only the tail is marked there and the provider looks back 20 blocks, so that turn's transcript is rewritten, not read |
|
|
510
|
+
| a mark never lands on an empty or whitespace-only text block, and never makes a fifth | it moves back a block or is dropped; a raw caller's own marks are counted first |
|
|
511
|
+
| `session` | OpenRouter header: 1 to 256 printable ASCII, else `X_AI_REQUEST_INVALID`. OpenAI `prompt_cache_key`: over 64 characters it is sent as `h:` + 62 hex of its SHA-256 |
|
|
512
|
+
|
|
442
513
|
## `hive()` — many members, one action
|
|
443
514
|
|
|
444
515
|
Fan an action out over many inputs. A factory over a primitive, like `llm()`, `backfill()` and
|
|
@@ -883,6 +954,8 @@ fail-closed `terminal` default.
|
|
|
883
954
|
| `X_LLM_STREAM_INVALID` | a streamed answer failed its schema, and a stream cannot take a repair turn |
|
|
884
955
|
| `X_AGENT_MAX_TURNS` | an `agent()` used every turn without answering |
|
|
885
956
|
| `X_AGENT_TOOL_UNEXPOSED` | an `agent()` lists an action no MCP surface exposes |
|
|
957
|
+
| `X_AGENT_TOOL_LOOP` | an `agent()` made the same tool calls and read the same results `maxRepeatedToolCalls` turns in a row |
|
|
958
|
+
| `X_AGENT_DEADLINE` | an `agent()` outlived its declared `deadlineMs` |
|
|
886
959
|
| `X_EVAL_THRESHOLD` | an eval scored below its bar |
|
|
887
960
|
| `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
|
|
888
961
|
| `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
|
|
@@ -896,7 +969,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
896
969
|
|
|
897
970
|
| Class | Code | Declared in |
|
|
898
971
|
|---|---|---|
|
|
972
|
+
| `AgentDeadlineError` | `X_AGENT_DEADLINE` | `src/agent-errors.ts` |
|
|
899
973
|
| `AgentMaxTurnsError` | `X_AGENT_MAX_TURNS` | `src/errors.ts` |
|
|
974
|
+
| `AgentToolLoopError` | `X_AGENT_TOOL_LOOP` | `src/agent-errors.ts` |
|
|
900
975
|
| `AgentToolUnexposedError` | `X_AGENT_TOOL_UNEXPOSED` | `src/errors.ts` |
|
|
901
976
|
| `AiBudgetExceededError` | `X_AI_BUDGET_EXCEEDED` | `src/errors.ts` |
|
|
902
977
|
| `AiContentUnsupportedError` | `X_AI_CONTENT_UNSUPPORTED` | `src/content-errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.9.0",
|
|
4
4
|
"description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/action": "27.
|
|
36
|
-
"@ultimat3/cache": "27.
|
|
37
|
-
"@ultimat3/core": "27.
|
|
38
|
-
"@ultimat3/db": "27.
|
|
39
|
-
"@ultimat3/http": "27.
|
|
40
|
-
"@ultimat3/jobs": "27.
|
|
41
|
-
"@ultimat3/money": "27.
|
|
42
|
-
"@ultimat3/policy": "27.
|
|
43
|
-
"@ultimat3/schema": "27.
|
|
44
|
-
"@ultimat3/time": "27.
|
|
35
|
+
"@ultimat3/action": "27.9.0",
|
|
36
|
+
"@ultimat3/cache": "27.9.0",
|
|
37
|
+
"@ultimat3/core": "27.9.0",
|
|
38
|
+
"@ultimat3/db": "27.9.0",
|
|
39
|
+
"@ultimat3/http": "27.9.0",
|
|
40
|
+
"@ultimat3/jobs": "27.9.0",
|
|
41
|
+
"@ultimat3/money": "27.9.0",
|
|
42
|
+
"@ultimat3/policy": "27.9.0",
|
|
43
|
+
"@ultimat3/schema": "27.9.0",
|
|
44
|
+
"@ultimat3/time": "27.9.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/agent-def.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Single responsibility: what an `agent()` DECLARATION is — its options, and the one event its
|
|
2
|
+
// observer is handed. Types only; ./agent owns the loop that reads them.
|
|
3
|
+
|
|
4
|
+
import type { ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AgentSystem } from './agent-system';
|
|
9
|
+
import type { AgentRunUsage } from './agent-usage';
|
|
10
|
+
import type { LlmBudget } from './llm';
|
|
11
|
+
import type { ModelId } from './models';
|
|
12
|
+
import type { Prompt, PromptVars } from './prompt';
|
|
13
|
+
import type { PromptCacheOption } from './prompt-cache';
|
|
14
|
+
import type { StopReason, TokenUsage } from './provider';
|
|
15
|
+
import type { AgentTool } from './tools';
|
|
16
|
+
|
|
17
|
+
export interface AgentBudget extends LlmBudget {
|
|
18
|
+
/**
|
|
19
|
+
* Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
|
|
20
|
+
* a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
|
|
21
|
+
*/
|
|
22
|
+
readonly tokensPerRun?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
|
|
26
|
+
readonly input: InferOutput<TInput>;
|
|
27
|
+
readonly ctx: Ctx;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AgentDef<
|
|
31
|
+
TInput extends StandardSchemaV1,
|
|
32
|
+
TOutput extends StandardSchemaV1,
|
|
33
|
+
V extends PromptVars,
|
|
34
|
+
> {
|
|
35
|
+
readonly model?: ModelId;
|
|
36
|
+
readonly input: TInput;
|
|
37
|
+
readonly output: TOutput;
|
|
38
|
+
readonly prompt: Prompt<V>;
|
|
39
|
+
/** Same contract as `llm()`: the one declared place a run loads data. */
|
|
40
|
+
vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
|
|
41
|
+
/**
|
|
42
|
+
* System text loaded at RUN time, beside the prompt's static `system`: a knowledge-base index,
|
|
43
|
+
* today's date. Redacted like the prompt. Not part of the prompt's hash, exactly as `vars` is not.
|
|
44
|
+
*/
|
|
45
|
+
system?(args: AgentVarsArgs<TInput>): AgentSystem | Promise<AgentSystem>;
|
|
46
|
+
/**
|
|
47
|
+
* Provider prompt caching. Default `'auto'`: every turn's request asks the provider to cache its
|
|
48
|
+
* prefix (the stable system block, then the transcript up to its tail), so turn N+1 reads turn
|
|
49
|
+
* N's prefix at the cache rate instead of paying input for it again. `'off'` sends no mark.
|
|
50
|
+
* `{ session }` is `'auto'` plus a key for hosts that route by one. `PROMPT_CACHE_RULES` decides
|
|
51
|
+
* what each wire is sent; a host that caches implicitly is sent nothing either way.
|
|
52
|
+
*/
|
|
53
|
+
readonly promptCache?: PromptCacheOption<AgentVarsArgs<TInput>>;
|
|
54
|
+
/**
|
|
55
|
+
* The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
|
|
56
|
+
* external MCP client is filtered by, so an in-app agent and an external one are offered
|
|
57
|
+
* exactly the same tools. Listing one that is not exposed is refused at declaration rather than
|
|
58
|
+
* dropped, because a tool that reads as offered and silently is not is the worst of both.
|
|
59
|
+
*/
|
|
60
|
+
readonly tools: readonly AgentTool[];
|
|
61
|
+
/** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
|
|
62
|
+
readonly maxTurns?: number;
|
|
63
|
+
readonly maxToolResultChars?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Consecutive turns that may make the SAME tool calls and read the SAME results. Reaching it is
|
|
66
|
+
* `X_AGENT_TOOL_LOOP`. Default 3.
|
|
67
|
+
*/
|
|
68
|
+
readonly maxRepeatedToolCalls?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Wall-clock ceiling for the whole run, in milliseconds. Passing it aborts the model call in
|
|
71
|
+
* flight and is `X_AGENT_DEADLINE`. No default: a timeout nobody declared is a lost answer.
|
|
72
|
+
*/
|
|
73
|
+
readonly deadlineMs?: number;
|
|
74
|
+
readonly budget?: AgentBudget;
|
|
75
|
+
readonly policy: ActionPolicy;
|
|
76
|
+
readonly mcp?: ActionMcp;
|
|
77
|
+
/** Enforced completion ceiling PER TURN. The model never sees it. */
|
|
78
|
+
readonly maxTokens?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Called once per completed model turn, before the answer or the tool calls are acted on. The
|
|
81
|
+
* one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
|
|
82
|
+
* it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
|
|
83
|
+
* hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
|
|
84
|
+
*
|
|
85
|
+
* Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
|
|
86
|
+
* actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
|
|
87
|
+
* run's own path, and an observer that silently stopped working would be indistinguishable from
|
|
88
|
+
* one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
|
|
89
|
+
* declares no hook is still readable in a trace.
|
|
90
|
+
*
|
|
91
|
+
* NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
|
|
92
|
+
* one turn deep by construction; this is per TURN.
|
|
93
|
+
*/
|
|
94
|
+
onTurn?(event: AgentTurn): void | Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
|
|
98
|
+
export interface AgentTurn {
|
|
99
|
+
/** 1-based, so `turn === maxTurns` is the last one this run will take. */
|
|
100
|
+
readonly turn: number;
|
|
101
|
+
readonly maxTurns: number;
|
|
102
|
+
readonly model: ModelId;
|
|
103
|
+
/** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
|
|
104
|
+
readonly toolCalls: readonly string[];
|
|
105
|
+
readonly stopReason: StopReason;
|
|
106
|
+
readonly usage: TokenUsage;
|
|
107
|
+
/** This turn's cost alone, integer minor units — never the run's running total. */
|
|
108
|
+
readonly cost: Money;
|
|
109
|
+
/** The running total, this turn included — on the last turn, the whole run. */
|
|
110
|
+
readonly run: AgentRunUsage;
|
|
111
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The two X_AGENT_* codes an UNATTENDED run needs, apart from ./errors only because one file has
|
|
2
|
+
// one job and that catalogue is at its ceiling — the split `hive-errors.ts` made. The codes, their
|
|
3
|
+
// titles and the single `registerErrorCodes` call stay in ./errors.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The loop asked for the same tool calls and read the same results, turn after turn.
|
|
9
|
+
*
|
|
10
|
+
* Call AND result, never the call alone: polling one tool with one argument is legitimate while
|
|
11
|
+
* what comes back keeps changing, and it stops being progress the moment it does not. A turn that
|
|
12
|
+
* repeats both can only be followed by a turn that repeats both, so every further turn buys the
|
|
13
|
+
* same non-answer over a transcript that is one turn longer.
|
|
14
|
+
*/
|
|
15
|
+
export class AgentToolLoopError extends UltimateError {
|
|
16
|
+
constructor(input: {
|
|
17
|
+
agent: string;
|
|
18
|
+
/** Laps of the cycle. */
|
|
19
|
+
repeats: number;
|
|
20
|
+
/** Turns in one lap: 1 is the same turn over and over, 2 is A,B,A,B. */
|
|
21
|
+
cycle: number;
|
|
22
|
+
tools: readonly string[];
|
|
23
|
+
}) {
|
|
24
|
+
super({
|
|
25
|
+
code: 'X_AGENT_TOOL_LOOP',
|
|
26
|
+
cause:
|
|
27
|
+
input.cycle === 1
|
|
28
|
+
? `agent "${input.agent}" made the same tool calls (${input.tools.join(', ')}) and read the same results ${input.repeats} turns in a row`
|
|
29
|
+
: `agent "${input.agent}" went round the same ${input.cycle} tool turns (${input.tools.join(', ')}), reading the same results, ${input.repeats} times in a row`,
|
|
30
|
+
fix: 'maxRepeatedToolCalls: 5 # on the agent, ONLY for a tool that is polled and answers identically while it waits — otherwise tell the template what to do when a tool result does not change, then bump its version',
|
|
31
|
+
meta: { agent: input.agent, repeats: input.repeats, cycle: input.cycle, tools: input.tools },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The run was still going when its declared `deadlineMs` elapsed. Distinct from `X_ABORTED` — the
|
|
38
|
+
* caller did not leave, the declaration ran out — and from `X_AGENT_MAX_TURNS`, because the fix is
|
|
39
|
+
* about wall clock: a slow tool or a slow model, not a loop with no exit.
|
|
40
|
+
*/
|
|
41
|
+
export class AgentDeadlineError extends UltimateError {
|
|
42
|
+
constructor(input: { agent: string; deadlineMs: number }) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_AGENT_DEADLINE',
|
|
45
|
+
cause: `agent "${input.agent}" was still running after its ${input.deadlineMs}ms deadline; whatever was in flight (setup, a model call or a tool) was signalled to stop, and no further turn or tool was started`,
|
|
46
|
+
fix: 'deadlineMs: 300_000 # on the agent, only once a trace shows the run converging — otherwise lower maxTurns, or shorten the slow tool the agent.turn span events name',
|
|
47
|
+
meta: { agent: input.agent, deadlineMs: input.deadlineMs },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Single responsibility: the two guards that let an `agent()` run with nobody watching — the
|
|
2
|
+
// repeated-turn detector and the per-run deadline. Apart from ./agent so the loop reads as a loop.
|
|
3
|
+
|
|
4
|
+
import { fingerprint, withChildContext } from '@ultimat3/core';
|
|
5
|
+
import { AgentDeadlineError, AgentToolLoopError } from './agent-errors';
|
|
6
|
+
import type { LlmToolCall, LlmToolResult } from './tools';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Consecutive identical turns an agent may take when the declaration omits a number. Three: one
|
|
10
|
+
* repeat is a model re-checking, two is a model that has not read the answer, and nothing a third
|
|
11
|
+
* identical turn returns can make the fourth differ.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_MAX_REPEATED_TOOL_CALLS = 3;
|
|
14
|
+
|
|
15
|
+
/** The longest cycle looked for: A,B,C,A,B,C. A longer one is `maxTurns`' to stop. */
|
|
16
|
+
const MAX_CYCLE = 3;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Called once per TOOL turn, with what the model asked for and what it was told. Throws
|
|
20
|
+
* `X_AGENT_TOOL_LOOP` when the most recent tool turns are one cycle of up to `MAX_CYCLE` turns
|
|
21
|
+
* gone round `limit` times — the same turn `limit` times, or A,B,A,B… — where "the same" is the
|
|
22
|
+
* calls, their arguments, the results AND whether each failed. A changed result is progress and
|
|
23
|
+
* breaks the cycle. A fingerprint, not the text: one result can be megabytes before truncation.
|
|
24
|
+
*
|
|
25
|
+
* A repair turn (a wrong-shaped answer; no tool ran) is not a tool turn, so it neither counts nor
|
|
26
|
+
* resets: the correction is about the answer's shape and tells the model nothing new about a tool.
|
|
27
|
+
*/
|
|
28
|
+
export function toolLoopGuard(
|
|
29
|
+
agent: string,
|
|
30
|
+
limit: number,
|
|
31
|
+
): (calls: readonly LlmToolCall[], results: readonly LlmToolResult[]) => void {
|
|
32
|
+
const window = MAX_CYCLE * limit;
|
|
33
|
+
let recent: readonly { readonly shape: string; readonly tools: readonly string[] }[] = [];
|
|
34
|
+
return (calls, results) => {
|
|
35
|
+
const shape = fingerprint({
|
|
36
|
+
calls: calls.map((call) => [call.name, call.input]),
|
|
37
|
+
results: results.map((result) => [result.content, result.isError === true]),
|
|
38
|
+
});
|
|
39
|
+
recent = [...recent, { shape, tools: calls.map((call) => call.name) }].slice(-window);
|
|
40
|
+
for (let cycle = 1; cycle <= MAX_CYCLE; cycle += 1) {
|
|
41
|
+
const laps = recent.slice(-cycle * limit);
|
|
42
|
+
if (laps.length < cycle * limit) break;
|
|
43
|
+
const repeats = (turn: (typeof laps)[number], index: number): boolean =>
|
|
44
|
+
index < cycle || isSameTurn(turn.shape, laps[index - cycle]?.shape);
|
|
45
|
+
if (!laps.every(repeats)) continue;
|
|
46
|
+
const tools = [...new Set(laps.slice(0, cycle).flatMap((turn) => turn.tools))];
|
|
47
|
+
throw new AgentToolLoopError({ agent, repeats: limit, cycle, tools });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isSameTurn = (one: string, other: string | undefined): boolean => one === other;
|
|
53
|
+
|
|
54
|
+
export interface RunDeadline {
|
|
55
|
+
/** The caller's signal and the deadline as one — what every provider call is handed. */
|
|
56
|
+
readonly signal: AbortSignal;
|
|
57
|
+
/** Throws `X_AGENT_DEADLINE` once the deadline has passed. Called before each turn and batch. */
|
|
58
|
+
check(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Await `work`, but no longer than the deadline, and report a failure the deadline caused AS the
|
|
61
|
+
* deadline: the aborted socket surfaces as a transport error, which would otherwise read as the
|
|
62
|
+
* provider's fault. Work that outlives the race is left to finish — a tool has no signal to hear.
|
|
63
|
+
*/
|
|
64
|
+
guard<T>(work: Promise<T>): Promise<T>;
|
|
65
|
+
/**
|
|
66
|
+
* Run `fn` with the deadline on the ambient `ctx.signal`, so a tool — an `action()` reading its
|
|
67
|
+
* own `ctx.signal`, a `fetch(url, { signal })` inside it — hears the deadline and can stop. A
|
|
68
|
+
* tool that never reads the signal still runs to its own end after the run has been refused.
|
|
69
|
+
*/
|
|
70
|
+
scope<T>(fn: () => T): T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `ms` undefined is no deadline: the caller's signal, and two no-ops. */
|
|
74
|
+
export function runDeadline(
|
|
75
|
+
agent: string,
|
|
76
|
+
ms: number | undefined,
|
|
77
|
+
caller: AbortSignal,
|
|
78
|
+
): RunDeadline {
|
|
79
|
+
if (ms === undefined) {
|
|
80
|
+
return { signal: caller, check: () => {}, guard: (work) => work, scope: (fn) => fn() };
|
|
81
|
+
}
|
|
82
|
+
const timer = AbortSignal.timeout(ms);
|
|
83
|
+
const expired = (): AgentDeadlineError => new AgentDeadlineError({ agent, deadlineMs: ms });
|
|
84
|
+
// The caller leaving first stays `X_ABORTED`: it is the truer account of why the run stopped.
|
|
85
|
+
const byDeadline = (): boolean => timer.aborted && !caller.aborted;
|
|
86
|
+
return {
|
|
87
|
+
signal: AbortSignal.any([caller, timer]),
|
|
88
|
+
scope: (fn) => withChildContext({ signal: timer }, fn),
|
|
89
|
+
check: () => {
|
|
90
|
+
if (byDeadline()) throw expired();
|
|
91
|
+
},
|
|
92
|
+
guard: async (work) => {
|
|
93
|
+
if (byDeadline()) {
|
|
94
|
+
// Abandoned before it was awaited; its own rejection must not go unhandled.
|
|
95
|
+
work.catch(() => {});
|
|
96
|
+
throw expired();
|
|
97
|
+
}
|
|
98
|
+
let stop = (): void => {};
|
|
99
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
100
|
+
stop = () => reject(expired());
|
|
101
|
+
timer.addEventListener('abort', stop, { once: true });
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
return await Promise.race([work, timeout]);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
work.catch(() => {});
|
|
107
|
+
if (byDeadline()) throw expired();
|
|
108
|
+
throw error;
|
|
109
|
+
} finally {
|
|
110
|
+
timer.removeEventListener('abort', stop);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|