@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 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
- Full text of each rule: [`docs/history/ai.md`](../../docs/history/ai.md#evals-retrieval-fix-lines).
281
-
282
- - **No fix line may name `x ai`** (planned); an eval is selected with `x test eval --filter <name>`.
283
- - An eval gates on the DROP from its baseline, mean AND per case; every prompt is named by an eval
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.13.2",
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.13.2",
36
- "@ultimat3/cache": "27.13.2",
37
- "@ultimat3/core": "27.13.2",
38
- "@ultimat3/db": "27.13.2",
39
- "@ultimat3/http": "27.13.2",
40
- "@ultimat3/jobs": "27.13.2",
41
- "@ultimat3/money": "27.13.2",
42
- "@ultimat3/policy": "27.13.2",
43
- "@ultimat3/schema": "27.13.2",
44
- "@ultimat3/time": "27.13.2"
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
+ }
@@ -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. */
@@ -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: { readonly sampling: Sampling; readonly owner: object },
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
- if (costPerRun !== undefined) {
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
  })
@@ -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,