@ultimat3/ai 29.4.0 → 30.1.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 +63 -90
- package/README.md +125 -8
- package/package.json +23 -11
- package/src/agent-checkpoint.ts +44 -0
- package/src/agent-compact-def.ts +11 -0
- package/src/agent-compact-measure.ts +38 -0
- package/src/agent-compact-request.ts +100 -0
- package/src/agent-compact-text.ts +95 -0
- package/src/agent-compact.ts +75 -50
- package/src/agent-continue.ts +15 -3
- package/src/agent-def.ts +9 -0
- package/src/agent-errors.ts +12 -2
- package/src/agent-facts.ts +8 -0
- package/src/agent-send.ts +82 -0
- package/src/agent-stream.ts +76 -0
- package/src/agent.ts +71 -21
- package/src/answer-errors.ts +1 -1
- package/src/anthropic-provider.ts +291 -0
- package/src/content-errors.ts +1 -1
- package/src/context-overflow.ts +86 -0
- package/src/error-codes.ts +95 -0
- package/src/errors-retry.ts +8 -4
- package/src/errors.ts +6 -87
- package/src/eval-agent.ts +100 -0
- package/src/eval-errors.ts +31 -5
- package/src/evals.ts +125 -45
- package/src/gateway-providers.ts +215 -0
- package/src/gateway.ts +63 -153
- package/src/hive-errors.ts +1 -1
- package/src/in-process-tool.ts +20 -11
- package/src/index.ts +31 -7
- package/src/llm.ts +39 -8
- package/src/model-resolve.ts +7 -1
- package/src/openai-body.ts +12 -5
- package/src/openai-provider.ts +28 -8
- package/src/openai-wire.ts +11 -7
- package/src/prompt-cache-rows.ts +82 -0
- package/src/prompt-cache.ts +24 -2
- package/src/provider.ts +15 -274
- package/src/remote-embedder.ts +38 -11
- package/src/run-meter.ts +40 -2
- package/src/runtime.ts +38 -3
- package/src/scorers.ts +35 -1
- package/src/scripted-provider.ts +146 -0
- package/src/tools-gate.ts +46 -0
- package/src/tools.ts +101 -27
- 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
|
+
}
|
package/src/agent-compact-def.ts
CHANGED
|
@@ -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;
|
package/src/agent-compact.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
}
|
package/src/agent-continue.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/agent-errors.ts
CHANGED
|
@@ -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 ./
|
|
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
|
|
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
|
}
|
package/src/agent-facts.ts
CHANGED
|
@@ -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();
|