@ultimat3/ai 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CLAUDE.md +63 -90
  2. package/README.md +125 -8
  3. package/package.json +23 -11
  4. package/src/agent-checkpoint.ts +44 -0
  5. package/src/agent-compact-def.ts +11 -0
  6. package/src/agent-compact-measure.ts +38 -0
  7. package/src/agent-compact-request.ts +100 -0
  8. package/src/agent-compact-text.ts +95 -0
  9. package/src/agent-compact.ts +75 -50
  10. package/src/agent-continue.ts +15 -3
  11. package/src/agent-def.ts +9 -0
  12. package/src/agent-errors.ts +12 -2
  13. package/src/agent-facts.ts +8 -0
  14. package/src/agent-send.ts +82 -0
  15. package/src/agent-stream.ts +76 -0
  16. package/src/agent.ts +71 -21
  17. package/src/answer-errors.ts +1 -1
  18. package/src/anthropic-provider.ts +291 -0
  19. package/src/content-errors.ts +1 -1
  20. package/src/context-overflow.ts +86 -0
  21. package/src/error-codes.ts +95 -0
  22. package/src/errors-retry.ts +8 -4
  23. package/src/errors.ts +6 -87
  24. package/src/eval-agent.ts +100 -0
  25. package/src/eval-errors.ts +31 -5
  26. package/src/evals.ts +125 -45
  27. package/src/gateway-providers.ts +215 -0
  28. package/src/gateway.ts +63 -153
  29. package/src/hive-errors.ts +1 -1
  30. package/src/in-process-tool.ts +20 -11
  31. package/src/index.ts +31 -7
  32. package/src/llm.ts +39 -8
  33. package/src/model-resolve.ts +7 -1
  34. package/src/openai-body.ts +12 -5
  35. package/src/openai-provider.ts +28 -8
  36. package/src/openai-wire.ts +11 -7
  37. package/src/prompt-cache-rows.ts +82 -0
  38. package/src/prompt-cache.ts +24 -2
  39. package/src/provider.ts +15 -274
  40. package/src/remote-embedder.ts +38 -11
  41. package/src/run-meter.ts +40 -2
  42. package/src/runtime.ts +38 -3
  43. package/src/scorers.ts +35 -1
  44. package/src/scripted-provider.ts +146 -0
  45. package/src/tools-gate.ts +46 -0
  46. package/src/tools.ts +101 -27
  47. package/src/wire.ts +12 -4
@@ -0,0 +1,44 @@
1
+ // Single responsibility: `onCheckpoint` — a signed handle for the run so far, handed to the app
2
+ // after a turn's tool results (or its correction) land and before the next turn is bought, so a
3
+ // worker that dies mid-run leaves something to `.continue()` from. Sealed only when the hook is
4
+ // declared: a run without it pays nothing and is byte-identical.
5
+
6
+ import type { Span } from '@ultimat3/core';
7
+ import type { AgentCheckpoint, TranscriptBinding } from './agent-continue';
8
+ import { sealTranscript } from './agent-continue';
9
+ import type { AgentRunUsage } from './agent-usage';
10
+ import type { AiMessage } from './provider';
11
+
12
+ /** What one checkpoint is cut from: the transcript after turn `turn`, and the run's total. */
13
+ export interface CheckpointState {
14
+ readonly turn: number;
15
+ readonly messages: readonly AiMessage[];
16
+ readonly run: AgentRunUsage;
17
+ /** Compactions so far, continuations included — signed, like a finished handle's. */
18
+ readonly compacted: number;
19
+ }
20
+
21
+ export type Checkpointer = (state: CheckpointState) => Promise<void>;
22
+
23
+ /**
24
+ * `undefined` without a hook — the loop then seals nothing. Awaited, and a throw fails the run, as
25
+ * `onTurn`'s does: a checkpoint that silently stopped reads exactly like one that works.
26
+ */
27
+ export function checkpointer<A>(
28
+ hook: ((event: AgentCheckpoint, args: A) => void | Promise<void>) | undefined,
29
+ args: A,
30
+ binding: TranscriptBinding,
31
+ span: Span,
32
+ ): Checkpointer | undefined {
33
+ if (hook === undefined) return undefined;
34
+ return async ({ turn, messages, run, compacted }) => {
35
+ const transcript = sealTranscript(binding, {
36
+ messages,
37
+ turns: turn,
38
+ usage: run.usage,
39
+ ...(compacted > 0 ? { compacted } : {}),
40
+ });
41
+ span.addEvent('agent.checkpoint', { 'agent.checkpoint.turn': turn });
42
+ await hook({ turn, transcript, run }, args);
43
+ };
44
+ }
@@ -60,6 +60,17 @@ export interface AgentCompaction {
60
60
  readonly compactions: number;
61
61
  /** The model that was asked for the summary. */
62
62
  readonly model: ModelId;
63
+ /**
64
+ * How the turns reached the summary call: `'messages'`, the run's own request up to the cut
65
+ * (it reads the cached prefix), or `'text'` when those did not fit the writer — re-sent as text,
66
+ * oldest tool results clipped to their first and last 1,000 characters, then left out. Absent
67
+ * when no call was built.
68
+ */
69
+ readonly summaryInput?: 'messages' | 'text';
70
+ /** On `'text'`: turns, oldest first, whose tool results were clipped or left out. */
71
+ readonly clipped?: number;
72
+ /** On `'text'`: of those, the turns whose tool results were left out entirely. */
73
+ readonly omitted?: number;
63
74
  /** The summary call's usage and cost; absent when no call was made. Already in the run's total. */
64
75
  readonly usage?: TokenUsage;
65
76
  readonly cost?: Money;
@@ -0,0 +1,38 @@
1
+ // Single responsibility: what one run has MEASURED about its prompt size — the ratio of the tokens
2
+ // a provider reported for a turn to the four-characters-a-token estimate of that request. Dense JSON
3
+ // and non-Latin text run at one to two characters a token, so the estimate alone let a run walk past
4
+ // the window it was meant to compact before.
5
+
6
+ import type { TokenUsage } from './provider';
7
+
8
+ /** One run's measurement. Starts at 1 (the estimate as is); never below it, never signed into a handle. */
9
+ export interface PromptMeasure {
10
+ /** Read a turn's reported input against the request that produced it. */
11
+ observe(usage: TokenUsage, estimate: number): void;
12
+ /** The provider refused `estimate` as over `room`: the real figure is at least `room + 1`. */
13
+ overflowed(estimate: number, room: number): void;
14
+ /** `estimate` as the provider would count it, on what this run has seen so far. */
15
+ scale(estimate: number): number;
16
+ }
17
+
18
+ /** The prompt half a provider reports: every input token, read from the cache or not. */
19
+ export const reportedInput = (usage: TokenUsage): number =>
20
+ usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
21
+
22
+ export function promptMeasure(): PromptMeasure {
23
+ let ratio = 1;
24
+ return {
25
+ observe(usage, estimate) {
26
+ const reported = reportedInput(usage);
27
+ // A provider reporting nothing (a cache hit, a wire with no usage) says nothing: the last
28
+ // measurement stands. Below 1 is ignored — the estimate may stay pessimistic.
29
+ if (reported <= 0 || estimate <= 0) return;
30
+ ratio = Math.max(1, reported / estimate);
31
+ },
32
+ overflowed(estimate, room) {
33
+ if (estimate <= 0) return;
34
+ ratio = Math.max(ratio, (room + 1) / estimate);
35
+ },
36
+ scale: (estimate) => Math.ceil(estimate * ratio),
37
+ };
38
+ }
@@ -0,0 +1,100 @@
1
+ // Single responsibility: the ONE request a compaction's summary call sends. The run's own request
2
+ // up to the cut, plus the instruction, when that fits the writer — it reads the cached prefix.
3
+ // Otherwise the same tools, system and first message, then the turns to summarise as TEXT,
4
+ // shortened oldest first (./agent-compact-text) — never a second call, never a dropped turn.
5
+
6
+ import { shortestFit } from './agent-compact-text';
7
+ import type { ModelId } from './models';
8
+ import type { AiContentBlock, AiMessage, GenerateRequest } from './provider';
9
+
10
+ /** How a compacted first message's summary block starts — the marker a later compaction reads. */
11
+ export const COMPACTED_MARK = '[compacted:';
12
+
13
+ /** What the summary call is asked, appended to the last message it is sent. */
14
+ 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.`;
15
+
16
+ /** Opens the text form's transcript block: what it is, and that it may have been shortened. */
17
+ export const TRANSCRIPT_NOTE = `${COMPACTED_MARK} transcript] The turns after the task, oldest first, as text: they did not fit as messages. The oldest tool results may be clipped (their start and end kept) or left out — say so in the summary where it matters.`;
18
+
19
+ /** The turns before the cut, split off the first message and the kept ones. */
20
+ export interface SummarySplit {
21
+ /** The first user message: the rendered prompt, and any earlier summary. */
22
+ readonly head: AiMessage;
23
+ /** Whole turns — an assistant message and the user message answering it — oldest first. */
24
+ readonly older: readonly AiMessage[];
25
+ }
26
+
27
+ /** How the summarised turns reached the summary call. */
28
+ export type SummaryInput =
29
+ | { readonly form: 'messages' }
30
+ | { readonly form: 'text'; readonly clipped: number; readonly omitted: number };
31
+
32
+ export interface SummaryCall {
33
+ readonly request: GenerateRequest;
34
+ readonly input: SummaryInput;
35
+ }
36
+
37
+ /**
38
+ * The summary call for `split`: as messages when `fits` takes them, else as text shortened until
39
+ * it does. `undefined` when even every tool result left out does not fit.
40
+ */
41
+ export function summaryCall(
42
+ base: GenerateRequest,
43
+ writer: ModelId,
44
+ maxTokens: number,
45
+ split: SummarySplit,
46
+ fits: (request: GenerateRequest) => boolean,
47
+ ): SummaryCall | undefined {
48
+ const asMessages = writerRequest(base, writer, maxTokens, withInstruction(split));
49
+ if (fits(asMessages)) return { request: asMessages, input: { form: 'messages' } };
50
+ const asText = (transcript: string): GenerateRequest =>
51
+ writerRequest(base, writer, maxTokens, textForm(split.head, transcript));
52
+ const fit = shortestFit(pairsOf(split.older), (text) => fits(asText(text)));
53
+ if (fit === undefined) return undefined;
54
+ const { clipped, omitted } = fit;
55
+ return { request: asText(fit.text), input: { form: 'text', clipped, omitted } };
56
+ }
57
+
58
+ /**
59
+ * The run's own tools, system and cache hint, so the provider reads the prefix the run wrote. A
60
+ * different summary model takes none of the run model's reasoning controls: one it lacks would be
61
+ * `X_AI_REQUEST_INVALID`.
62
+ */
63
+ function writerRequest(
64
+ base: GenerateRequest,
65
+ writer: ModelId,
66
+ maxTokens: number,
67
+ messages: readonly AiMessage[],
68
+ ): GenerateRequest {
69
+ if (writer === base.model) return { ...base, messages, maxTokens };
70
+ const { effort: _effort, thinking: _thinking, ...rest } = base;
71
+ return { ...rest, model: writer, messages, maxTokens };
72
+ }
73
+
74
+ /** Roles must alternate, and the older turns end on a user message: the instruction joins it. */
75
+ function withInstruction(split: SummarySplit): readonly AiMessage[] {
76
+ const messages = [split.head, ...split.older];
77
+ const last = messages.at(-1) ?? split.head;
78
+ return [...messages.slice(0, -1), { role: 'user', content: [...blocksOf(last), instruction()] }];
79
+ }
80
+
81
+ /** The first message as it was sent — its bytes, its cache — then the transcript, then the ask. */
82
+ function textForm(head: AiMessage, transcript: string): readonly AiMessage[] {
83
+ const block = { type: 'text' as const, text: `${TRANSCRIPT_NOTE}\n\n${transcript}` };
84
+ return [{ role: 'user', content: [...blocksOf(head), block, instruction()] }];
85
+ }
86
+
87
+ const instruction = (): AiContentBlock => ({ type: 'text', text: SUMMARY_INSTRUCTION });
88
+
89
+ /** The older turns, two messages each: the cut is between turns, so they pair exactly. */
90
+ function pairsOf(older: readonly AiMessage[]): readonly (readonly AiMessage[])[] {
91
+ const pairs: AiMessage[][] = [];
92
+ for (let i = 0; i < older.length; i += 2) pairs.push(older.slice(i, i + 2));
93
+ return pairs;
94
+ }
95
+
96
+ export function blocksOf(message: AiMessage): readonly AiContentBlock[] {
97
+ return typeof message.content === 'string'
98
+ ? [{ type: 'text', text: message.content }]
99
+ : message.content;
100
+ }
@@ -0,0 +1,95 @@
1
+ // Single responsibility: the turns to summarise as TEXT, shortened oldest first until they fit —
2
+ // the summary call's input when the run's own messages up to the cut do not. Tool results are
3
+ // clipped to their start and end, then left out; a turn itself is never dropped. Where the design
4
+ // came from: `docs/history/ai.md`.
5
+
6
+ import type { AiContentBlock, AiMessage } from './provider';
7
+
8
+ /** The characters of one tool result a clipped turn keeps: half from its start, half from its end. */
9
+ export const SUMMARY_CLIP_CHARS = 2_000;
10
+
11
+ /** One turn's render at each shortening: whole, its tool results clipped, its tool results left out. */
12
+ type Renders = readonly [whole: string, clipped: string, omitted: string];
13
+
14
+ /** The transcript that fit, and how far it was shortened to. */
15
+ export interface TextTranscript {
16
+ readonly text: string;
17
+ /** Turns, oldest first, whose tool results were clipped or left out. */
18
+ readonly clipped: number;
19
+ /** ...of those, the turns whose tool results were left out entirely. */
20
+ readonly omitted: number;
21
+ }
22
+
23
+ /**
24
+ * `value` cut to `max` characters, the first and last halves kept — the end of a tool result is
25
+ * where its error, its count or its last row is. By code point, so no surrogate pair is split.
26
+ * Never longer than `value`: a cut that would add more marker than it removes is not made.
27
+ */
28
+ export function clipText(value: string, max: number = SUMMARY_CLIP_CHARS): string {
29
+ if (value.length <= max) return value;
30
+ const chars = Array.from(value);
31
+ const half = Math.floor(max / 2);
32
+ const left = chars.length - 2 * half;
33
+ if (left <= 0) return value;
34
+ const clipped = `${chars.slice(0, half).join('')}\n[… ${left} characters left out …]\n${chars.slice(-half).join('')}`;
35
+ return clipped.length < value.length ? clipped : value;
36
+ }
37
+
38
+ /**
39
+ * The least shortening of `turns` (each an assistant message and the user message answering it)
40
+ * that `fits`: every turn reaches a level before any turn reaches the next, oldest first.
41
+ * `undefined` when even every tool result left out does not fit.
42
+ */
43
+ export function shortestFit(
44
+ turns: readonly (readonly AiMessage[])[],
45
+ fits: (text: string) => boolean,
46
+ ): TextTranscript | undefined {
47
+ const renders = turns.map(rendersOf);
48
+ const n = renders.length;
49
+ // Shortening `s` (0..2n): turns before `s` clipped, turns before `s - n` left out. Each step
50
+ // only ever shortens one turn, so the size falls with `s` and the least `s` that fits is found
51
+ // by bisection — a handful of estimates, never one per turn.
52
+ const at = (s: number): string =>
53
+ renders.map((render, i) => render[(i < s ? 1 : 0) + (i < s - n ? 1 : 0)]).join('\n\n');
54
+ if (!fits(at(2 * n))) return undefined;
55
+ let low = 0;
56
+ let high = 2 * n;
57
+ while (low < high) {
58
+ const mid = Math.floor((low + high) / 2);
59
+ if (fits(at(mid))) high = mid;
60
+ else low = mid + 1;
61
+ }
62
+ return { text: at(low), clipped: Math.min(low, n), omitted: Math.max(0, low - n) };
63
+ }
64
+
65
+ function rendersOf(turn: readonly AiMessage[]): Renders {
66
+ const whole = render(turn, 0);
67
+ // A level is never longer than the one before it, so the bisection above stays sound.
68
+ const clipped = shorter(render(turn, 1), whole);
69
+ return [whole, clipped, shorter(render(turn, 2), clipped)];
70
+ }
71
+
72
+ const shorter = (a: string, b: string): string => (a.length < b.length ? a : b);
73
+
74
+ const render = (turn: readonly AiMessage[], level: 0 | 1 | 2): string =>
75
+ turn
76
+ .flatMap((message) => blocksOf(message).map((block) => line(message.role, block, level)))
77
+ .filter((text) => text !== '')
78
+ .join('\n');
79
+
80
+ function line(role: AiMessage['role'], block: AiContentBlock, level: 0 | 1 | 2): string {
81
+ if (block.type === 'text') return block.text === '' ? '' : `[${role}]: ${block.text}`;
82
+ if (block.type === 'tool_use') {
83
+ const input = JSON.stringify(block.input) ?? '';
84
+ return `[tool call ${block.name} #${block.id}]: ${level === 2 ? clipText(input) : input}`;
85
+ }
86
+ if (block.type === 'tool_result') {
87
+ const label = `${block.is_error === true ? 'tool error' : 'tool result'} #${block.tool_use_id}`;
88
+ if (level === 2) return `[${label} left out: ${block.content.length} characters]`;
89
+ return `[${label}]: ${level === 1 ? clipText(block.content) : block.content}`;
90
+ }
91
+ return `[${block.type} left out]`;
92
+ }
93
+
94
+ const blocksOf = (message: AiMessage): readonly AiContentBlock[] =>
95
+ typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content;
@@ -8,25 +8,27 @@
8
8
  // change, once, and the next turn writes the new, shorter prefix.
9
9
  //
10
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.
11
+ // transcript that does not fit is `X_AGENT_CONTEXT_EXHAUSTED` before it is sent. Only the summary
12
+ // call's INPUT is ever shortened (./agent-compact-request), when the turns do not fit it whole. The trigger reads
13
+ // the estimate scaled by what the provider REPORTED (./agent-compact-measure); a provider that
14
+ // refuses a request anyway (`X_AI_CONTEXT_OVERFLOW`) gets one forced compaction, then that code.
12
15
 
13
16
  import type { Ctx, Span } from '@ultimat3/core';
14
17
  import { stringField, throwIfAborted } from '@ultimat3/core';
15
18
  import type { AgentCompaction, CompactPlan } from './agent-compact-def';
19
+ import { promptMeasure } from './agent-compact-measure';
20
+ import type { SummaryInput, SummarySplit } from './agent-compact-request';
21
+ import { blocksOf, COMPACTED_MARK, summaryCall } from './agent-compact-request';
16
22
  import { AgentContextExhaustedError } from './agent-errors';
17
23
  import type { RunDeadline } from './agent-guards';
18
24
  import type { Gateway } from './gateway';
19
25
  import type { ModelId } from './models';
20
26
  import { modelSpec } from './models';
21
- import type { AiContentBlock, AiMessage, GenerateRequest, GenerateResult } from './provider';
27
+ import type { AiMessage, GenerateRequest, GenerateResult, TokenUsage } from './provider';
22
28
  import { estimateInputTokens, estimateTextTokens, isTruncated } from './provider';
23
29
  import { runMeter } from './run-meter';
24
30
 
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.`;
31
+ export { COMPACTED_MARK, SUMMARY_INSTRUCTION } from './agent-compact-request';
30
32
 
31
33
  /** The run's own stops: thrown as themselves from the summary call, never read as its failure. */
32
34
  const RUN_STOPS: ReadonlySet<string> = new Set([
@@ -55,6 +57,17 @@ export interface CompactorInput {
55
57
  export interface Compactor {
56
58
  /** The messages the next turn sends: as given, compacted, or refused. */
57
59
  fit(messages: readonly AiMessage[], turn: number): Promise<readonly AiMessage[]>;
60
+ /** A turn answered: its reported input corrects every later estimate of this run. */
61
+ observe(usage: TokenUsage, request: GenerateRequest): void;
62
+ /**
63
+ * The provider refused `messages` as over the window (`refusal` is what it said): compact
64
+ * regardless of the trigger, or throw `X_AGENT_CONTEXT_EXHAUSTED`. Never returns them as given.
65
+ */
66
+ force(
67
+ messages: readonly AiMessage[],
68
+ turn: number,
69
+ refusal: string,
70
+ ): Promise<readonly AiMessage[]>;
58
71
  /** Compactions so far, continuations included — what the handle's `compacted` carries. */
59
72
  count(): number;
60
73
  }
@@ -84,6 +97,9 @@ export function compactor(input: CompactorInput): Compactor {
84
97
  // itself: each attempt is a billed call, and one that just failed is tried again only when the
85
98
  // run can no longer go on without it — never once per turn while it still fits.
86
99
  let trigger = plan.at * limit;
100
+ // Reported over estimated, per run: a continuation's first turn estimates plainly.
101
+ const measure = promptMeasure();
102
+ const measured = (request: GenerateRequest): number => measure.scale(promptTokens(request));
87
103
 
88
104
  const report = async (event: AgentCompaction): Promise<void> => {
89
105
  span.addEvent('agent.compact', {
@@ -95,20 +111,31 @@ export function compactor(input: CompactorInput): Compactor {
95
111
  'agent.compact.summarised_turns': event.summarised,
96
112
  'agent.compact.kept_turns': event.kept,
97
113
  ...(event.reason === undefined ? {} : { 'agent.compact.reason': event.reason }),
114
+ ...(event.summaryInput === undefined
115
+ ? {}
116
+ : {
117
+ 'agent.compact.summary_input': event.summaryInput,
118
+ 'agent.compact.clipped_turns': event.clipped ?? 0,
119
+ 'agent.compact.omitted_turns': event.omitted ?? 0,
120
+ }),
98
121
  ...(event.cost === undefined ? {} : { 'llm.cost.minor': event.cost.minor }),
99
122
  });
100
123
  await input.onCompact?.(event);
101
124
  };
102
125
 
103
- const fit = async (
126
+ /** `refusal` set: forced by a provider's overflow, so a failure can never go on whole. */
127
+ const attempt = async (
104
128
  messages: readonly AiMessage[],
105
129
  turn: number,
130
+ refusal: string | undefined,
106
131
  ): Promise<readonly AiMessage[]> => {
107
- const before = promptTokens({ ...base, messages });
108
- if (before <= trigger) return messages;
132
+ const before = measured({ ...base, messages });
133
+ if (refusal === undefined && before <= trigger) return messages;
109
134
  const facts = { turn, before, limit, model: writer };
110
135
  const split = splitTurns(messages, plan.keepTurns);
111
136
  const kept = split === undefined ? Math.floor(messages.length / 2) : plan.keepTurns;
137
+ // How the turns reached the summary call, once one is built — reported on either outcome.
138
+ let shape: SummaryInput | undefined;
112
139
  const failed = async (
113
140
  reason: string,
114
141
  summarised: number,
@@ -122,18 +149,20 @@ export function compactor(input: CompactorInput): Compactor {
122
149
  summarised,
123
150
  kept,
124
151
  compactions: count,
152
+ ...inputFacts(shape),
125
153
  ...(call === undefined ? {} : { usage: call.usage, cost: call.cost }),
126
154
  });
127
155
  // Still fits: the run goes on whole. Compaction is how a run stays under the window, never
128
156
  // a condition of running — the turns that fit are not refused for a summary that failed.
129
157
  trigger = limit;
130
- if (before <= limit) return messages;
158
+ if (refusal === undefined && before <= limit) return messages;
131
159
  throw new AgentContextExhaustedError({
132
160
  agent: input.agent,
133
161
  model,
134
162
  estimate: before,
135
163
  limit,
136
164
  reason,
165
+ ...(refusal === undefined ? {} : { refused: refusal }),
137
166
  });
138
167
  };
139
168
 
@@ -141,10 +170,22 @@ export function compactor(input: CompactorInput): Compactor {
141
170
  return failed(`there is no turn older than the last ${plan.keepTurns} to summarise`, 0);
142
171
  }
143
172
  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);
173
+ const room = roomOf(writer, plan.maxSummaryTokens);
174
+ const call = summaryCall(
175
+ base,
176
+ writer,
177
+ plan.maxSummaryTokens,
178
+ split,
179
+ (r) => measured(r) <= room,
180
+ );
181
+ if (call === undefined) {
182
+ return failed(
183
+ `the turns to summarise do not fit model "${writer}" even as text with every tool result left out`,
184
+ summarised,
185
+ );
147
186
  }
187
+ const { request } = call;
188
+ shape = call.input;
148
189
  throwIfAborted(input.ctx);
149
190
  input.deadline.check();
150
191
  input.assertCost(request);
@@ -172,7 +213,7 @@ export function compactor(input: CompactorInput): Compactor {
172
213
  }
173
214
  if (summary === '') return failed('the summary call answered no text', summarised, result);
174
215
  const compacted = [compactedHead(split.head, summarised, summary), ...split.kept];
175
- const after = promptTokens({ ...base, messages: compacted });
216
+ const after = measured({ ...base, messages: compacted });
176
217
  if (after >= before) {
177
218
  return failed('the summary was no shorter than the turns it replaced', summarised, result);
178
219
  }
@@ -188,20 +229,26 @@ export function compactor(input: CompactorInput): Compactor {
188
229
  summarised,
189
230
  kept,
190
231
  compactions: count,
232
+ ...inputFacts(shape),
191
233
  usage: result.usage,
192
234
  cost: result.cost,
193
235
  });
194
236
  return compacted;
195
237
  };
196
238
 
197
- return { fit, count: () => count };
239
+ return {
240
+ fit: (messages, turn) => attempt(messages, turn, undefined),
241
+ observe: (usage, request) => measure.observe(usage, promptTokens(request)),
242
+ force: (messages, turn, refusal) => {
243
+ // The provider counted more than the room: every estimate from here on is at least that.
244
+ measure.overflowed(promptTokens({ ...base, messages }), limit);
245
+ return attempt(messages, turn, refusal);
246
+ },
247
+ count: () => count,
248
+ };
198
249
  }
199
250
 
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[];
251
+ interface Split extends SummarySplit {
205
252
  /** The last `keepTurns` turns, verbatim. */
206
253
  readonly kept: readonly AiMessage[];
207
254
  }
@@ -220,31 +267,6 @@ function splitTurns(messages: readonly AiMessage[], keepTurns: number): Split |
220
267
  return { head, older: messages.slice(1, cut), kept: messages.slice(cut) };
221
268
  }
222
269
 
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
270
  /**
249
271
  * The first message, rebuilt: the prompt as it was sent (an earlier summary dropped — the new one
250
272
  * covers it), then the summary, marked. The model reads it as its own notes, said to be so.
@@ -257,8 +279,11 @@ function compactedHead(head: AiMessage, turns: number, summary: string): AiMessa
257
279
  return { role: 'user', content: [...prompt, { type: 'text', text: note }] };
258
280
  }
259
281
 
260
- function blocksOf(message: AiMessage): readonly AiContentBlock[] {
261
- return typeof message.content === 'string'
262
- ? [{ type: 'text', text: message.content }]
263
- : message.content;
282
+ /** The event's facts about the summary call's input: its form, and how far text was shortened. */
283
+ function inputFacts(
284
+ shape: SummaryInput | undefined,
285
+ ): Pick<AgentCompaction, 'summaryInput' | 'clipped' | 'omitted'> {
286
+ if (shape === undefined) return {};
287
+ if (shape.form === 'messages') return { summaryInput: 'messages' };
288
+ return { summaryInput: 'text', clipped: shape.clipped, omitted: shape.omitted };
264
289
  }
@@ -20,6 +20,7 @@ import {
20
20
  canonicalJson,
21
21
  currentSigningSecret,
22
22
  fingerprint,
23
+ hmac,
23
24
  isProduction,
24
25
  isUltimateError,
25
26
  usesDevCursorSecret,
@@ -83,6 +84,19 @@ export interface TranscriptBinding {
83
84
  readonly org: string | null;
84
85
  }
85
86
 
87
+ /**
88
+ * `onCheckpoint`'s event: the run so far as a handle, after turn `turn`'s tool results or its
89
+ * correction landed and before turn `turn + 1` is bought. The same signed, bound shape a finished
90
+ * run returns, so `.continue(input, { transcript, user })` resumes from it and the screen refuses
91
+ * an edited or cross-tenant copy as it refuses any other.
92
+ */
93
+ export interface AgentCheckpoint {
94
+ readonly turn: number;
95
+ readonly transcript: AgentTranscript;
96
+ /** The run's total so far, continuations included. */
97
+ readonly run: AgentRunUsage;
98
+ }
99
+
86
100
  /** What `.run()` and `.continue()` resolve to: the validated answer and the handle to go on from. */
87
101
  export interface AgentRun<TOutput> {
88
102
  readonly output: TOutput;
@@ -218,9 +232,7 @@ const toolsMark = (names: readonly string[]): string => fingerprint([...names].s
218
232
 
219
233
  function signatureOf(fields: Omit<AgentTranscript, 'sig'>): string {
220
234
  // The framework's one signing secret (`ULTIMATE_CURSOR_SECRET`), under this file's own label.
221
- return new Bun.CryptoHasher('sha256', currentSigningSecret())
222
- .update(`${PURPOSE}\n${canonicalJson(fields)}`)
223
- .digest('hex');
235
+ return hmac('sha256', currentSigningSecret(), `${PURPOSE}\n${canonicalJson(fields)}`, 'hex');
224
236
  }
225
237
 
226
238
  /** A handle for `state`, bound and signed. The one place a transcript is written. */
package/src/agent-def.ts CHANGED
@@ -6,6 +6,7 @@ import type { Ctx } from '@ultimat3/core';
6
6
  import type { Money } from '@ultimat3/money';
7
7
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
8
8
  import type { AgentCompaction, AgentCompactOption } from './agent-compact-def';
9
+ import type { AgentCheckpoint } from './agent-continue';
9
10
  import type { AgentSystem } from './agent-system';
10
11
  import type { AgentRunUsage } from './agent-usage';
11
12
  import type { LlmBudget } from './llm';
@@ -173,6 +174,14 @@ export interface AgentDef<
173
174
  * throw fails the run. Also an `agent.compact` span event.
174
175
  */
175
176
  onCompact?(event: AgentCompaction): void | Promise<void>;
177
+ /**
178
+ * Called after each turn whose tool results (or correction) landed, before the next turn is
179
+ * bought — never after the answer, which `.run()` returns. Its `transcript` is a signed handle:
180
+ * store it, and a worker that died resumes with `.continue(input, { transcript, user })` instead
181
+ * of re-buying every turn. Awaited; a throw fails the run. Sealed only when declared. `args` is
182
+ * the run's own input and context, as `vars()` gets them: where the handle goes is the app's row.
183
+ */
184
+ onCheckpoint?(event: AgentCheckpoint, args: AgentVarsArgs<TInput>): void | Promise<void>;
176
185
  }
177
186
 
178
187
  /** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
@@ -1,6 +1,6 @@
1
1
  // The X_AGENT_* codes an UNATTENDED run needs, and the one a continuation's handle is refused by, apart from ./errors only because one file has
2
2
  // one job and that catalogue is at its ceiling — the split `hive-errors.ts` made. The codes, their
3
- // titles and the single `registerErrorCodes` call stay in ./errors.
3
+ // titles and the single `registerErrorCodes` call stay in ./error-codes.
4
4
 
5
5
  import { UltimateError } from '@ultimat3/core';
6
6
 
@@ -101,10 +101,19 @@ export class AgentContextExhaustedError extends UltimateError {
101
101
  limit: number;
102
102
  /** Why compaction did not bring it under. */
103
103
  reason: string;
104
+ /**
105
+ * What the provider said when it refused the request as over the window
106
+ * (`X_AI_CONTEXT_OVERFLOW`): the run was SENT, and the refusal is the measurement.
107
+ */
108
+ refused?: string;
104
109
  }) {
110
+ const size =
111
+ input.refused === undefined
112
+ ? `next request is estimated at ${input.estimate} prompt tokens, over the ${input.limit} model "${input.model}" has room for (contextWindow less maxTokens)`
113
+ : `request was refused by the provider as over model "${input.model}"'s context window (${input.refused}); ${input.limit} tokens is its room (contextWindow less maxTokens)`;
105
114
  super({
106
115
  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}`,
116
+ cause: `agent "${input.agent}"'s ${size}, and compaction could not bring it under: ${input.reason}`,
108
117
  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
118
  meta: {
110
119
  agent: input.agent,
@@ -112,6 +121,7 @@ export class AgentContextExhaustedError extends UltimateError {
112
121
  estimate: input.estimate,
113
122
  limit: input.limit,
114
123
  reason: input.reason,
124
+ ...(input.refused === undefined ? {} : { refused: input.refused }),
115
125
  },
116
126
  });
117
127
  }
@@ -83,6 +83,14 @@ export function describeAgents(): readonly AgentFact[] {
83
83
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
84
84
  }
85
85
 
86
+ /**
87
+ * One agent's facts, named yet or not — how `defineEval({ agent })` learns the prompt it measures.
88
+ * `undefined` for an action `agent()` did not build, and for a `named()` twin (see above).
89
+ */
90
+ export function agentFactsOf(target: AnyAction): Omit<AgentFact, 'name'> | undefined {
91
+ return registry.get(target)?.();
92
+ }
93
+
86
94
  /** Test-only reset. A module-level registry otherwise leaks between test files. */
87
95
  export function resetAgents(): void {
88
96
  registry.clear();