@ultimat3/ai 27.13.2 → 27.14.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 +9 -10
- package/README.md +52 -0
- package/package.json +11 -11
- package/src/agent-compact-def.ts +104 -0
- package/src/agent-compact.ts +264 -0
- package/src/agent-continue.ts +16 -1
- package/src/agent-def.ts +15 -0
- package/src/agent-errors.ts +32 -0
- package/src/agent.ts +49 -11
- package/src/errors-retry.ts +3 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +2 -0
package/CLAUDE.md
CHANGED
|
@@ -65,6 +65,7 @@ one adds, the other adds.
|
|
|
65
65
|
| `prompt-cache.ts` | PROVIDER prompt caching: `SystemParts`, `PromptCacheHint`, `PROMPT_CACHE_RULES` (sources in its header) |
|
|
66
66
|
| `prompt-cache-blocks.ts` | the Anthropic wire's marks (chat-completions': `openai-messages.ts`) |
|
|
67
67
|
| `agent-per-run.ts` / `agent-run-limits.ts` | one run's `offerTools()`/`model()`/`limits()`, narrow only; `costPerRun` |
|
|
68
|
+
| `agent-compact-def.ts` / `agent-compact.ts` | `compact`'s declaration and event; the trigger, the cut, the summary call |
|
|
68
69
|
| `agent-continue.ts` / `agent-turn.ts` | `.run()` / `.continue()`, the transcript handle and its screen; one turn's report and refusals |
|
|
69
70
|
| `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
|
|
70
71
|
| `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish (its consumer has to be `cli`, tier 5) |
|
|
@@ -245,6 +246,10 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
|
|
|
245
246
|
cursor secret, own label) and bound to agent, model, tool names and `orgId`; screened before
|
|
246
247
|
anything is loaded. `maxTurns`/`tokensPerRun` are cumulative — the remainder is the ceiling,
|
|
247
248
|
NEVER a ledger debit. A failure seals an updated handle onto the error (`transcriptOf`).
|
|
249
|
+
- **`compact` (default `'auto'`) acts BEFORE a turn, never after a 400**: the summary call is the
|
|
250
|
+
run's own request up to a cut BETWEEN turns (the cached prefix, same session), billed, not a turn;
|
|
251
|
+
system, tools, kept turns byte-identical; failed → whole, or `X_AGENT_CONTEXT_EXHAUSTED`, never
|
|
252
|
+
truncated ([why](../../docs/history/ai.md#auto-compaction-2026-10)).
|
|
248
253
|
- **`hive()` is a fan-out action** (`PRIMITIVE_FACTORIES`): `HiveResult` is a SCHEMA from the
|
|
249
254
|
member's `output`; three arms (`ok`/`failed`/`skipped`) and three counters; `members` in SPLIT
|
|
250
255
|
order with `index`; the hive never names an actor; no hive budget code; a member's throw goes
|
|
@@ -277,16 +282,10 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
|
|
|
277
282
|
|
|
278
283
|
## Invariants — evals, retrieval, fix lines
|
|
279
284
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
(`X_EVAL_MISSING`); `ULTIMATE_EVAL_RECORD=1` records (`x verify` with it: `X_EVAL_RECORDING`).
|
|
285
|
-
- Retrieval is hybrid, no vector-only path; `chunk()` hard-wraps; `VectorStore.prune` is required.
|
|
286
|
-
- **An unbound store read inside a request whose actor has an `orgId` is `X_VECTOR_UNSCOPED`**
|
|
287
|
-
(both stores, every read); `scope: UNSCOPED` is the named opt-in, an omitted scope is `UNBOUND`.
|
|
288
|
-
- `postgresVectorStore()` is the only production vector path (the live suite never skips); tenant
|
|
289
|
-
and policy filters in SQL on every statement and BOTH fusion halves; `scoped()` only TIGHTENS.
|
|
285
|
+
In brief ([full text](../../docs/history/ai.md#evals-retrieval-fix-lines)): no fix line names
|
|
286
|
+
`x ai`; an eval gates on the DROP from its baseline, mean AND per case, and every prompt has one;
|
|
287
|
+
retrieval is hybrid only; an unbound store read under an org actor is `X_VECTOR_UNSCOPED`;
|
|
288
|
+
`postgresVectorStore()` filters tenant and policy in SQL on every statement; `scoped()` TIGHTENS.
|
|
290
289
|
|
|
291
290
|
## Commands
|
|
292
291
|
|
package/README.md
CHANGED
|
@@ -445,6 +445,7 @@ sub-agent in its own `tools` and the sub-agent runs under the same actor, throug
|
|
|
445
445
|
| `budget.costPerRun` caps the run's **money**: before each turn, what the run cost so far (continuations and nested calls included) plus that turn's worst-case estimate | `costPerCall` bounds one call and nothing bounded their sum; refused pre-flight as `X_AI_BUDGET_EXCEEDED` (scope `costPerRun`), never truncated |
|
|
446
446
|
| one run may **narrow** its declaration, never widen it: `offerTools()` a subset of `tools` (another name is `X_AGENT_TOOL_UNDECLARED`), `model` as a function (resolved through the registry, `X_AI_MODEL_UNKNOWN`), `limits()` lower ceilings (a higher one is `X_AI_REQUEST_INVALID`) | a stored permission rule or model tier needed one declared agent per combination; the declaration stays the blast radius, and the handle binds the run's model and offered subset — [Agents → Per-run picks](https://github.com/developerz-ai/ultimate/wiki/Agents#per-run-picks-offertools-model-limits) |
|
|
447
447
|
| a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
|
|
448
|
+
| a run nearing its model's context window is **compacted** before the turn that would not fit (`compact`, default `'auto'`) | the transcript IS the request, so a long loop grew until the provider answered a 400 after every turn of it was paid for — [below](#auto-compaction) |
|
|
448
449
|
| **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
|
|
449
450
|
|
|
450
451
|
### What a call cost
|
|
@@ -455,6 +456,55 @@ sub-agent in its own `tools` and the sub-agent runs under the same actor, throug
|
|
|
455
456
|
|
|
456
457
|
`agent()` returns an `AgentAction`: the action, plus `.run(input, opts)` → `{ output, transcript, turns, usage }` and `.continue(input, { transcript, user }, opts)`, which appends one user turn to a stored transcript and resumes the loop. The transcript is re-sent byte for byte (the provider's prompt cache reads it back; no tool re-runs); `maxTurns` and `budget.tokensPerRun` are cumulative; the handle is signed, bound to the agent, model, tools and tenant, and screened (`X_AGENT_TRANSCRIPT_INVALID`); a failed call leaves an updated one on the error (`transcriptOf(error)`), which is the one a job stores. Full contract: [Agents → Continuing a run](https://github.com/developerz-ai/ultimate/wiki/Agents#continuing-a-run-run--continue).
|
|
457
458
|
|
|
459
|
+
### Auto-compaction
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { agent, definePrompt, t } from '@ultimat3/ai';
|
|
463
|
+
import { can } from '@ultimat3/policy';
|
|
464
|
+
|
|
465
|
+
export const researcher = agent({
|
|
466
|
+
input: t.object({ question: t.string }),
|
|
467
|
+
output: t.object({ answer: t.string }),
|
|
468
|
+
prompt: definePrompt<{ question: string }>({
|
|
469
|
+
id: 'research',
|
|
470
|
+
version: '1.0.0',
|
|
471
|
+
template: 'Research: {{question}}',
|
|
472
|
+
}),
|
|
473
|
+
vars: ({ input }) => ({ question: input.question }),
|
|
474
|
+
tools: [],
|
|
475
|
+
policy: can('kb:ask'),
|
|
476
|
+
compact: { at: 0.8, keepTurns: 4, maxSummaryTokens: 2_048 }, // 'auto' (default) | 'off' | { …, model }
|
|
477
|
+
onCompact: ({ turn, outcome, reason, before, after, limit, summarised, kept, cost }) => {
|
|
478
|
+
console.log(turn, outcome, reason, before, after, limit, summarised, kept, cost?.minor);
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Before each turn, the gateway's estimate of that turn's request is compared with the model row's
|
|
484
|
+
`contextWindow` less `maxTokens`. Over `at` of it, ONE model call summarises every turn older than
|
|
485
|
+
the last `keepTurns`; the summary joins the first user message (after the prompt, verbatim, marked
|
|
486
|
+
`[compacted: …]`), the kept turns follow byte for byte, and the turn is sent.
|
|
487
|
+
|
|
488
|
+
| Rule | Why |
|
|
489
|
+
|---|---|
|
|
490
|
+
| default **`'auto'`**; `'off'` never compacts and never checks | a summary changes what the model sees, but only above `at` of the window, where the alternative is a run lost to a 400 after every turn was paid for. Below it a run is byte-identical to `'off'` — requests and handle — and it is never silent: `onCompact`, the `agent.compact` span event, the handle's `compacted` |
|
|
491
|
+
| triggered on the **estimate of the next request** — the gateway's prompt estimate plus the tool definitions — over `at` × (`contextWindow` − `maxTokens`), before the call | after a 400 there is no request left to fix; `maxTokens` is reserved because the completion shares the window |
|
|
492
|
+
| the system prompt, its stable block and the tools are **never touched** | a provider cache is a prefix match over tools → system → messages: they are the part every turn and every run shares, so they are still read at the cache rate after a compaction. Only the messages are rewritten, once |
|
|
493
|
+
| the summary call is the run's **own request** up to the cut, plus one instruction — same gateway, tools, system and `promptCache` hint | it reads the prefix the turn that ended at the cut already cached instead of paying for it again; on OpenRouter the same `x-session-id` routes it to the provider holding that cache (`compact.model` trades that read for a cheaper writer) |
|
|
494
|
+
| the cut falls **between turns**: the prompt and the last `keepTurns` (4, at least 1) turns are verbatim | a turn is an assistant message and the user message answering every `tool_use` in it, so no call is ever separated from its result (a 400 on every provider); the model keeps its task as written and the results it just asked for |
|
|
495
|
+
| it summarises what the model was **sent** — tool results already redacted and cut to `maxToolResultChars` | the summary call reads nothing the run's turns did not; a later compaction folds the earlier summary in rather than stacking another |
|
|
496
|
+
| the summary call is **billed to the run**: the same ledger (`tokensPerRun`), `costPerRun` checked before it, `usage`, `onTurn`'s `run`, `meta.billed`; priced at its own model's rates | it is spend like any other; a budget refusal, a deadline or an abort is thrown as itself, never read as a failed compaction |
|
|
497
|
+
| it is **not a turn** — `maxTurns` and `onTurn` count the loop alone | the turn ceiling bounds the loop's decisions, and a summary is not one |
|
|
498
|
+
| a failed summary (no text, cut off at `maxSummaryTokens`, refused, unreachable, no shorter) leaves the run **whole while it fits**; over the room it is **`X_AGENT_CONTEXT_EXHAUSTED`**, before the request is sent, registered `terminal` | never a truncated transcript — a model reasoning over a run it can no longer see answers confidently and wrong; and the same input outgrows the window on every job attempt |
|
|
499
|
+
| after a failed attempt the next waits until the request **no longer fits** | each attempt is a billed call: one that just failed is not bought again on every turn the run can still take whole |
|
|
500
|
+
| the handle stores the **compacted** messages and a signed `compacted` count, absent at zero | `.continue()` resumes from exactly what was sent, a continuation compacts on its own turns, and a run that never compacted writes the handle it always did |
|
|
501
|
+
|
|
502
|
+
| Limit | Detail |
|
|
503
|
+
|---|---|
|
|
504
|
+
| the estimate | ~4 characters a token, which is why `at` defaults below 1 |
|
|
505
|
+
| `tools: []` on the chat-completions wire | only `respond` is offered, so it is forced, the summary call answers no text and the run falls back to whole |
|
|
506
|
+
| per run | `compact` is the declaration's; `limits()` does not take it, `describeAgents()` does not publish it |
|
|
507
|
+
|
|
458
508
|
### Provider prompt caching
|
|
459
509
|
|
|
460
510
|
Not the gateway's response cache and not `llm()`'s semantic one: this asks the **provider** to keep
|
|
@@ -971,6 +1021,7 @@ fail-closed `terminal` default.
|
|
|
971
1021
|
| `X_AGENT_TOOL_UNDECLARED` | an `agent()` run's `offerTools()` named a tool its declaration does not list |
|
|
972
1022
|
| `X_AGENT_TOOL_LOOP` | an `agent()` made the same tool calls and read the same results `maxRepeatedToolCalls` turns in a row |
|
|
973
1023
|
| `X_AGENT_DEADLINE` | an `agent()` outlived its declared `deadlineMs` |
|
|
1024
|
+
| `X_AGENT_CONTEXT_EXHAUSTED` | an `agent()`'s next request would not fit its model's context window, and compaction could not bring it under; refused before it is sent |
|
|
974
1025
|
| `X_EVAL_THRESHOLD` | an eval scored below its bar |
|
|
975
1026
|
| `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
|
|
976
1027
|
| `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
|
|
@@ -986,6 +1037,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
986
1037
|
|
|
987
1038
|
| Class | Code | Declared in |
|
|
988
1039
|
|---|---|---|
|
|
1040
|
+
| `AgentContextExhaustedError` | `X_AGENT_CONTEXT_EXHAUSTED` | `src/agent-errors.ts` |
|
|
989
1041
|
| `AgentDeadlineError` | `X_AGENT_DEADLINE` | `src/agent-errors.ts` |
|
|
990
1042
|
| `AgentMaxTurnsError` | `X_AGENT_MAX_TURNS` | `src/errors.ts` |
|
|
991
1043
|
| `AgentToolLoopError` | `X_AGENT_TOOL_LOOP` | `src/agent-errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.14.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.14.0",
|
|
36
|
+
"@ultimat3/cache": "27.14.0",
|
|
37
|
+
"@ultimat3/core": "27.14.0",
|
|
38
|
+
"@ultimat3/db": "27.14.0",
|
|
39
|
+
"@ultimat3/http": "27.14.0",
|
|
40
|
+
"@ultimat3/jobs": "27.14.0",
|
|
41
|
+
"@ultimat3/money": "27.14.0",
|
|
42
|
+
"@ultimat3/policy": "27.14.0",
|
|
43
|
+
"@ultimat3/schema": "27.14.0",
|
|
44
|
+
"@ultimat3/time": "27.14.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Single responsibility: what an `agent({ compact })` declaration says — the option, its defaults,
|
|
2
|
+
// the screen it passes at declaration, and the one event a compaction reports. ./agent-compact
|
|
3
|
+
// owns the run-time half that reads them.
|
|
4
|
+
|
|
5
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import { AiRequestInvalidError } from './errors';
|
|
8
|
+
import type { ModelId } from './models';
|
|
9
|
+
import type { TokenUsage } from './provider';
|
|
10
|
+
|
|
11
|
+
/** The fraction of the model's input room (`contextWindow - maxTokens`) that triggers a compaction. */
|
|
12
|
+
export const DEFAULT_COMPACT_AT = 0.8;
|
|
13
|
+
|
|
14
|
+
/** The most recent turns sent verbatim after a compaction — one turn is one assistant message and the user message answering it. */
|
|
15
|
+
export const DEFAULT_KEEP_TURNS = 4;
|
|
16
|
+
|
|
17
|
+
/** The summary call's completion ceiling when the declaration names none (held under `maxTokens`). */
|
|
18
|
+
export const DEFAULT_MAX_SUMMARY_TOKENS = 2_048;
|
|
19
|
+
|
|
20
|
+
export interface AgentCompactRule {
|
|
21
|
+
/** Compact once the next request is estimated over this fraction of the input room. 0 < at <= 1. */
|
|
22
|
+
readonly at?: number;
|
|
23
|
+
/** Turns kept verbatim, the newest. At least 1: the model must read the tool results it just asked for. */
|
|
24
|
+
readonly keepTurns?: number;
|
|
25
|
+
/** The model that writes the summary. Default: the run's own — which also reads the cached prefix. */
|
|
26
|
+
readonly model?: ModelId;
|
|
27
|
+
/** The summary call's completion ceiling. Default `min(2,048, maxTokens)`. */
|
|
28
|
+
readonly maxSummaryTokens?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `'auto'` (the default) is `{}` — every default above. `'off'` never compacts and never checks. */
|
|
32
|
+
export type AgentCompactOption = 'auto' | 'off' | AgentCompactRule;
|
|
33
|
+
|
|
34
|
+
/** A declaration's `compact`, screened, defaults applied — `undefined` for `'off'`. */
|
|
35
|
+
export interface CompactPlan {
|
|
36
|
+
readonly at: number;
|
|
37
|
+
readonly keepTurns: number;
|
|
38
|
+
readonly model: ModelId | undefined;
|
|
39
|
+
readonly maxSummaryTokens: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One compaction attempt, as an observer sees it. Facts only — never the summary, never the transcript. */
|
|
43
|
+
export interface AgentCompaction {
|
|
44
|
+
/** The turn the compaction was for: the request about to be sent. */
|
|
45
|
+
readonly turn: number;
|
|
46
|
+
readonly outcome: 'compacted' | 'failed';
|
|
47
|
+
/** On `failed`: why. The run then went on uncompacted, or stopped with `X_AGENT_CONTEXT_EXHAUSTED`. */
|
|
48
|
+
readonly reason?: string;
|
|
49
|
+
/** The next request's estimated prompt tokens before the compaction. */
|
|
50
|
+
readonly before: number;
|
|
51
|
+
/** ...and after it — `before` again when it failed. */
|
|
52
|
+
readonly after: number;
|
|
53
|
+
/** The input room: the model's `contextWindow` less `maxTokens`. */
|
|
54
|
+
readonly limit: number;
|
|
55
|
+
/** Turns folded into the summary (0 when none were old enough). */
|
|
56
|
+
readonly summarised: number;
|
|
57
|
+
/** Turns sent verbatim after it. */
|
|
58
|
+
readonly kept: number;
|
|
59
|
+
/** Which compaction of the run this was, continuations included — 1-based. Counts successes only. */
|
|
60
|
+
readonly compactions: number;
|
|
61
|
+
/** The model that was asked for the summary. */
|
|
62
|
+
readonly model: ModelId;
|
|
63
|
+
/** The summary call's usage and cost; absent when no call was made. Already in the run's total. */
|
|
64
|
+
readonly usage?: TokenUsage;
|
|
65
|
+
readonly cost?: Money;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SUBJECT = 'agent';
|
|
69
|
+
|
|
70
|
+
/** At DECLARATION, like every other bound: an `agent()` runs at module scope, so a bad one fails the boot. */
|
|
71
|
+
export function screenCompact(
|
|
72
|
+
option: AgentCompactOption | undefined,
|
|
73
|
+
maxTokens: number,
|
|
74
|
+
): CompactPlan | undefined {
|
|
75
|
+
if (option === 'off') return undefined;
|
|
76
|
+
const rule: AgentCompactRule = option === undefined || option === 'auto' ? {} : option;
|
|
77
|
+
if (typeof rule !== 'object' || rule === null) {
|
|
78
|
+
throw new AiRequestInvalidError({
|
|
79
|
+
detail: `agent compact is ${String(option)}, which is not 'auto', 'off' or a rule`,
|
|
80
|
+
fix: "compact: 'auto' # on the agent — or 'off', or { at: 0.8, keepTurns: 4 }",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const at = rule.at ?? DEFAULT_COMPACT_AT;
|
|
84
|
+
// `estimate > NaN * room` is false on every turn: compaction silently never runs.
|
|
85
|
+
if (!Number.isFinite(at) || at <= 0 || at > 1) {
|
|
86
|
+
throw new AiRequestInvalidError({
|
|
87
|
+
detail: `agent compact.at is ${at}; it is a fraction of the context window, above 0 and at most 1`,
|
|
88
|
+
fix: 'compact: { at: 0.8 } # on the agent — compact once the next request is 80% of the room',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const keepTurns = finiteCount(
|
|
92
|
+
SUBJECT,
|
|
93
|
+
'compact.keepTurns',
|
|
94
|
+
rule.keepTurns ?? DEFAULT_KEEP_TURNS,
|
|
95
|
+
1,
|
|
96
|
+
);
|
|
97
|
+
const maxSummaryTokens = finiteCount(
|
|
98
|
+
SUBJECT,
|
|
99
|
+
'compact.maxSummaryTokens',
|
|
100
|
+
rule.maxSummaryTokens ?? Math.min(DEFAULT_MAX_SUMMARY_TOKENS, maxTokens),
|
|
101
|
+
1,
|
|
102
|
+
);
|
|
103
|
+
return { at, keepTurns, model: rule.model, maxSummaryTokens };
|
|
104
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Single responsibility: auto-compaction for ONE `agent()` run — the trigger before each turn, the
|
|
2
|
+
// cut between turns, the one summary call, and the transcript rebuilt around it.
|
|
3
|
+
//
|
|
4
|
+
// Shaped by the provider prompt cache (./prompt-cache): a cache is a prefix match, so everything
|
|
5
|
+
// before the messages — the tools, the system prompt and its stable block — is sent byte for byte
|
|
6
|
+
// as it was, and the summary call is the run's OWN request up to the cut plus one instruction, so
|
|
7
|
+
// it reads the prefix an earlier turn wrote instead of paying for it again. Only the messages
|
|
8
|
+
// change, once, and the next turn writes the new, shorter prefix.
|
|
9
|
+
//
|
|
10
|
+
// Never a truncation: a summary that cannot be had leaves the transcript whole, and a whole
|
|
11
|
+
// transcript that does not fit is `X_AGENT_CONTEXT_EXHAUSTED` before it is sent.
|
|
12
|
+
|
|
13
|
+
import type { Ctx, Span } from '@ultimat3/core';
|
|
14
|
+
import { stringField, throwIfAborted } from '@ultimat3/core';
|
|
15
|
+
import type { AgentCompaction, CompactPlan } from './agent-compact-def';
|
|
16
|
+
import { AgentContextExhaustedError } from './agent-errors';
|
|
17
|
+
import type { RunDeadline } from './agent-guards';
|
|
18
|
+
import type { Gateway } from './gateway';
|
|
19
|
+
import type { ModelId } from './models';
|
|
20
|
+
import { modelSpec } from './models';
|
|
21
|
+
import type { AiContentBlock, AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
22
|
+
import { estimateInputTokens, estimateTextTokens, isTruncated } from './provider';
|
|
23
|
+
import { runMeter } from './run-meter';
|
|
24
|
+
|
|
25
|
+
/** How a compacted first message's summary block starts — the marker a later compaction reads. */
|
|
26
|
+
export const COMPACTED_MARK = '[compacted:';
|
|
27
|
+
|
|
28
|
+
/** What the summary call is asked, appended to the last message it is sent. */
|
|
29
|
+
export const SUMMARY_INSTRUCTION = `${COMPACTED_MARK} instruction] This conversation is about to be shortened to fit the context window: everything after the task in the first message, any earlier summary included, will be replaced by the summary you write now. Summarise it for yourself — what you did, every tool you called and what it returned (keep exact ids, numbers, names and quotes you will still need), what you concluded, and what remains to do. Do not call any tool. Reply with the summary as plain text only.`;
|
|
30
|
+
|
|
31
|
+
/** The run's own stops: thrown as themselves from the summary call, never read as its failure. */
|
|
32
|
+
const RUN_STOPS: ReadonlySet<string> = new Set([
|
|
33
|
+
'X_AI_BUDGET_EXCEEDED',
|
|
34
|
+
'X_AGENT_DEADLINE',
|
|
35
|
+
'X_ABORTED',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export interface CompactorInput {
|
|
39
|
+
readonly agent: string;
|
|
40
|
+
readonly plan: CompactPlan;
|
|
41
|
+
/** The run's request with no messages: its model, system, tools and cache hint. */
|
|
42
|
+
readonly base: GenerateRequest;
|
|
43
|
+
readonly model: ModelId;
|
|
44
|
+
/** Compactions the handle being continued already holds. */
|
|
45
|
+
readonly prior: number;
|
|
46
|
+
readonly gateway: Gateway;
|
|
47
|
+
readonly deadline: RunDeadline;
|
|
48
|
+
readonly ctx: Ctx;
|
|
49
|
+
readonly span: Span;
|
|
50
|
+
readonly onCompact: ((event: AgentCompaction) => void | Promise<void>) | undefined;
|
|
51
|
+
/** `costPerRun` for the summary call, as for a turn: throws `X_AI_BUDGET_EXCEEDED` pre-flight. */
|
|
52
|
+
readonly assertCost: (request: GenerateRequest) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Compactor {
|
|
56
|
+
/** The messages the next turn sends: as given, compacted, or refused. */
|
|
57
|
+
fit(messages: readonly AiMessage[], turn: number): Promise<readonly AiMessage[]>;
|
|
58
|
+
/** Compactions so far, continuations included — what the handle's `compacted` carries. */
|
|
59
|
+
count(): number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The prompt half of a request, as the gateway estimates it (`estimateInputTokens`), plus the tool
|
|
64
|
+
* definitions that estimate leaves out — every one of them is sent on every turn.
|
|
65
|
+
*/
|
|
66
|
+
export function promptTokens(request: GenerateRequest): number {
|
|
67
|
+
return estimateInputTokens(request) + estimateTextTokens(JSON.stringify(request.tools ?? []));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The input room of `model` for a completion ceiling of `maxTokens`. Never below one token. */
|
|
71
|
+
export function roomOf(model: ModelId, maxTokens: number): number {
|
|
72
|
+
return Math.max(1, modelSpec(model).contextWindow - maxTokens);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function compactor(input: CompactorInput): Compactor {
|
|
76
|
+
const { plan, base, model, span } = input;
|
|
77
|
+
const writer = plan.model ?? model;
|
|
78
|
+
// Before the first turn: an unregistered summary model is `X_AI_MODEL_UNKNOWN` at run start,
|
|
79
|
+
// not in the middle of the run whose transcript it was supposed to save.
|
|
80
|
+
modelSpec(writer);
|
|
81
|
+
const limit = roomOf(model, base.maxTokens);
|
|
82
|
+
let count = input.prior;
|
|
83
|
+
// The estimate a compaction is attempted above. After a FAILED attempt it rises to the room
|
|
84
|
+
// itself: each attempt is a billed call, and one that just failed is tried again only when the
|
|
85
|
+
// run can no longer go on without it — never once per turn while it still fits.
|
|
86
|
+
let trigger = plan.at * limit;
|
|
87
|
+
|
|
88
|
+
const report = async (event: AgentCompaction): Promise<void> => {
|
|
89
|
+
span.addEvent('agent.compact', {
|
|
90
|
+
'agent.compact.turn': event.turn,
|
|
91
|
+
'agent.compact.outcome': event.outcome,
|
|
92
|
+
'agent.compact.before_tokens': event.before,
|
|
93
|
+
'agent.compact.after_tokens': event.after,
|
|
94
|
+
'agent.compact.limit_tokens': event.limit,
|
|
95
|
+
'agent.compact.summarised_turns': event.summarised,
|
|
96
|
+
'agent.compact.kept_turns': event.kept,
|
|
97
|
+
...(event.reason === undefined ? {} : { 'agent.compact.reason': event.reason }),
|
|
98
|
+
...(event.cost === undefined ? {} : { 'llm.cost.minor': event.cost.minor }),
|
|
99
|
+
});
|
|
100
|
+
await input.onCompact?.(event);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const fit = async (
|
|
104
|
+
messages: readonly AiMessage[],
|
|
105
|
+
turn: number,
|
|
106
|
+
): Promise<readonly AiMessage[]> => {
|
|
107
|
+
const before = promptTokens({ ...base, messages });
|
|
108
|
+
if (before <= trigger) return messages;
|
|
109
|
+
const facts = { turn, before, limit, model: writer };
|
|
110
|
+
const split = splitTurns(messages, plan.keepTurns);
|
|
111
|
+
const kept = split === undefined ? Math.floor(messages.length / 2) : plan.keepTurns;
|
|
112
|
+
const failed = async (
|
|
113
|
+
reason: string,
|
|
114
|
+
summarised: number,
|
|
115
|
+
call?: GenerateResult,
|
|
116
|
+
): Promise<readonly AiMessage[]> => {
|
|
117
|
+
await report({
|
|
118
|
+
...facts,
|
|
119
|
+
outcome: 'failed',
|
|
120
|
+
reason,
|
|
121
|
+
after: before,
|
|
122
|
+
summarised,
|
|
123
|
+
kept,
|
|
124
|
+
compactions: count,
|
|
125
|
+
...(call === undefined ? {} : { usage: call.usage, cost: call.cost }),
|
|
126
|
+
});
|
|
127
|
+
// Still fits: the run goes on whole. Compaction is how a run stays under the window, never
|
|
128
|
+
// a condition of running — the turns that fit are not refused for a summary that failed.
|
|
129
|
+
trigger = limit;
|
|
130
|
+
if (before <= limit) return messages;
|
|
131
|
+
throw new AgentContextExhaustedError({
|
|
132
|
+
agent: input.agent,
|
|
133
|
+
model,
|
|
134
|
+
estimate: before,
|
|
135
|
+
limit,
|
|
136
|
+
reason,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (split === undefined) {
|
|
141
|
+
return failed(`there is no turn older than the last ${plan.keepTurns} to summarise`, 0);
|
|
142
|
+
}
|
|
143
|
+
const summarised = split.older.length / 2;
|
|
144
|
+
const request = summaryRequest(base, writer, plan.maxSummaryTokens, split);
|
|
145
|
+
if (promptTokens(request) > roomOf(writer, plan.maxSummaryTokens)) {
|
|
146
|
+
return failed(`the turns to summarise do not fit model "${writer}" either`, summarised);
|
|
147
|
+
}
|
|
148
|
+
throwIfAborted(input.ctx);
|
|
149
|
+
input.deadline.check();
|
|
150
|
+
input.assertCost(request);
|
|
151
|
+
let result: GenerateResult;
|
|
152
|
+
try {
|
|
153
|
+
// Its own meter, inside the run's: the bill reaches the run as a nested call's does — on the
|
|
154
|
+
// same ledger, in `tokensPerRun` and `costPerRun`, priced at the summary model's own rates.
|
|
155
|
+
result = await input.deadline.guard(runMeter().call(input.gateway.generate(request)));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throwIfAborted(input.ctx);
|
|
158
|
+
const code = stringField(error, 'code');
|
|
159
|
+
if (code !== undefined && RUN_STOPS.has(code)) throw error;
|
|
160
|
+
return failed(`the summary call failed${code === undefined ? '' : ` (${code})`}`, summarised);
|
|
161
|
+
}
|
|
162
|
+
const summary = result.text.trim();
|
|
163
|
+
if (result.stopReason === 'refusal') {
|
|
164
|
+
return failed('the summary call was refused', summarised, result);
|
|
165
|
+
}
|
|
166
|
+
if (isTruncated(result.stopReason)) {
|
|
167
|
+
return failed(
|
|
168
|
+
`the summary was cut off at maxSummaryTokens (${plan.maxSummaryTokens})`,
|
|
169
|
+
summarised,
|
|
170
|
+
result,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (summary === '') return failed('the summary call answered no text', summarised, result);
|
|
174
|
+
const compacted = [compactedHead(split.head, summarised, summary), ...split.kept];
|
|
175
|
+
const after = promptTokens({ ...base, messages: compacted });
|
|
176
|
+
if (after >= before) {
|
|
177
|
+
return failed('the summary was no shorter than the turns it replaced', summarised, result);
|
|
178
|
+
}
|
|
179
|
+
if (after > limit) {
|
|
180
|
+
return failed('even compacted, the next request is over the window', summarised, result);
|
|
181
|
+
}
|
|
182
|
+
count += 1;
|
|
183
|
+
trigger = plan.at * limit;
|
|
184
|
+
await report({
|
|
185
|
+
...facts,
|
|
186
|
+
outcome: 'compacted',
|
|
187
|
+
after,
|
|
188
|
+
summarised,
|
|
189
|
+
kept,
|
|
190
|
+
compactions: count,
|
|
191
|
+
usage: result.usage,
|
|
192
|
+
cost: result.cost,
|
|
193
|
+
});
|
|
194
|
+
return compacted;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return { fit, count: () => count };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface Split {
|
|
201
|
+
/** The first user message: the rendered prompt, and any earlier summary. */
|
|
202
|
+
readonly head: AiMessage;
|
|
203
|
+
/** Whole turns — an assistant message and the user message answering it — oldest first. */
|
|
204
|
+
readonly older: readonly AiMessage[];
|
|
205
|
+
/** The last `keepTurns` turns, verbatim. */
|
|
206
|
+
readonly kept: readonly AiMessage[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The transcript cut BETWEEN turns, never inside one. A loop's messages are the prompt, then
|
|
211
|
+
* assistant/user pairs — each user message answering every `tool_use` of the assistant message
|
|
212
|
+
* before it — so a cut before an assistant message cannot separate a call from its result.
|
|
213
|
+
* `undefined` when no whole turn is older than the kept ones.
|
|
214
|
+
*/
|
|
215
|
+
function splitTurns(messages: readonly AiMessage[], keepTurns: number): Split | undefined {
|
|
216
|
+
const head = messages[0];
|
|
217
|
+
const cut = messages.length - 2 * keepTurns;
|
|
218
|
+
if (head === undefined || head.role !== 'user' || cut < 3) return undefined;
|
|
219
|
+
if (messages[cut]?.role !== 'assistant' || messages[cut - 1]?.role !== 'user') return undefined;
|
|
220
|
+
return { head, older: messages.slice(1, cut), kept: messages.slice(cut) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The run's own request up to the cut, plus the instruction — the same tools, system and cache
|
|
225
|
+
* hint, so the provider reads the prefix an earlier turn wrote. A different summary model takes
|
|
226
|
+
* none of the run model's reasoning controls: one it lacks would be `X_AI_REQUEST_INVALID`.
|
|
227
|
+
*/
|
|
228
|
+
function summaryRequest(
|
|
229
|
+
base: GenerateRequest,
|
|
230
|
+
writer: ModelId,
|
|
231
|
+
maxTokens: number,
|
|
232
|
+
split: Split,
|
|
233
|
+
): GenerateRequest {
|
|
234
|
+
const messages = withInstruction([split.head, ...split.older]);
|
|
235
|
+
if (writer === base.model) return { ...base, messages, maxTokens };
|
|
236
|
+
const { effort: _effort, thinking: _thinking, ...rest } = base;
|
|
237
|
+
return { ...rest, model: writer, messages, maxTokens };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Roles must alternate, and the older turns end on a user message: the instruction joins it. */
|
|
241
|
+
function withInstruction(messages: readonly AiMessage[]): readonly AiMessage[] {
|
|
242
|
+
const last = messages.at(-1);
|
|
243
|
+
const text = { type: 'text' as const, text: SUMMARY_INSTRUCTION };
|
|
244
|
+
if (last === undefined) return [{ role: 'user', content: SUMMARY_INSTRUCTION }];
|
|
245
|
+
return [...messages.slice(0, -1), { role: 'user', content: [...blocksOf(last), text] }];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The first message, rebuilt: the prompt as it was sent (an earlier summary dropped — the new one
|
|
250
|
+
* covers it), then the summary, marked. The model reads it as its own notes, said to be so.
|
|
251
|
+
*/
|
|
252
|
+
function compactedHead(head: AiMessage, turns: number, summary: string): AiMessage {
|
|
253
|
+
const prompt = blocksOf(head).filter(
|
|
254
|
+
(block) => !(block.type === 'text' && block.text.startsWith(COMPACTED_MARK)),
|
|
255
|
+
);
|
|
256
|
+
const note = `${COMPACTED_MARK} ${turns} earlier turn${turns === 1 ? '' : 's'} of this run were summarised to fit the context window. Your summary of everything after the task above:]\n\n${summary}`;
|
|
257
|
+
return { role: 'user', content: [...prompt, { type: 'text', text: note }] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function blocksOf(message: AiMessage): readonly AiContentBlock[] {
|
|
261
|
+
return typeof message.content === 'string'
|
|
262
|
+
? [{ type: 'text', text: message.content }]
|
|
263
|
+
: message.content;
|
|
264
|
+
}
|
package/src/agent-continue.ts
CHANGED
|
@@ -64,6 +64,12 @@ export interface AgentTranscript {
|
|
|
64
64
|
readonly turns: number;
|
|
65
65
|
/** Usage across the run and every continuation — what `budget.tokensPerRun` counts. */
|
|
66
66
|
readonly usage: TokenUsage;
|
|
67
|
+
/**
|
|
68
|
+
* Compactions applied so far, continuations included — present only once there was one, so a
|
|
69
|
+
* run that never compacted writes exactly the handle it always did. Its first message then holds
|
|
70
|
+
* the prompt and a `[compacted: …]` summary block (`./agent-compact`).
|
|
71
|
+
*/
|
|
72
|
+
readonly compacted?: number;
|
|
67
73
|
/** HMAC-SHA256 over the canonical JSON of every field above (`sealTranscript`). */
|
|
68
74
|
readonly sig: string;
|
|
69
75
|
}
|
|
@@ -220,7 +226,7 @@ function signatureOf(fields: Omit<AgentTranscript, 'sig'>): string {
|
|
|
220
226
|
/** A handle for `state`, bound and signed. The one place a transcript is written. */
|
|
221
227
|
export function sealTranscript(
|
|
222
228
|
binding: TranscriptBinding,
|
|
223
|
-
state: Pick<AgentTranscript, 'messages' | 'turns' | 'usage'>,
|
|
229
|
+
state: Pick<AgentTranscript, 'messages' | 'turns' | 'usage' | 'compacted'>,
|
|
224
230
|
): AgentTranscript {
|
|
225
231
|
const fields: Omit<AgentTranscript, 'sig'> = {
|
|
226
232
|
v: TRANSCRIPT_VERSION,
|
|
@@ -231,6 +237,7 @@ export function sealTranscript(
|
|
|
231
237
|
messages: state.messages,
|
|
232
238
|
turns: state.turns,
|
|
233
239
|
usage: state.usage,
|
|
240
|
+
...(state.compacted === undefined || state.compacted < 1 ? {} : { compacted: state.compacted }),
|
|
234
241
|
};
|
|
235
242
|
return { ...fields, sig: signatureOf(fields) };
|
|
236
243
|
}
|
|
@@ -320,6 +327,13 @@ export function screenTranscript(binding: TranscriptBinding, raw: unknown): Agen
|
|
|
320
327
|
refuse('its turn count is not a whole number');
|
|
321
328
|
}
|
|
322
329
|
const usage = usageOf(handle['usage']) ?? refuse('its usage is not four token counts');
|
|
330
|
+
const compacted = handle['compacted'];
|
|
331
|
+
if (
|
|
332
|
+
compacted !== undefined &&
|
|
333
|
+
(typeof compacted !== 'number' || !Number.isInteger(compacted) || compacted < 1)
|
|
334
|
+
) {
|
|
335
|
+
refuse('its compaction count is not a whole number above zero');
|
|
336
|
+
}
|
|
323
337
|
const messages = Array.isArray(handle['messages']) ? handle['messages'] : refuse('no messages');
|
|
324
338
|
if (messages.length < 1) refuse('it holds no message');
|
|
325
339
|
const declared = new Set(binding.tools);
|
|
@@ -362,6 +376,7 @@ export function screenTranscript(binding: TranscriptBinding, raw: unknown): Agen
|
|
|
362
376
|
messages: screened,
|
|
363
377
|
turns: turns as number,
|
|
364
378
|
usage,
|
|
379
|
+
...(compacted === undefined ? {} : { compacted: compacted as number }),
|
|
365
380
|
sig: sig as string,
|
|
366
381
|
};
|
|
367
382
|
}
|
package/src/agent-def.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
|
5
5
|
import type { Ctx } from '@ultimat3/core';
|
|
6
6
|
import type { Money } from '@ultimat3/money';
|
|
7
7
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AgentCompaction, AgentCompactOption } from './agent-compact-def';
|
|
8
9
|
import type { AgentSystem } from './agent-system';
|
|
9
10
|
import type { AgentRunUsage } from './agent-usage';
|
|
10
11
|
import type { LlmBudget } from './llm';
|
|
@@ -87,6 +88,14 @@ export interface AgentDef<
|
|
|
87
88
|
* what each wire is sent; a host that caches implicitly is sent nothing either way.
|
|
88
89
|
*/
|
|
89
90
|
readonly promptCache?: PromptCacheOption<AgentVarsArgs<TInput>>;
|
|
91
|
+
/**
|
|
92
|
+
* Auto-compaction. Default `'auto'`: before a turn whose request the gateway estimates over
|
|
93
|
+
* `at` (0.8) of the model's `contextWindow` less `maxTokens`, ONE model call summarises the turns
|
|
94
|
+
* older than the last `keepTurns` (4) into the first user message; the system prompt, the tools
|
|
95
|
+
* and the kept turns are sent byte for byte as before. A request that still would not fit is
|
|
96
|
+
* `X_AGENT_CONTEXT_EXHAUSTED` before it is sent. `'off'` never compacts and never checks.
|
|
97
|
+
*/
|
|
98
|
+
readonly compact?: AgentCompactOption;
|
|
90
99
|
/**
|
|
91
100
|
* What the model may call: an `action()` that is `mcp: { expose: true }` — the same predicate an
|
|
92
101
|
* external MCP client is filtered by, so both are offered the same actions — or an
|
|
@@ -158,6 +167,12 @@ export interface AgentDef<
|
|
|
158
167
|
* one turn deep by construction; this is per TURN.
|
|
159
168
|
*/
|
|
160
169
|
onTurn?(event: AgentTurn): void | Promise<void>;
|
|
170
|
+
/**
|
|
171
|
+
* Called once per compaction ATTEMPT, before the turn it was for is sent — `outcome:
|
|
172
|
+
* 'compacted'` or `'failed'` with the reason. Same contract as `onTurn`: observation only, and a
|
|
173
|
+
* throw fails the run. Also an `agent.compact` span event.
|
|
174
|
+
*/
|
|
175
|
+
onCompact?(event: AgentCompaction): void | Promise<void>;
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
/** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
|
package/src/agent-errors.ts
CHANGED
|
@@ -84,3 +84,35 @@ export class AgentToolUndeclaredError extends UltimateError {
|
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The next request would not fit the model's context window, and compaction could not make it
|
|
90
|
+
* fit. Refused BEFORE the request is sent: a provider answers an over-long request with a 400 (or
|
|
91
|
+
* a turn cut off at `model_context_window_exceeded`) after the whole transcript was paid for, and
|
|
92
|
+
* a transcript cut to fit is a model reasoning over a run it can no longer see.
|
|
93
|
+
*/
|
|
94
|
+
export class AgentContextExhaustedError extends UltimateError {
|
|
95
|
+
constructor(input: {
|
|
96
|
+
agent: string;
|
|
97
|
+
model: string;
|
|
98
|
+
/** The next request's estimated prompt tokens. */
|
|
99
|
+
estimate: number;
|
|
100
|
+
/** The model's `contextWindow` less `maxTokens`. */
|
|
101
|
+
limit: number;
|
|
102
|
+
/** Why compaction did not bring it under. */
|
|
103
|
+
reason: string;
|
|
104
|
+
}) {
|
|
105
|
+
super({
|
|
106
|
+
code: 'X_AGENT_CONTEXT_EXHAUSTED',
|
|
107
|
+
cause: `agent "${input.agent}"'s next request is estimated at ${input.estimate} prompt tokens, over the ${input.limit} model "${input.model}" has room for (contextWindow less maxTokens), and compaction could not bring it under: ${input.reason}`,
|
|
108
|
+
fix: 'compact: { at: 0.6, keepTurns: 2 } # on the agent — compact earlier and keep fewer turns verbatim; or lower maxToolResultChars, or run on a model whose registerModel row has a larger contextWindow',
|
|
109
|
+
meta: {
|
|
110
|
+
agent: input.agent,
|
|
111
|
+
model: input.model,
|
|
112
|
+
estimate: input.estimate,
|
|
113
|
+
limit: input.limit,
|
|
114
|
+
reason: input.reason,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -26,6 +26,10 @@ import type { Ctx } from '@ultimat3/core';
|
|
|
26
26
|
import { throwIfAborted, withSpan } from '@ultimat3/core';
|
|
27
27
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
28
28
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
29
|
+
import type { Compactor } from './agent-compact';
|
|
30
|
+
import { compactor } from './agent-compact';
|
|
31
|
+
import type { CompactPlan } from './agent-compact-def';
|
|
32
|
+
import { screenCompact } from './agent-compact-def';
|
|
29
33
|
import type { AgentAction, TranscriptBinding } from './agent-continue';
|
|
30
34
|
import {
|
|
31
35
|
claimAgentSession,
|
|
@@ -65,6 +69,7 @@ import { ignoredSamplingAttribute, screenSampling } from './sampling';
|
|
|
65
69
|
import type { LlmToolResult } from './tools';
|
|
66
70
|
import { isOfferedTool, runLlmToolCall, toolLabel } from './tools';
|
|
67
71
|
|
|
72
|
+
export type { AgentCompaction, AgentCompactOption, AgentCompactRule } from './agent-compact-def';
|
|
68
73
|
export type {
|
|
69
74
|
AgentBudget,
|
|
70
75
|
AgentDef,
|
|
@@ -97,6 +102,7 @@ export function agent<
|
|
|
97
102
|
// This declaration's identity for `.run()` / `.continue()` sessions (`claimAgentSession`).
|
|
98
103
|
const owner = {};
|
|
99
104
|
const sampling = screenSampling(SUBJECT, def);
|
|
105
|
+
const compact = screenCompact(def.compact, bounds.maxTokens);
|
|
100
106
|
// Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
|
|
101
107
|
// scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
|
|
102
108
|
// the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
@@ -115,7 +121,7 @@ export function agent<
|
|
|
115
121
|
def,
|
|
116
122
|
respond,
|
|
117
123
|
await resolvePerRun(def, { ...declared, adapted: adapt() }, args),
|
|
118
|
-
{ sampling, owner },
|
|
124
|
+
{ sampling, owner, compact },
|
|
119
125
|
args,
|
|
120
126
|
),
|
|
121
127
|
});
|
|
@@ -149,7 +155,11 @@ async function run<
|
|
|
149
155
|
respond: Respond,
|
|
150
156
|
/** This run's picks — model, offered tools, ceilings — resolved from the declaration. */
|
|
151
157
|
picked: PerRun,
|
|
152
|
-
fixed: {
|
|
158
|
+
fixed: {
|
|
159
|
+
readonly sampling: Sampling;
|
|
160
|
+
readonly owner: object;
|
|
161
|
+
readonly compact: CompactPlan | undefined;
|
|
162
|
+
},
|
|
153
163
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
154
164
|
): Promise<InferOutput<TOutput>> {
|
|
155
165
|
const { prompt } = def;
|
|
@@ -237,6 +247,36 @@ async function run<
|
|
|
237
247
|
// deadline, a truncated turn, the caller leaving — is thrown carrying what the run was billed.
|
|
238
248
|
const meter = runMeter();
|
|
239
249
|
const disclosure = { discloseErrors: def.discloseToolErrors !== false };
|
|
250
|
+
// Worst case: the run so far (continuations and nested calls included) plus this request,
|
|
251
|
+
// estimated as the gateway's own reservation will be — at the write rate only where a provider
|
|
252
|
+
// serving the model would mark it. Before every turn, and before a compaction's summary call.
|
|
253
|
+
const assertCost = (next: GenerateRequest): void => {
|
|
254
|
+
if (costPerRun === undefined) return;
|
|
255
|
+
const spent = runTotal(model, prior?.usage, meter.total(), meter.below()).costExact;
|
|
256
|
+
const marked = gateway.marksPromptCache?.(next.model ?? model) === true;
|
|
257
|
+
assertRunCost(costPerRun, spent, estimateCost(next, marked));
|
|
258
|
+
};
|
|
259
|
+
const compacting: Compactor | undefined =
|
|
260
|
+
fixed.compact === undefined
|
|
261
|
+
? undefined
|
|
262
|
+
: compactor({
|
|
263
|
+
agent: name,
|
|
264
|
+
plan: fixed.compact,
|
|
265
|
+
base,
|
|
266
|
+
model,
|
|
267
|
+
prior: prior?.compacted ?? 0,
|
|
268
|
+
gateway,
|
|
269
|
+
deadline,
|
|
270
|
+
ctx: args.ctx,
|
|
271
|
+
span,
|
|
272
|
+
onCompact: def.onCompact?.bind(def),
|
|
273
|
+
assertCost,
|
|
274
|
+
});
|
|
275
|
+
// What the handle's `compacted` carries: a run with `compact: 'off'` keeps what it was handed.
|
|
276
|
+
const compacted = (): { compacted?: number } => {
|
|
277
|
+
const count = compacting?.count() ?? prior?.compacted ?? 0;
|
|
278
|
+
return count > 0 ? { compacted: count } : {};
|
|
279
|
+
};
|
|
240
280
|
// Where a failure would resume from: the request in flight, and the turns answered so far.
|
|
241
281
|
const at = { messages: [] as readonly AiMessage[], turns: prior?.turns ?? 0 };
|
|
242
282
|
const loop = async (): Promise<InferOutput<TOutput>> => {
|
|
@@ -259,15 +299,12 @@ async function run<
|
|
|
259
299
|
// budget, runs every remaining tool's side effects, and discards the answer.
|
|
260
300
|
throwIfAborted(args.ctx);
|
|
261
301
|
deadline.check();
|
|
302
|
+
// Before the request exists, never after a 400: a turn the window cannot take is
|
|
303
|
+
// compacted first, or refused (`X_AGENT_CONTEXT_EXHAUSTED`) without being sent.
|
|
304
|
+
if (compacting !== undefined) messages = await compacting.fit(messages, turn);
|
|
305
|
+
at.messages = messages;
|
|
262
306
|
const request: GenerateRequest = { ...base, messages };
|
|
263
|
-
|
|
264
|
-
// Worst case: the run so far (continuations and nested calls included) plus this turn,
|
|
265
|
-
// estimated as the gateway's own reservation will be — at the write rate only where a
|
|
266
|
-
// provider serving the model would mark the request.
|
|
267
|
-
const spent = runTotal(model, prior?.usage, meter.total(), meter.below()).costExact;
|
|
268
|
-
const marked = gateway.marksPromptCache?.(model) === true;
|
|
269
|
-
assertRunCost(costPerRun, spent, estimateCost(request, marked));
|
|
270
|
-
}
|
|
307
|
+
assertCost(request);
|
|
271
308
|
const result = await deadline
|
|
272
309
|
.guard(meter.call(gateway.generate(request)))
|
|
273
310
|
.catch((error: unknown) => {
|
|
@@ -360,6 +397,7 @@ async function run<
|
|
|
360
397
|
messages: [...messages, assistantTurn(result)],
|
|
361
398
|
turns: turn,
|
|
362
399
|
usage: usage.usage,
|
|
400
|
+
...compacted(),
|
|
363
401
|
}),
|
|
364
402
|
turns: turn,
|
|
365
403
|
usage,
|
|
@@ -398,7 +436,7 @@ async function run<
|
|
|
398
436
|
// — six truncated continuations billed six times against a ceiling that saw none of them.
|
|
399
437
|
if (session !== undefined && at.messages.length > 0) {
|
|
400
438
|
const { usage } = runTotal(model, prior?.usage, meter.billed());
|
|
401
|
-
withTranscript(failed, sealTranscript(binding, { ...at, usage }));
|
|
439
|
+
withTranscript(failed, sealTranscript(binding, { ...at, usage, ...compacted() }));
|
|
402
440
|
}
|
|
403
441
|
throw failed;
|
|
404
442
|
})
|
package/src/errors-retry.ts
CHANGED
|
@@ -55,6 +55,9 @@ export const AI_ERROR_RETRY = {
|
|
|
55
55
|
// A run whose `offerTools()` named an undeclared tool names it again on the next attempt: the
|
|
56
56
|
// fix is the declaration or the app's own rule, never a retry.
|
|
57
57
|
X_AGENT_TOOL_UNDECLARED: 'terminal',
|
|
58
|
+
// A run whose transcript outgrew the window outgrows it again: the attempt starts from the same
|
|
59
|
+
// input, takes the same turns and reaches the same size. The fix is `compact` or the model.
|
|
60
|
+
X_AGENT_CONTEXT_EXHAUSTED: 'terminal',
|
|
58
61
|
} as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
|
|
59
62
|
|
|
60
63
|
// `Partial`, so the table may be a subset — but every key is still checked against the declared set,
|
package/src/errors.ts
CHANGED
|
@@ -36,6 +36,7 @@ export const AI_ERROR_CODES = [
|
|
|
36
36
|
'X_AGENT_TRANSCRIPT_INVALID',
|
|
37
37
|
'X_AI_PROVIDER_UNEXPECTED',
|
|
38
38
|
'X_AGENT_TOOL_UNDECLARED',
|
|
39
|
+
'X_AGENT_CONTEXT_EXHAUSTED',
|
|
39
40
|
] as const;
|
|
40
41
|
|
|
41
42
|
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
@@ -75,6 +76,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
75
76
|
X_AI_PROVIDER_UNEXPECTED:
|
|
76
77
|
'a provider ended a turn with an unknown reason and an incomplete answer',
|
|
77
78
|
X_AGENT_TOOL_UNDECLARED: 'an agent run offered a tool its declaration does not list',
|
|
79
|
+
X_AGENT_CONTEXT_EXHAUSTED: "an agent's next request would not fit its model's context window",
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export type { Infer } from '@ultimat3/schema';
|
|
6
6
|
export { t } from '@ultimat3/schema';
|
|
7
7
|
export { agent } from './agent';
|
|
8
|
+
export type { AgentCompaction, AgentCompactOption, AgentCompactRule } from './agent-compact-def';
|
|
8
9
|
export type {
|
|
9
10
|
AgentAction,
|
|
10
11
|
AgentContinuation,
|
|
@@ -26,6 +27,7 @@ export type {
|
|
|
26
27
|
AgentVarsArgs,
|
|
27
28
|
} from './agent-def';
|
|
28
29
|
export {
|
|
30
|
+
AgentContextExhaustedError,
|
|
29
31
|
AgentDeadlineError,
|
|
30
32
|
AgentToolLoopError,
|
|
31
33
|
AgentToolUndeclaredError,
|