@aragon-agent/core 0.2.14 → 0.2.16

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Pure compaction mechanics (context-auto-compaction §3.6.1 / §3.6.2 / §5.3).
3
+ *
4
+ * NO I/O, NO CLOCK, NO PROVIDER KNOWLEDGE, NO `node:*` IMPORT. This module is
5
+ * the engine's whole contribution to compaction: where a history may safely be
6
+ * cut, which cut to take, and — the one that matters — whether a history the
7
+ * host handed back is structurally sendable at all.
8
+ *
9
+ * WHY THE ENGINE OWNS VALIDATION AND NOTHING ELSE (D-4). A compacted history
10
+ * that strands a `tool_result` without its `tool_call` is an immediate HTTP 400
11
+ * on Anthropic: `convertMessages` emits one `user` message carrying a
12
+ * `tool_use_id` per `tool_result`, and the API rejects a `tool_use_id` with no
13
+ * `tool_use` before it. That failure is not recoverable by the host and must not
14
+ * be recoverable BY POLICY — so the engine refuses to adopt an invalid history
15
+ * and continues with the original. A host bug becomes "compaction did not
16
+ * happen" rather than "the session is now permanently un-sendable" (R-2).
17
+ */
18
+ import type { Message } from '../llm/types.js';
19
+ export interface CompactionPlan {
20
+ /** Head is `[0, cutIndex)`; tail is `[cutIndex, end)`. */
21
+ cutIndex: number;
22
+ droppedMessages: number;
23
+ retainedTurns: number;
24
+ }
25
+ export type HistoryCheck = {
26
+ ok: true;
27
+ } | {
28
+ ok: false;
29
+ reason: 'empty' | 'orphan_tool_result' | 'unclosed_tool_call' | 'grew';
30
+ };
31
+ /**
32
+ * Every index at which the history may be split without stranding a tool call.
33
+ *
34
+ * `i` is SAFE iff, walking from 0, the set of open tool calls is empty at the
35
+ * boundary before `i` AND `messages[i]` is not a `tool_result`.
36
+ *
37
+ * A SET-BASED WALK RATHER THAN A ROLE-PATTERN RULE, and that is deliberate: the
38
+ * history is not always the clean `user -> assistant -> tool_result*` shape the
39
+ * loop produces. Steering interruption pushes synthesized `tool_result`s for the
40
+ * REMAINING calls in a batch, CodeAct injects a bare `user` message, and
41
+ * `/resume` restores whatever a file contained. A rule that encodes the happy
42
+ * path is a rule that produces an invalid splice on the one history that got
43
+ * interesting.
44
+ */
45
+ export declare function findSafeCutIndices(messages: readonly Message[]): number[];
46
+ export interface PlanCompactionOptions {
47
+ keepRecentTurns: number;
48
+ /**
49
+ * Messages at the head that are ALREADY structurally preserved by the host
50
+ * (the goal anchor, and a prior `<compacted_context>` block right after it).
51
+ *
52
+ * THIS PARAMETER IS WHAT MAKES REPEATED COMPACTION TERMINATE CLEANLY (P1-9 /
53
+ * D-21). Without it the condition would have to be "the best cut is 0 or 1",
54
+ * which encodes an assumption about what messages 0 and 1 ARE — and after one
55
+ * compaction they are the anchor and the summary block, so a second pass would
56
+ * re-summarize a summary with no new material, produce a smaller and lossier
57
+ * record, and report success.
58
+ *
59
+ * The HOST counts it (it is the host that knows what an `<original_task>`
60
+ * message is) and passes the number; core stays free of any knowledge of the
61
+ * block format, which is the §1.2 boundary. Hard-coding `1` here, or teaching
62
+ * this module the tag, are both ways of putting host knowledge in the engine
63
+ * to save a parameter.
64
+ */
65
+ protectedPrefix?: number;
66
+ }
67
+ /**
68
+ * The largest safe cut that still leaves at least `keepRecentTurns` complete
69
+ * turns in the tail, or `null` when there is nothing worth dropping.
70
+ *
71
+ * `null` — rather than a zero-length head — is what lets §3.8 turn "the recent
72
+ * turns alone exceed the threshold" into a bounded, loud outcome instead of a
73
+ * retry loop against a paid API.
74
+ */
75
+ export declare function planCompaction(messages: readonly Message[], opts: PlanCompactionOptions): CompactionPlan | null;
76
+ export interface ValidateHistoryOptions {
77
+ /**
78
+ * The length of the history being REPLACED. Supplied positionally by the loop
79
+ * so the `grew` rung is live: a compaction that returns more messages than it
80
+ * was given is not a correctness failure, but it is always a bug, and adopting
81
+ * it would let a broken compactor amplify the very problem it exists to solve.
82
+ */
83
+ previousLength?: number;
84
+ }
85
+ /**
86
+ * THE GATE (§3.3 step 5). One walk, the same open-set as `findSafeCutIndices`.
87
+ *
88
+ * Also exported for the host's IDLE `/compact` path (§4.4 / D-25), which cannot
89
+ * reach the loop's gate because there is no loop — and D-4 declares that gate
90
+ * unbypassable.
91
+ */
92
+ export declare function validateHistory(messages: readonly Message[], opts?: ValidateHistoryOptions): HistoryCheck;
93
+ export interface TailReliefOptions {
94
+ /** Index the tail starts at; nothing before it is touched. */
95
+ from: number;
96
+ /** Clip each oversized `tool_result` body to this many characters. */
97
+ clipChars: number;
98
+ /** Stop as soon as the estimate falls below this. */
99
+ targetTokens: number;
100
+ /** System prompt, so the estimate is measured the same way everywhere. */
101
+ systemPrompt: string;
102
+ /** Appended where text was removed. The host supplies it; core has no glyphs. */
103
+ marker: (removed: number) => string;
104
+ }
105
+ export interface TailReliefResult {
106
+ messages: Message[];
107
+ clippedMessages: number;
108
+ charsRemoved: number;
109
+ }
110
+ /**
111
+ * Clip oversized `tool_result` bodies inside a retained tail until the whole
112
+ * history is projected under `targetTokens` — the last rung of the ladder
113
+ * (context-auto-compaction-hardening §3.3).
114
+ *
115
+ * WHY THIS EXISTS. Every other rung operates on the HEAD: summarize it, retry
116
+ * the summarization, drop it without a summary. When the TAIL is what does not
117
+ * fit — `keepRecentTurns` turns carrying several 100 KB tool results — each of
118
+ * those rungs reports success or `nothing_to_drop` while the history stays
119
+ * un-sendable, and the run dies with no rung left.
120
+ *
121
+ * FOUR RULES, EACH LOAD-BEARING:
122
+ *
123
+ * 1. ONLY `tool_result` BODIES ARE ELIGIBLE. Not `user` messages (the anchor
124
+ * and the user's own instructions are the last things to damage), not
125
+ * `assistant` messages (clipping the model's own reasoning mid-tail produces
126
+ * a history that contradicts itself). The size argument is the bound: a tool
127
+ * result is capped at 100 KB EACH with several per turn, while an assistant
128
+ * message is capped by the turn's `maxOutputTokens`. The tail is blown by
129
+ * results, not by calls.
130
+ * 2. OLDEST FIRST. The newest tool result is the one the next request is most
131
+ * likely to be about, so the walk starts at `from` and stops the moment the
132
+ * projection is under budget.
133
+ * 3. STRUCTURE IS NEVER CHANGED. No message is removed, no `toolCallId` is
134
+ * touched, no role changes — so `validateHistory` cannot fail on a relieved
135
+ * history that was valid before. That is what makes this safe to run AFTER a
136
+ * splice, and it is the reason relief CLIPS rather than DROPS.
137
+ * 4. EVERY CLIP IS ANNOUNCED IN THE TEXT THE MODEL READS, at the exact place
138
+ * the data was removed. A model that sees the marker knows the output is
139
+ * partial and can re-run the tool; a model handed a silently truncated file
140
+ * does not.
141
+ *
142
+ * PURE, AND IT DOES NOT MUTATE ITS INPUT. Untouched messages keep their identity
143
+ * in the returned array, which is what lets a caller assert "nothing before
144
+ * `from` changed" by reference rather than by deep equality.
145
+ */
146
+ export declare function relieveTail(messages: readonly Message[], opts: TailReliefOptions): TailReliefResult;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Pure compaction mechanics (context-auto-compaction §3.6.1 / §3.6.2 / §5.3).
3
+ *
4
+ * NO I/O, NO CLOCK, NO PROVIDER KNOWLEDGE, NO `node:*` IMPORT. This module is
5
+ * the engine's whole contribution to compaction: where a history may safely be
6
+ * cut, which cut to take, and — the one that matters — whether a history the
7
+ * host handed back is structurally sendable at all.
8
+ *
9
+ * WHY THE ENGINE OWNS VALIDATION AND NOTHING ELSE (D-4). A compacted history
10
+ * that strands a `tool_result` without its `tool_call` is an immediate HTTP 400
11
+ * on Anthropic: `convertMessages` emits one `user` message carrying a
12
+ * `tool_use_id` per `tool_result`, and the API rejects a `tool_use_id` with no
13
+ * `tool_use` before it. That failure is not recoverable by the host and must not
14
+ * be recoverable BY POLICY — so the engine refuses to adopt an invalid history
15
+ * and continues with the original. A host bug becomes "compaction did not
16
+ * happen" rather than "the session is now permanently un-sendable" (R-2).
17
+ */
18
+ import { estimatePromptTokens } from '../llm/output-limits.js';
19
+ /**
20
+ * Every index at which the history may be split without stranding a tool call.
21
+ *
22
+ * `i` is SAFE iff, walking from 0, the set of open tool calls is empty at the
23
+ * boundary before `i` AND `messages[i]` is not a `tool_result`.
24
+ *
25
+ * A SET-BASED WALK RATHER THAN A ROLE-PATTERN RULE, and that is deliberate: the
26
+ * history is not always the clean `user -> assistant -> tool_result*` shape the
27
+ * loop produces. Steering interruption pushes synthesized `tool_result`s for the
28
+ * REMAINING calls in a batch, CodeAct injects a bare `user` message, and
29
+ * `/resume` restores whatever a file contained. A rule that encodes the happy
30
+ * path is a rule that produces an invalid splice on the one history that got
31
+ * interesting.
32
+ */
33
+ export function findSafeCutIndices(messages) {
34
+ const safe = [];
35
+ const open = new Set();
36
+ for (let i = 0; i < messages.length; i += 1) {
37
+ const m = messages[i];
38
+ if (open.size === 0 && m.role !== 'tool_result')
39
+ safe.push(i);
40
+ if (m.role === 'assistant') {
41
+ for (const b of m.content)
42
+ if (b.type === 'tool_call')
43
+ open.add(b.toolCallId);
44
+ }
45
+ else if (m.role === 'tool_result') {
46
+ open.delete(m.toolCallId);
47
+ }
48
+ }
49
+ if (open.size === 0)
50
+ safe.push(messages.length);
51
+ return safe;
52
+ }
53
+ /** A "turn" is a `user`-role message; `tool_result` is a separate role here. */
54
+ function countTurns(messages, from) {
55
+ let turns = 0;
56
+ for (let i = from; i < messages.length; i += 1) {
57
+ if (messages[i].role === 'user')
58
+ turns += 1;
59
+ }
60
+ return turns;
61
+ }
62
+ /**
63
+ * The largest safe cut that still leaves at least `keepRecentTurns` complete
64
+ * turns in the tail, or `null` when there is nothing worth dropping.
65
+ *
66
+ * `null` — rather than a zero-length head — is what lets §3.8 turn "the recent
67
+ * turns alone exceed the threshold" into a bounded, loud outcome instead of a
68
+ * retry loop against a paid API.
69
+ */
70
+ export function planCompaction(messages, opts) {
71
+ const keep = Math.max(1, Math.floor(opts.keepRecentTurns));
72
+ const protectedPrefix = Math.max(0, Math.floor(opts.protectedPrefix ?? 0));
73
+ const safe = findSafeCutIndices(messages);
74
+ for (let i = safe.length - 1; i >= 0; i -= 1) {
75
+ const cutIndex = safe[i];
76
+ // The tail must still be a conversation, not an empty suffix.
77
+ if (cutIndex >= messages.length)
78
+ continue;
79
+ const retainedTurns = countTurns(messages, cutIndex);
80
+ if (retainedTurns < keep)
81
+ continue;
82
+ if (cutIndex <= protectedPrefix)
83
+ return null;
84
+ return {
85
+ cutIndex,
86
+ droppedMessages: cutIndex - protectedPrefix,
87
+ retainedTurns,
88
+ };
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * THE GATE (§3.3 step 5). One walk, the same open-set as `findSafeCutIndices`.
94
+ *
95
+ * Also exported for the host's IDLE `/compact` path (§4.4 / D-25), which cannot
96
+ * reach the loop's gate because there is no loop — and D-4 declares that gate
97
+ * unbypassable.
98
+ */
99
+ export function validateHistory(messages, opts = {}) {
100
+ if (!Array.isArray(messages) || messages.length === 0)
101
+ return { ok: false, reason: 'empty' };
102
+ if (opts.previousLength !== undefined && messages.length > opts.previousLength) {
103
+ return { ok: false, reason: 'grew' };
104
+ }
105
+ const open = new Set();
106
+ for (const m of messages) {
107
+ if (m.role === 'assistant') {
108
+ for (const b of m.content)
109
+ if (b.type === 'tool_call')
110
+ open.add(b.toolCallId);
111
+ }
112
+ else if (m.role === 'tool_result') {
113
+ if (!open.has(m.toolCallId))
114
+ return { ok: false, reason: 'orphan_tool_result' };
115
+ open.delete(m.toolCallId);
116
+ }
117
+ }
118
+ if (open.size > 0)
119
+ return { ok: false, reason: 'unclosed_tool_call' };
120
+ return { ok: true };
121
+ }
122
+ /**
123
+ * Clip oversized `tool_result` bodies inside a retained tail until the whole
124
+ * history is projected under `targetTokens` — the last rung of the ladder
125
+ * (context-auto-compaction-hardening §3.3).
126
+ *
127
+ * WHY THIS EXISTS. Every other rung operates on the HEAD: summarize it, retry
128
+ * the summarization, drop it without a summary. When the TAIL is what does not
129
+ * fit — `keepRecentTurns` turns carrying several 100 KB tool results — each of
130
+ * those rungs reports success or `nothing_to_drop` while the history stays
131
+ * un-sendable, and the run dies with no rung left.
132
+ *
133
+ * FOUR RULES, EACH LOAD-BEARING:
134
+ *
135
+ * 1. ONLY `tool_result` BODIES ARE ELIGIBLE. Not `user` messages (the anchor
136
+ * and the user's own instructions are the last things to damage), not
137
+ * `assistant` messages (clipping the model's own reasoning mid-tail produces
138
+ * a history that contradicts itself). The size argument is the bound: a tool
139
+ * result is capped at 100 KB EACH with several per turn, while an assistant
140
+ * message is capped by the turn's `maxOutputTokens`. The tail is blown by
141
+ * results, not by calls.
142
+ * 2. OLDEST FIRST. The newest tool result is the one the next request is most
143
+ * likely to be about, so the walk starts at `from` and stops the moment the
144
+ * projection is under budget.
145
+ * 3. STRUCTURE IS NEVER CHANGED. No message is removed, no `toolCallId` is
146
+ * touched, no role changes — so `validateHistory` cannot fail on a relieved
147
+ * history that was valid before. That is what makes this safe to run AFTER a
148
+ * splice, and it is the reason relief CLIPS rather than DROPS.
149
+ * 4. EVERY CLIP IS ANNOUNCED IN THE TEXT THE MODEL READS, at the exact place
150
+ * the data was removed. A model that sees the marker knows the output is
151
+ * partial and can re-run the tool; a model handed a silently truncated file
152
+ * does not.
153
+ *
154
+ * PURE, AND IT DOES NOT MUTATE ITS INPUT. Untouched messages keep their identity
155
+ * in the returned array, which is what lets a caller assert "nothing before
156
+ * `from` changed" by reference rather than by deep equality.
157
+ */
158
+ export function relieveTail(messages, opts) {
159
+ const out = [...messages];
160
+ const from = Math.max(0, Math.floor(opts.from));
161
+ const clipChars = Math.max(0, Math.floor(opts.clipChars));
162
+ let clippedMessages = 0;
163
+ let charsRemoved = 0;
164
+ // RE-ESTIMATED AFTER EVERY CLIP rather than decremented, and the cost is not
165
+ // the reason to worry about it: the array this walks is one retained tail, not
166
+ // a session. `estimatePromptTokens` rounds, so a hand-rolled running total
167
+ // would drift from the number the caller's budget check uses — and the whole
168
+ // point of this function is to land under THAT number.
169
+ let projected = estimatePromptTokens(out, opts.systemPrompt);
170
+ for (let i = from; i < out.length; i += 1) {
171
+ if (projected <= opts.targetTokens)
172
+ break;
173
+ const message = out[i];
174
+ if (message.role !== 'tool_result')
175
+ continue;
176
+ const clipped = clipToolResultContent(message.content, clipChars, opts.marker);
177
+ if (clipped === null)
178
+ continue;
179
+ out[i] = { ...message, content: clipped.content };
180
+ clippedMessages += 1;
181
+ charsRemoved += clipped.removed;
182
+ projected = estimatePromptTokens(out, opts.systemPrompt);
183
+ }
184
+ return { messages: out, clippedMessages, charsRemoved };
185
+ }
186
+ /**
187
+ * One `tool_result` body, clipped — or `null` when there was nothing to remove.
188
+ *
189
+ * A PART ARRAY IS CLIPPED LONGEST-FIRST, and only its `text` parts. `image`
190
+ * parts are left alone: the estimator charges them a flat rate that has nothing
191
+ * to do with their length, so clipping one removes characters without removing
192
+ * tokens, and re-encoding an image is a different feature.
193
+ */
194
+ function clipToolResultContent(content, clipChars, marker) {
195
+ if (typeof content === 'string') {
196
+ if (content.length <= clipChars)
197
+ return null;
198
+ const removed = content.length - clipChars;
199
+ return { content: content.slice(0, clipChars) + marker(removed), removed };
200
+ }
201
+ if (!Array.isArray(content))
202
+ return null;
203
+ const order = content
204
+ .map((part, index) => ({ index, length: part.type === 'text' ? part.text.length : -1 }))
205
+ .filter((entry) => entry.length > clipChars)
206
+ .sort((a, b) => b.length - a.length);
207
+ if (order.length === 0)
208
+ return null;
209
+ const parts = [...content];
210
+ let total = parts.reduce((sum, p) => sum + (p.type === 'text' ? p.text.length : 0), 0);
211
+ let removed = 0;
212
+ for (const entry of order) {
213
+ if (total <= clipChars)
214
+ break;
215
+ const part = parts[entry.index];
216
+ if (part.type !== 'text')
217
+ continue;
218
+ const cut = part.text.length - clipChars;
219
+ parts[entry.index] = { type: 'text', text: part.text.slice(0, clipChars) + marker(cut) };
220
+ removed += cut;
221
+ total -= cut;
222
+ }
223
+ if (removed === 0)
224
+ return null;
225
+ return { content: parts, removed };
226
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The injected context-management port (context-auto-compaction §3.2).
3
+ *
4
+ * TYPES ONLY, ZERO LOGIC, and the only imports are erased type imports from
5
+ * `llm/types.js` and `agent-loop.js`. The engine does not know what a context
6
+ * window IS — `Agent` holds a `ModelRef`, never a `ModelInfo` — and it must not
7
+ * learn, because `ModelRegistry` lives one layer up and
8
+ * `no-host-coupling.test.ts` rule 2 forbids this package from reaching for a
9
+ * host. So the engine gains ONE port it calls at a turn boundary, and every
10
+ * decision behind it — the threshold, which model summarizes, what the summary
11
+ * asks for, what happens on failure — is made on the host's side of it (D-2).
12
+ */
13
+ import type { Message, TokenUsage } from '../llm/types.js';
14
+ import type { ModelRef } from './agent-loop.js';
15
+ /** Why compaction is being considered right now. */
16
+ export type CompactionTrigger = 'pressure' | 'overflow';
17
+ /** What the engine can cheaply tell the host at a turn boundary. */
18
+ export interface CompactionProbe {
19
+ readonly messageCount: number;
20
+ /**
21
+ * Authoritative usage of the most recently COMPLETED turn, or `undefined`
22
+ * before the first one (a fresh run, or the first turn after `/resume`).
23
+ *
24
+ * THE HOST MUST HANDLE `undefined` (§3.4.2). It is not an edge case: the most
25
+ * dangerous single moment in this feature's life is the first request of a
26
+ * resumed 180 k-token session, where there is no usage to read and the naive
27
+ * answer is "0 %".
28
+ */
29
+ readonly lastUsage?: TokenUsage;
30
+ /** 1-based index of the turn that is about to be sent. */
31
+ readonly turnIndex: number;
32
+ readonly trigger: CompactionTrigger;
33
+ }
34
+ export interface CompactionContext extends CompactionProbe {
35
+ /**
36
+ * A SHALLOW COPY of the engine's history, never the live array (P1-2).
37
+ *
38
+ * `MessageManager.getAll()` returns the internal array itself (the "readonly
39
+ * view" in its doc comment is a TYPE, not a copy). Passing that across the
40
+ * port would let a host that sorts, splices or truncates it corrupt engine
41
+ * state in place, BEFORE the structural gate at §3.3 step 5 can look at it —
42
+ * which would defeat the one job the engine has in this feature. One array
43
+ * allocation per compaction, i.e. at most `maxPerRun` per run, buys the whole
44
+ * guarantee.
45
+ */
46
+ readonly messages: readonly Message[];
47
+ readonly systemPrompt: string;
48
+ readonly model: ModelRef;
49
+ /**
50
+ * The run's signal. The host MUST forward it into its own LLM call.
51
+ *
52
+ * The engine does not TRUST that it does: `runCompaction` races this promise
53
+ * against the signal and against a hard ceiling (§3.3), because the watchdog
54
+ * is paused across the call and a non-settling promise would otherwise hang
55
+ * the run with the idle detector switched off (P1-3).
56
+ */
57
+ readonly signal: AbortSignal;
58
+ }
59
+ export type CompactionOutcome = {
60
+ action: 'keep';
61
+ reason: string;
62
+ } | {
63
+ action: 'replace';
64
+ messages: Message[];
65
+ mode: 'summarized' | 'truncated';
66
+ /** For the event; the engine does not interpret it. */
67
+ summary?: string;
68
+ reason?: string;
69
+ };
70
+ export interface ContextManager {
71
+ /**
72
+ * SYNCHRONOUS, CHEAP, SIDE-EFFECT FREE. Called once per turn, before the LLM
73
+ * request is built.
74
+ *
75
+ * IT IS SYNCHRONOUS SO THAT "OFF" AND "BELOW THRESHOLD" COST NOTHING (D-3).
76
+ * A single async port would put an `await` — a microtask tick and a promise
77
+ * allocation — on every turn of every run for a decision that is `false` more
78
+ * than 99 % of the time, and would make it impossible to state that a session
79
+ * below the threshold is byte-identical to a pre-feature build.
80
+ */
81
+ shouldCompact(probe: CompactionProbe): boolean;
82
+ /**
83
+ * Only called when `shouldCompact` returned `true`.
84
+ *
85
+ * MUST NOT THROW. The engine wraps the call defensively anyway (§3.3 step 4),
86
+ * because a host that breaks its own contract must degrade to "compaction did
87
+ * not happen", never to "the run died" — but a throwing implementation is a
88
+ * bug on the host side and is reported as one through `compaction_end.reason`.
89
+ */
90
+ compact(ctx: CompactionContext): Promise<CompactionOutcome>;
91
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The injected context-management port (context-auto-compaction §3.2).
3
+ *
4
+ * TYPES ONLY, ZERO LOGIC, and the only imports are erased type imports from
5
+ * `llm/types.js` and `agent-loop.js`. The engine does not know what a context
6
+ * window IS — `Agent` holds a `ModelRef`, never a `ModelInfo` — and it must not
7
+ * learn, because `ModelRegistry` lives one layer up and
8
+ * `no-host-coupling.test.ts` rule 2 forbids this package from reaching for a
9
+ * host. So the engine gains ONE port it calls at a turn boundary, and every
10
+ * decision behind it — the threshold, which model summarizes, what the summary
11
+ * asks for, what happens on failure — is made on the host's side of it (D-2).
12
+ */
13
+ export {};
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { ContentPart, TextContentPart, ImageContentPart, ContentBlock, Text
9
9
  export { THINKING_BUDGET } from './llm/types.js';
10
10
  export type { LLMProvider, LLMRequest, LLMErrorType, } from './llm/provider.js';
11
11
  export { LLMError, classifyHttpError, wrapFetchError, } from './llm/provider.js';
12
- export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, } from './llm/output-limits.js';
12
+ export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, CHARS_PER_TOKEN, } from './llm/output-limits.js';
13
13
  export type { OutputLimitInput, OutputLimitResolution, CeilingSource, } from './llm/output-limits.js';
14
14
  export { classifyOutputLimitFailure, sendWithOutputLimitRecovery, learnTokenField, getLearnedTokenField, clearLearnedTokenFields, } from './llm/output-limit-recovery.js';
15
15
  export { RETRY_LIMITS, DEFAULT_RETRY_POLICY, isRetryableError, computeBackoffDelay, parseRetryAfterMs, withRetry, } from './llm/retry.js';
@@ -41,7 +41,7 @@ export { createSkillFindTool } from './skills/skill-find-tool.js';
41
41
  export { computeToolPolicy, evaluateToolCall } from './skills/tool-policy.js';
42
42
  export { SKILL_CATALOG_MAX_BYTES, SKILL_BODY_MAX_BYTES, SKILL_RESULT_MAX_BYTES, ALWAYS_SKILLS_MAX_BYTES, SKILL_MD_MAX_BYTES, SKILL_DESC_LINE_MAX, SKILL_FILES_MAX, SKILL_FIND_MAX_BYTES, SKILL_NAME_PATTERN, } from './skills/constants.js';
43
43
  export type { SkillScope, SkillActivation, SkillFrontmatter, SkillRecord, SkillFileRef, SkillHost, SkillManifest, SkillValidationIssue, SkillCatalogOptions, SkillBodyOptions, SkillFindOptions, SkillIntegrity, SkillUsageStat, SkillUsageMap, SkillInvocationOptions, SkillPlatform, SkillPolicyView, SkillToolPolicyMode, ToolPolicyDecision, ToolPolicyInput, ToolPolicySource, ToolPolicyVerdict, } from './skills/types.js';
44
- export type { AgentEvent, AgentEventListener, AgentStartEvent, AgentEndEvent, TurnStartEvent, TurnEndEvent, MessageUpdateEvent, ToolExecutionStartEvent, ToolExecutionEndEvent, CodeExecutionStartEvent, CodeExecutionEndEvent, } from './types.js';
44
+ export type { AgentEvent, AgentEventListener, AgentStartEvent, AgentEndEvent, TurnStartEvent, TurnEndEvent, MessageUpdateEvent, ToolExecutionStartEvent, ToolExecutionEndEvent, CodeExecutionStartEvent, CodeExecutionEndEvent, CompactionStartEvent, CompactionEndEvent, } from './types.js';
45
45
  export { Agent } from './engine/agent.js';
46
46
  export type { AgentConfig, AgentState } from './engine/agent.js';
47
47
  export type { ModelRef } from './engine/agent-loop.js';
@@ -49,5 +49,8 @@ export type { CodeActSandbox, CodeActResult } from './engine/agent-loop.js';
49
49
  export { MessageManager } from './engine/message-manager.js';
50
50
  export { MessageQueueManager } from './engine/steering.js';
51
51
  export { IdleWatchdog } from './engine/watchdog.js';
52
+ export { findSafeCutIndices, planCompaction, relieveTail, validateHistory, } from './engine/compaction.js';
53
+ export type { CompactionPlan, HistoryCheck, PlanCompactionOptions, TailReliefOptions, TailReliefResult, ValidateHistoryOptions, } from './engine/compaction.js';
54
+ export type { ContextManager, CompactionProbe, CompactionContext, CompactionOutcome, CompactionTrigger, } from './engine/context-manager.js';
52
55
  export type { SandboxConfig, ExecuteOptions, ExecuteResult, ConsoleEntry, SandboxToolCall, } from './sandbox/types.js';
53
56
  export { Agent as AragonAgent } from './engine/agent.js';
package/dist/index.js CHANGED
@@ -11,7 +11,11 @@ export { LLMError, classifyHttpError, wrapFetchError, } from './llm/provider.js'
11
11
  // Output-token limits — the authority for "how many tokens may this model
12
12
  // produce". Every adapter resolves through here; nothing else spells 64000.
13
13
  // ---------------------------------------------------------------------------
14
- export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, } from './llm/output-limits.js';
14
+ export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens,
15
+ // Promoted from module-private for the compaction digest's budget arithmetic
16
+ // (context-auto-compaction P2-3): two chars-per-token constants that must agree
17
+ // and nothing checking that they do is how a budget and an estimate drift.
18
+ CHARS_PER_TOKEN, } from './llm/output-limits.js';
15
19
  export { classifyOutputLimitFailure, sendWithOutputLimitRecovery, learnTokenField, getLearnedTokenField, clearLearnedTokenFields, } from './llm/output-limit-recovery.js';
16
20
  // ---------------------------------------------------------------------------
17
21
  // Retry & exponential backoff — the authority for "a provider call failed, now
@@ -71,6 +75,19 @@ export { Agent } from './engine/agent.js';
71
75
  export { MessageManager } from './engine/message-manager.js';
72
76
  export { MessageQueueManager } from './engine/steering.js';
73
77
  export { IdleWatchdog } from './engine/watchdog.js';
78
+ // ---------------------------------------------------------------------------
79
+ // Context compaction (context-auto-compaction §4.1)
80
+ //
81
+ // THREE PURE FUNCTIONS AND FIVE TYPES. The mechanism is here; the policy — the
82
+ // threshold, the summarizer, the prompt, the failure ladder — belongs to the
83
+ // host, because this package has no `ModelInfo` and must not learn one (D-2).
84
+ //
85
+ // `validateHistory` is public for a specific reason: a host that splices while
86
+ // the agent is IDLE cannot reach the loop's own gate, and D-4 declares that gate
87
+ // unbypassable. Exporting it is what makes honouring D-4 on the manual path cost
88
+ // three lines instead of a second implementation.
89
+ // ---------------------------------------------------------------------------
90
+ export { findSafeCutIndices, planCompaction, relieveTail, validateHistory, } from './engine/compaction.js';
74
91
  // Sandbox exports are NOT re-exported here — isolated-vm is incompatible with Electron's V8.
75
92
  // Import directly from './sandbox/*.js' when needed (lazy loading only).
76
93
  // ---------------------------------------------------------------------------
@@ -96,6 +96,16 @@ export interface OutputLimitResolution {
96
96
  * `0` or a negative.
97
97
  */
98
98
  export declare function resolveOutputTokens(input: OutputLimitInput): OutputLimitResolution;
99
+ /**
100
+ * Average characters per token across the model families this package speaks to.
101
+ *
102
+ * EXPORTED (context-auto-compaction P2-3). It was module-private, which would
103
+ * have forced the CLI's compaction digest to grow a second copy for its budget
104
+ * arithmetic — two chars-per-token constants that must agree and nothing
105
+ * checking that they do is exactly how a digest budget and an occupancy estimate
106
+ * drift apart. No behaviour change here.
107
+ */
108
+ export declare const CHARS_PER_TOKEN = 4;
99
109
  /**
100
110
  * Deliberately crude: ~4 chars/token plus per-message framing. Used ONLY to keep
101
111
  * `max_tokens + prompt <= context` on OpenAI, where being 20 % wrong is absorbed
@@ -222,8 +222,16 @@ export function resolveOutputTokens(input) {
222
222
  // ---------------------------------------------------------------------------
223
223
  // Prompt estimation
224
224
  // ---------------------------------------------------------------------------
225
- /** Average characters per token across the model families this package speaks to. */
226
- const CHARS_PER_TOKEN = 4;
225
+ /**
226
+ * Average characters per token across the model families this package speaks to.
227
+ *
228
+ * EXPORTED (context-auto-compaction P2-3). It was module-private, which would
229
+ * have forced the CLI's compaction digest to grow a second copy for its budget
230
+ * arithmetic — two chars-per-token constants that must agree and nothing
231
+ * checking that they do is exactly how a digest budget and an occupancy estimate
232
+ * drift apart. No behaviour change here.
233
+ */
234
+ export const CHARS_PER_TOKEN = 4;
227
235
  /** Role markers, separators and tool-call scaffolding, per message. */
228
236
  const PER_MESSAGE_OVERHEAD_TOKENS = 4;
229
237
  function partLength(content) {
@@ -19,6 +19,20 @@ export interface ToolExecutorOptions {
19
19
  timeoutOverrides?: Record<string, number>;
20
20
  /** Maximum size (in bytes) of combined text output before truncation. Defaults to 100_000 (100 KB). */
21
21
  maxOutputSize?: number;
22
+ /**
23
+ * How long, after an abort is signalled, a tool has to settle before its
24
+ * promise is ABANDONED and an error result is returned in its place.
25
+ * Defaults to 5000.
26
+ *
27
+ * THE INVARIANT BEING PROTECTED IS "AN ABORT IS ALWAYS ANSWERABLE", AND AN
28
+ * INVARIANT THAT DEPENDS ON EVERY TOOL AUTHOR REMEMBERING IS NOT AN
29
+ * INVARIANT. Step 5 below used to be a bare `await tool.execute(...)`: the
30
+ * signal was delivered, the tool was free to ignore it, and the loop waited
31
+ * forever — so the host's "press Esc to stop" did nothing at all in exactly
32
+ * the situation where a user most needs it. Racing here makes the guarantee a
33
+ * property of the executor rather than of the toolset it happens to be given.
34
+ */
35
+ abortGraceMs?: number;
22
36
  }
23
37
  /**
24
38
  * Executes tool calls with validation, timeout management,
@@ -30,6 +44,7 @@ export declare class ToolExecutor {
30
44
  private readonly defaultTimeout;
31
45
  private readonly timeoutOverrides;
32
46
  private readonly maxOutputSize;
47
+ private readonly abortGraceMs;
33
48
  constructor(registry: ToolRegistry, options?: ToolExecutorOptions);
34
49
  /**
35
50
  * Execute a tool call end-to-end.