@aragon-agent/core 0.2.13 → 0.2.15

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/CHANGELOG.md CHANGED
@@ -9,6 +9,67 @@ were written.
9
9
 
10
10
  ### Added
11
11
 
12
+ - **Tail relief — the last rung of the compaction ladder.** `relieveTail` joins
13
+ the root barrel with `TailReliefOptions` and `TailReliefResult` (85 → 86
14
+ runtime exports), and `compaction_end` gains an optional `tailRelief` field.
15
+
16
+ Every existing rung operates on the HEAD of a history. When the RETAINED tail
17
+ is what does not fit — a few turns carrying several 100 KB tool results — each
18
+ of them reports success or "nothing to drop" while the conversation stays
19
+ un-sendable. `relieveTail` clips oversized `tool_result` bodies in place,
20
+ oldest first, and stops as soon as the projection is under budget.
21
+
22
+ It is **structure-preserving by construction**: no message is removed, no
23
+ `toolCallId` is touched, no role changes, so a history that passed
24
+ `validateHistory` before still passes after. That is what makes it safe to run
25
+ on an already-spliced history, and it is why relief clips rather than drops.
26
+ Every clip is announced in the text the model reads, at the exact place the
27
+ data was removed, through a `marker` callback the host supplies — this package
28
+ owns no glyphs.
29
+
30
+ `compaction_end.tailRelief` is optional, so a subscriber that does not know
31
+ about it is unaffected.
32
+
33
+ - **Context compaction — one injected port, and the engine refuses a bad
34
+ splice.** `AgentConfig.contextManager?: ContextManager` is called at every turn
35
+ boundary (after the steering drain, before `turn_start`). When it says so, the
36
+ engine hands it a **copy** of the history, adopts whatever comes back — and
37
+ validates it first.
38
+
39
+ `findSafeCutIndices`, `planCompaction` and `validateHistory` join the root
40
+ barrel, along with `CHARS_PER_TOKEN`, which was module-private (81 → 85 runtime
41
+ exports). Two new `AgentEvent` members, `compaction_start` and
42
+ `compaction_end`, carry the story; a consumer with a `default` branch is
43
+ unaffected.
44
+
45
+ **The mechanism is here; the policy is the host's.** This package holds a
46
+ `ModelRef` and never a `ModelInfo`, so the threshold, the summarizer, the
47
+ prompt and the failure ladder are all decisions on the other side of the port.
48
+
49
+ Four properties are load-bearing and each is asserted by a test:
50
+
51
+ - **Off is free.** With no `contextManager` the checkpoint is one `if` against
52
+ an absent field — no promise allocation, no events, no behaviour change.
53
+ - **The engine validates and may refuse.** An orphan `tool_result`, an unclosed
54
+ `tool_call`, an empty array or a history that grew is not adopted; the run
55
+ continues on the original and `compaction_end` reports
56
+ `invalid_history: <reason>`. An orphan `tool_result` is an unrecoverable 400
57
+ on Anthropic, so a host bug must not be able to produce one.
58
+ - **The call cannot hang the run.** `compaction_start` pauses the idle
59
+ watchdog, so the engine races `compact()` against the run's signal **and** a
60
+ 120 s ceiling of its own, and emits `compaction_end` from a `finally` — which
61
+ is what resumes the watchdog. A non-settling promise, an ignored signal or a
62
+ malformed return value each degrade to "compaction did not happen".
63
+ - **The host gets a copy.** `ctx.messages` is a shallow copy, so a host that
64
+ splices it in place cannot corrupt engine state before the gate sees it.
65
+
66
+ **`compaction` is now a legitimate word in the engine**, which makes RT-4's
67
+ original claim ("no history-trimming API exists") false by design. That guard
68
+ now asserts the narrower and more useful thing: history shrinks in exactly one
69
+ place, and that place goes through `validateHistory`. The skill digest's
70
+ `force=true` escape hatch — added for a *truncated* earlier copy — covers a
71
+ *compacted-away* one unchanged, and is pinned by a test.
72
+
12
73
  - **`llm/retry.ts` — retry with exponential backoff, ON BY DEFAULT.**
13
74
  `RETRY_LIMITS`, `DEFAULT_RETRY_POLICY`, `isRetryableError`,
14
75
  `computeBackoffDelay`, `parseRetryAfterMs` and `withRetry` are exported from the
@@ -12,6 +12,7 @@ import type { ToolRegistry } from '../tools/registry.js';
12
12
  import type { AgentEvent } from '../types.js';
13
13
  import type { MessageManager } from './message-manager.js';
14
14
  import type { MessageQueueManager } from './steering.js';
15
+ import type { ContextManager } from './context-manager.js';
15
16
  import type { CodeActSandbox } from '../sandbox/types.js';
16
17
  export type { CodeActSandbox };
17
18
  export type { ExecuteResult as CodeActResult } from '../sandbox/types.js';
@@ -35,12 +36,46 @@ export interface AgentLoopContext {
35
36
  /** Maximum output tokens per LLM call. `undefined` = use provider default. */
36
37
  maxTokens?: number;
37
38
  signal: AbortSignal;
39
+ /**
40
+ * Optional context-compaction port (context-auto-compaction §3.2).
41
+ *
42
+ * `undefined` MEANS THE ENGINE NEVER COMPACTS, and every code path is
43
+ * byte-identical to a pre-feature build: the checkpoint below is one `if`
44
+ * against a field that is genuinely absent, not a live object whose predicate
45
+ * says no (AC-1).
46
+ */
47
+ contextManager?: ContextManager;
38
48
  timeouts: {
39
49
  llmCallTimeout: number;
40
50
  toolTimeout: number;
41
51
  codeTimeout: number;
52
+ /**
53
+ * The engine's ceiling on ONE `compact()` call. Defaults to
54
+ * `COMPACTION_HARD_TIMEOUT_MS`.
55
+ *
56
+ * OVERRIDABLE ONLY SO AC-10a IS TESTABLE IN UNDER TWO MINUTES. It is NOT a
57
+ * host policy knob: the whole point of the ceiling is that it does not depend
58
+ * on the host being correct, so raising it in production re-opens exactly the
59
+ * hang it exists to bound (P1-3 / R-13).
60
+ */
61
+ compactionHardTimeout?: number;
42
62
  };
43
63
  }
64
+ /**
65
+ * A CEILING, NOT A POLICY (P1-3). It must sit STRICTLY ABOVE the largest wall
66
+ * clock a well-behaved host can legitimately spend, or it starts firing on
67
+ * correct hosts and the failure ladder's rungs stop being reachable. The CLI's
68
+ * worst legal case is two `callTimeoutMs` calls back to back (45 s + 45 s = 90 s)
69
+ * plus the digest render, so 90 s would be exactly the boundary — this is 120 s,
70
+ * leaving 30 s of slack.
71
+ *
72
+ * It is deliberately longer than `DEFAULT_IDLE_TIMEOUT` (60 s): `compaction_start`
73
+ * PAUSES the watchdog, so across this call this is the only clock running. An
74
+ * `await` with no ceiling here would be strictly worse than the pre-feature
75
+ * behaviour — a host that returns a non-settling promise would hang the run AND
76
+ * there would be nothing left to notice.
77
+ */
78
+ export declare const COMPACTION_HARD_TIMEOUT_MS = 120000;
44
79
  /**
45
80
  * Run the Agent loop until completion, steering exhaustion, or abort.
46
81
  *
@@ -5,6 +5,205 @@
5
5
  * cycle, with steering/follow-up checkpoints and optional CodeAct code
6
6
  * block detection and execution.
7
7
  */
8
+ import { validateHistory } from './compaction.js';
9
+ import { estimatePromptTokens } from '../llm/output-limits.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Compaction checkpoint (context-auto-compaction §3.3)
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * A CEILING, NOT A POLICY (P1-3). It must sit STRICTLY ABOVE the largest wall
15
+ * clock a well-behaved host can legitimately spend, or it starts firing on
16
+ * correct hosts and the failure ladder's rungs stop being reachable. The CLI's
17
+ * worst legal case is two `callTimeoutMs` calls back to back (45 s + 45 s = 90 s)
18
+ * plus the digest render, so 90 s would be exactly the boundary — this is 120 s,
19
+ * leaving 30 s of slack.
20
+ *
21
+ * It is deliberately longer than `DEFAULT_IDLE_TIMEOUT` (60 s): `compaction_start`
22
+ * PAUSES the watchdog, so across this call this is the only clock running. An
23
+ * `await` with no ceiling here would be strictly worse than the pre-feature
24
+ * behaviour — a host that returns a non-settling promise would hang the run AND
25
+ * there would be nothing left to notice.
26
+ */
27
+ export const COMPACTION_HARD_TIMEOUT_MS = 120_000;
28
+ function errText(err) {
29
+ return err instanceof Error ? err.message : String(err);
30
+ }
31
+ /**
32
+ * Coerce whatever the host resolved with into a `CompactionOutcome`.
33
+ *
34
+ * THE TYPE IS NOT THE GUARANTEE (implementation finding IF-2). `ContextManager`
35
+ * is an interface a host implements in its own package, so at this boundary the
36
+ * value is untyped at runtime: a stub, a mock, a host that forgot a `return`, or
37
+ * a `Promise<void>` all arrive here as `undefined`. Reading `.action` off that
38
+ * throws OUTSIDE the inner `catch` — after `compaction_end` has been emitted from
39
+ * the `finally`, so the watchdog is correctly resumed, but the throw still
40
+ * escapes `runCompaction` and kills the run.
41
+ *
42
+ * That is exactly the failure §3.3 step 4 says must not happen: "a host that
43
+ * breaks its own contract must degrade to 'compaction did not happen', never to
44
+ * 'the run died'". The inner `catch` covers a REJECTED promise; this covers a
45
+ * promise that resolves with the wrong thing, which is the commoner mistake.
46
+ */
47
+ function normalizeOutcome(value) {
48
+ const outcome = value;
49
+ if (!outcome || typeof outcome !== 'object') {
50
+ return { action: 'keep', reason: 'manager_bad_outcome' };
51
+ }
52
+ if (outcome.action === 'replace') {
53
+ if (!Array.isArray(outcome.messages)) {
54
+ return { action: 'keep', reason: 'manager_bad_outcome' };
55
+ }
56
+ return outcome;
57
+ }
58
+ if (outcome.action === 'keep')
59
+ return outcome;
60
+ return { action: 'keep', reason: 'manager_bad_outcome' };
61
+ }
62
+ /**
63
+ * Race the host's promise against the run's signal and a hard ceiling.
64
+ *
65
+ * RESOLVES rather than rejects on both loss conditions, so the two ladder rungs
66
+ * stay distinguishable in the event and in the log. The losing promise is left
67
+ * to settle on its own; its result is dropped, and it cannot reach `restore`
68
+ * because `applied` is decided by the caller.
69
+ */
70
+ async function raceCompaction(work, signal, timeoutMs) {
71
+ if (signal.aborted)
72
+ return { action: 'keep', reason: 'aborted' };
73
+ let timer;
74
+ let onAbort;
75
+ try {
76
+ return await Promise.race([
77
+ work,
78
+ new Promise((resolve) => {
79
+ timer = setTimeout(() => resolve({ action: 'keep', reason: 'manager_timeout' }), timeoutMs);
80
+ }),
81
+ new Promise((resolve) => {
82
+ onAbort = () => resolve({ action: 'keep', reason: 'aborted' });
83
+ signal.addEventListener('abort', onAbort, { once: true });
84
+ }),
85
+ ]);
86
+ }
87
+ finally {
88
+ if (timer !== undefined)
89
+ clearTimeout(timer);
90
+ if (onAbort)
91
+ signal.removeEventListener('abort', onAbort);
92
+ }
93
+ }
94
+ /**
95
+ * One compaction checkpoint. Returns whether a new history was ADOPTED.
96
+ *
97
+ * Five steps, and step 5 is the whole reason the engine is involved at all
98
+ * (D-4): the host proposes, the engine validates, and an invalid history is
99
+ * refused rather than adopted.
100
+ */
101
+ async function runCompaction(ctx, params) {
102
+ const cm = ctx.contextManager;
103
+ if (!cm)
104
+ return false; // 1. off: zero cost, zero allocation
105
+ const probe = {
106
+ messageCount: ctx.messageManager.length,
107
+ trigger: params.trigger,
108
+ turnIndex: params.turnIndex,
109
+ ...(params.lastUsage ? { lastUsage: params.lastUsage } : {}),
110
+ };
111
+ let wanted = false;
112
+ try {
113
+ wanted = cm.shouldCompact(probe); // 2. sync gate
114
+ }
115
+ catch (err) {
116
+ // A throwing predicate is a host bug, and it must not take the run down.
117
+ ctx.emit({ type: 'compaction_start', trigger: params.trigger, messageCount: probe.messageCount });
118
+ ctx.emit({
119
+ type: 'compaction_end',
120
+ applied: false,
121
+ mode: 'none',
122
+ reason: `manager_threw: ${errText(err)}`,
123
+ messagesBefore: probe.messageCount,
124
+ messagesAfter: probe.messageCount,
125
+ droppedMessages: 0,
126
+ estimatedTokensBefore: 0,
127
+ estimatedTokensAfter: 0,
128
+ durationMs: 0,
129
+ });
130
+ return false;
131
+ }
132
+ if (!wanted)
133
+ return false;
134
+ // A SHALLOW COPY, NOT THE LIVE ARRAY (P1-2). `getAll()` hands back the internal
135
+ // array; `as Message[]` would launder away the only thing stopping a host from
136
+ // mutating engine state in place, before step 5 can see it.
137
+ const before = [...ctx.messageManager.getAll()];
138
+ const estimatedTokensBefore = estimatePromptTokens(before, ctx.systemPrompt);
139
+ // 3. Pauses the watchdog (`Agent.applyWatchdogPolicy`), applied BEFORE the
140
+ // listeners so a subscriber that throws cannot leave the run armed.
141
+ ctx.emit({ type: 'compaction_start', trigger: params.trigger, messageCount: before.length });
142
+ const started = Date.now();
143
+ let applied = false;
144
+ let mode = 'none';
145
+ let reason;
146
+ let summary;
147
+ let after = before;
148
+ try {
149
+ let outcome;
150
+ try {
151
+ // 4. BOUNDED AND RACED, because step 3 just switched the idle detector off.
152
+ // NORMALIZED, because the interface is not enforced at this boundary.
153
+ outcome = normalizeOutcome(await raceCompaction(cm.compact({
154
+ ...probe,
155
+ messages: before,
156
+ systemPrompt: ctx.systemPrompt,
157
+ model: ctx.model,
158
+ signal: ctx.signal,
159
+ }), ctx.signal, params.hardTimeoutMs ?? ctx.timeouts.compactionHardTimeout ?? COMPACTION_HARD_TIMEOUT_MS));
160
+ }
161
+ catch (err) {
162
+ outcome = { action: 'keep', reason: `manager_threw: ${errText(err)}` };
163
+ }
164
+ if (outcome.action === 'keep') {
165
+ reason = outcome.reason;
166
+ }
167
+ else {
168
+ // 5. THE STRUCTURAL GATE. Positional `previousLength` keeps the `grew`
169
+ // rung live.
170
+ const check = validateHistory(outcome.messages, { previousLength: before.length });
171
+ if (check.ok) {
172
+ ctx.messageManager.restore(outcome.messages);
173
+ after = outcome.messages;
174
+ applied = true;
175
+ mode = outcome.mode;
176
+ summary = outcome.summary;
177
+ reason = outcome.reason;
178
+ }
179
+ else {
180
+ reason = `invalid_history: ${check.reason}`;
181
+ }
182
+ }
183
+ }
184
+ finally {
185
+ // IN A `finally`. `compaction_end` is what RESUMES the watchdog, so it must
186
+ // be emitted on every exit from this function — including a `throw` that
187
+ // escapes the two guards above. A compaction that failed to emit its `end`
188
+ // leaves the run permanently deaf.
189
+ ctx.emit({
190
+ type: 'compaction_end',
191
+ applied,
192
+ mode,
193
+ ...(reason !== undefined ? { reason } : {}),
194
+ messagesBefore: before.length,
195
+ messagesAfter: after.length,
196
+ droppedMessages: Math.max(0, before.length - after.length),
197
+ estimatedTokensBefore,
198
+ estimatedTokensAfter: applied
199
+ ? estimatePromptTokens(after, ctx.systemPrompt)
200
+ : estimatedTokensBefore,
201
+ ...(summary !== undefined ? { summary } : {}),
202
+ durationMs: Date.now() - started,
203
+ });
204
+ }
205
+ return applied;
206
+ }
8
207
  // ---------------------------------------------------------------------------
9
208
  // CodeAct regex for extracting ```execute-js blocks
10
209
  // ---------------------------------------------------------------------------
@@ -52,6 +251,16 @@ function getToolCalls(message) {
52
251
  * for emitting `agent_end` in its own finally block.
53
252
  */
54
253
  export async function runAgentLoop(ctx) {
254
+ // ----- Compaction bookkeeping (context-auto-compaction §3.3) --------------
255
+ //
256
+ // ALL THREE ARE HOISTED ABOVE THE `while`, and only two of them are ever reset
257
+ // inside it — after a stream COMPLETES, never at the top of the loop.
258
+ /** Authoritative usage of the most recently completed turn (P1-1). */
259
+ let lastUsage;
260
+ /** 1-based, ONE PER ITERATION — guard 1's cooldown unit (P2-1). */
261
+ let turnIndex = 0;
262
+ /** NOT RESET AT THE TOP OF THE LOOP (P0-1). See the note at `turn_end`. */
263
+ let overflowRecovered = false;
55
264
  while (!ctx.signal.aborted) {
56
265
  // ----- Steering checkpoint (before LLM call) -----
57
266
  if (ctx.messageQueueManager.hasSteering()) {
@@ -62,6 +271,21 @@ export async function runAgentLoop(ctx) {
62
271
  }
63
272
  // Continue to LLM call with injected steering messages.
64
273
  }
274
+ turnIndex += 1;
275
+ // ----- Context pressure checkpoint (context-auto-compaction §3.3) -----
276
+ //
277
+ // PLACEMENT IS LOAD-BEARING IN THREE WAYS.
278
+ // 1. AFTER the steering drain, so a message the user just steered in is
279
+ // part of the history being measured and is inside the retained tail by
280
+ // construction (it is the newest message).
281
+ // 2. BEFORE `turn_start`, so the UI reads compaction as a phase of its own
282
+ // rather than as a stall inside a turn.
283
+ // 3. INSIDE the `while`, not before it. A run that starts under the
284
+ // threshold and crosses it at turn 12 is the normal case, and it is the
285
+ // case an `agent_end`-based design cannot serve at all (D-1).
286
+ await runCompaction(ctx, { trigger: 'pressure', lastUsage, turnIndex });
287
+ if (ctx.signal.aborted)
288
+ break;
65
289
  // ----- Turn start -----
66
290
  ctx.emit({ type: 'turn_start' });
67
291
  // ----- Build LLM request -----
@@ -86,18 +310,43 @@ export async function runAgentLoop(ctx) {
86
310
  thinkingLevel: ctx.thinkingLevel,
87
311
  maxTokens: ctx.maxTokens,
88
312
  });
89
- for await (const event of stream) {
313
+ try {
314
+ for await (const event of stream) {
315
+ if (ctx.signal.aborted)
316
+ break;
317
+ // Forward every stream event to subscribers.
318
+ ctx.emit({ type: 'message_update', streamEvent: event });
319
+ if (event.type === 'done') {
320
+ assistantMessage = event.message;
321
+ usage = event.usage;
322
+ }
323
+ else if (event.type === 'error') {
324
+ throw event.error;
325
+ }
326
+ }
327
+ }
328
+ catch (err) {
329
+ // ----- Reactive compaction (context-auto-compaction §3.5) -----
330
+ //
331
+ // The static context window is a GUESS for anything the table has not seen
332
+ // (`buildRuntimeModel` returns 128 000 for every unknown model), so a proxy
333
+ // in front of a 32 k model refuses the request before the proactive trigger
334
+ // could ever fire. One recovery turns that from a dead run into a re-send.
335
+ //
336
+ // STRUCTURAL CHECK, NOT `instanceof LLMError`: the error may have crossed a
337
+ // module boundary or been rebuilt, and an identity check on the class is the
338
+ // version of this test that fails in production and passes in the unit test
339
+ // (`retry.ts::isRetryableError` records the same reasoning).
340
+ const errorType = err?.errorType;
341
+ if (errorType !== 'context_overflow' || overflowRecovered || !ctx.contextManager)
342
+ throw err;
343
+ overflowRecovered = true;
344
+ const recovered = await runCompaction(ctx, { trigger: 'overflow', lastUsage, turnIndex });
345
+ if (!recovered)
346
+ throw err;
90
347
  if (ctx.signal.aborted)
91
348
  break;
92
- // Forward every stream event to subscribers.
93
- ctx.emit({ type: 'message_update', streamEvent: event });
94
- if (event.type === 'done') {
95
- assistantMessage = event.message;
96
- usage = event.usage;
97
- }
98
- else if (event.type === 'error') {
99
- throw event.error;
100
- }
349
+ continue; // re-send this turn against the compacted history
101
350
  }
102
351
  if (ctx.signal.aborted)
103
352
  break;
@@ -107,6 +356,24 @@ export async function runAgentLoop(ctx) {
107
356
  }
108
357
  // ----- Turn end -----
109
358
  ctx.emit({ type: 'turn_end', message: assistantMessage, usage });
359
+ // THE TWO LINES THE COMPACTION PROBE DEPENDS ON, and both are cleared HERE
360
+ // rather than at the top of the loop.
361
+ //
362
+ // Without the first, `lastUsage` is forever `undefined`, every trigger runs
363
+ // on the crude estimate, and the status bar and the trigger diverge — the
364
+ // exact defect the shared `occupiedTokens` exists to close, reintroduced
365
+ // silently (P1-1).
366
+ //
367
+ // Without the second — or with it moved to the top of the `while` — the
368
+ // one-shot overflow guard would not exist at all: §3.5's recovery ends in
369
+ // `continue`, which re-enters the loop, so a top-of-loop reset would clear
370
+ // the flag on the way back in and a session whose tail alone cannot fit
371
+ // would compact on every pass (P0-1). Clearing it after a stream COMPLETES
372
+ // is the correct expression of the intent: the flag means "this turn has
373
+ // already spent its one overflow recovery", and a turn that never produced a
374
+ // `done` event has not finished.
375
+ lastUsage = usage;
376
+ overflowRecovered = false;
110
377
  ctx.messageManager.push(assistantMessage);
111
378
  // ----- Process tool calls -----
112
379
  const toolCalls = getToolCalls(assistantMessage);
@@ -10,6 +10,7 @@ import type { ProviderRegistry } from '../llm/providers/index.js';
10
10
  import type { AgentTool } from '../tools/types.js';
11
11
  import type { AgentEventListener } from '../types.js';
12
12
  import type { CodeActSandbox, ModelRef } from './agent-loop.js';
13
+ import type { ContextManager } from './context-manager.js';
13
14
  export interface AgentConfig {
14
15
  /** The initial system prompt. */
15
16
  systemPrompt: string;
@@ -27,6 +28,16 @@ export interface AgentConfig {
27
28
  sandbox?: CodeActSandbox;
28
29
  /** Maximum output tokens per LLM call. `undefined` = use provider default. */
29
30
  maxTokens?: number;
31
+ /**
32
+ * Optional context manager (context-auto-compaction §4.1).
33
+ *
34
+ * `undefined` means the engine NEVER compacts and every code path is
35
+ * byte-identical to a pre-feature build. THERE IS NO SETTER, deliberately: a
36
+ * host implementation reads its own config live through closures (the idiom
37
+ * `FastWiring` already uses), so the object handed in here can stay stable for
38
+ * the `Agent`'s whole lifetime while the policy behind it changes mid-session.
39
+ */
40
+ contextManager?: ContextManager;
30
41
  /** Timeout configuration. */
31
42
  timeouts?: {
32
43
  /** Single LLM call timeout (ms). Default: 300_000 (5 min). */
@@ -37,6 +48,15 @@ export interface AgentConfig {
37
48
  toolTimeout?: number;
38
49
  /** Code execution timeout (ms). Default: 30_000 (30 s). */
39
50
  codeTimeout?: number;
51
+ /**
52
+ * Ceiling on ONE `ContextManager.compact()` call (ms). Default: 120_000.
53
+ *
54
+ * EXPOSED ONLY SO AC-10a IS TESTABLE IN UNDER TWO MINUTES. It is not a host
55
+ * policy knob — the whole point of the ceiling is that it does not depend on
56
+ * the host being correct, and `compaction_start` pauses the idle watchdog, so
57
+ * across the call this is the only clock running (P1-3 / R-13).
58
+ */
59
+ compactionHardTimeout?: number;
40
60
  /**
41
61
  * Per-tool ceilings keyed by tool name, overriding `toolTimeout`.
42
62
  *
@@ -73,11 +93,13 @@ export declare class Agent {
73
93
  private readonly providerRegistry;
74
94
  private readonly getApiKeyFn;
75
95
  private readonly sandbox?;
96
+ private readonly contextManager?;
76
97
  private maxTokens;
77
98
  private readonly llmCallTimeout;
78
99
  private readonly idleTimeout;
79
100
  private readonly toolTimeout;
80
101
  private readonly codeTimeout;
102
+ private readonly compactionHardTimeout;
81
103
  private abortController;
82
104
  private running;
83
105
  private idlePromiseResolvers;
@@ -132,8 +154,23 @@ export declare class Agent {
132
154
  */
133
155
  private emit;
134
156
  /**
135
- * Suspend the idle watchdog across a retry backoff, and resume it when the
136
- * next attempt goes out (llm-api-retry-backoff §4.6).
157
+ * Suspend the idle watchdog across a wait the ENGINE ITSELF scheduled, and
158
+ * resume it when work moves again.
159
+ *
160
+ * TWO PAIRS, ONE POLICY (context-auto-compaction §3.3). Retry backoff was the
161
+ * first (llm-api-retry-backoff §4.6); context compaction is the second, and it
162
+ * is the reason this method lost the `Retry` in its name. Both are cases where
163
+ * the run is provably waiting on purpose and the watchdog cannot tell that
164
+ * apart from a wedged network call.
165
+ *
166
+ * `compaction_end` IS EMITTED FROM A `finally` IN `runCompaction`, on every
167
+ * exit path including a throw, precisely so this `resume()` is unconditional.
168
+ * A compaction whose `end` never arrived would leave the watchdog paused for
169
+ * the rest of the run, with nothing anywhere reporting it (P1-3 / R-13).
170
+ *
171
+ * Both halves are idempotent, and `runLoopWithLifecycle`'s `finally` calls
172
+ * `watchdog.stop()`, which clears `paused` — that is what bounds an abort
173
+ * landing mid-wait.
137
174
  *
138
175
  * `withRetry` waits up to `maxDelayMs` between attempts and emits nothing while
139
176
  * it does. That is indistinguishable from a wedged network call to the
@@ -147,7 +184,7 @@ export declare class Agent {
147
184
  * clears `paused` for exactly this reason. DO NOT "simplify" that line out of
148
185
  * `stop()`.
149
186
  */
150
- private applyRetryWatchdogPolicy;
187
+ private applyWatchdogPolicy;
151
188
  /**
152
189
  * Synchronize the ToolRegistry with the provided tool array.
153
190
  * Removes tools no longer present and adds new ones.
@@ -37,6 +37,7 @@ export class Agent {
37
37
  providerRegistry;
38
38
  getApiKeyFn;
39
39
  sandbox;
40
+ contextManager;
40
41
  // --- LLM generation parameters ---
41
42
  maxTokens;
42
43
  // --- Timeouts ---
@@ -44,6 +45,7 @@ export class Agent {
44
45
  idleTimeout;
45
46
  toolTimeout;
46
47
  codeTimeout;
48
+ compactionHardTimeout;
47
49
  // --- Runtime state ---
48
50
  abortController = null;
49
51
  running = false;
@@ -56,12 +58,14 @@ export class Agent {
56
58
  this.providerRegistry = config.providerRegistry;
57
59
  this.getApiKeyFn = config.getApiKey;
58
60
  this.sandbox = config.sandbox;
61
+ this.contextManager = config.contextManager;
59
62
  this.maxTokens = config.maxTokens;
60
63
  // Timeouts
61
64
  this.llmCallTimeout = config.timeouts?.llmCallTimeout ?? DEFAULT_LLM_CALL_TIMEOUT;
62
65
  this.idleTimeout = config.timeouts?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT;
63
66
  this.toolTimeout = config.timeouts?.toolTimeout ?? DEFAULT_TOOL_TIMEOUT;
64
67
  this.codeTimeout = config.timeouts?.codeTimeout ?? DEFAULT_CODE_TIMEOUT;
68
+ this.compactionHardTimeout = config.timeouts?.compactionHardTimeout;
65
69
  // Register initial tools
66
70
  this.syncToolRegistry(config.tools);
67
71
  // Tool executor
@@ -222,7 +226,7 @@ export class Agent {
222
226
  // policy still has to have been applied — otherwise a bad subscriber leaves
223
227
  // the watchdog armed across a 30-second backoff and the run is aborted for
224
228
  // being "idle" while it is provably waiting on purpose.
225
- this.applyRetryWatchdogPolicy(event);
229
+ this.applyWatchdogPolicy(event);
226
230
  for (const listener of this.listeners) {
227
231
  try {
228
232
  listener(event);
@@ -234,8 +238,23 @@ export class Agent {
234
238
  this.watchdog.kick();
235
239
  }
236
240
  /**
237
- * Suspend the idle watchdog across a retry backoff, and resume it when the
238
- * next attempt goes out (llm-api-retry-backoff §4.6).
241
+ * Suspend the idle watchdog across a wait the ENGINE ITSELF scheduled, and
242
+ * resume it when work moves again.
243
+ *
244
+ * TWO PAIRS, ONE POLICY (context-auto-compaction §3.3). Retry backoff was the
245
+ * first (llm-api-retry-backoff §4.6); context compaction is the second, and it
246
+ * is the reason this method lost the `Retry` in its name. Both are cases where
247
+ * the run is provably waiting on purpose and the watchdog cannot tell that
248
+ * apart from a wedged network call.
249
+ *
250
+ * `compaction_end` IS EMITTED FROM A `finally` IN `runCompaction`, on every
251
+ * exit path including a throw, precisely so this `resume()` is unconditional.
252
+ * A compaction whose `end` never arrived would leave the watchdog paused for
253
+ * the rest of the run, with nothing anywhere reporting it (P1-3 / R-13).
254
+ *
255
+ * Both halves are idempotent, and `runLoopWithLifecycle`'s `finally` calls
256
+ * `watchdog.stop()`, which clears `paused` — that is what bounds an abort
257
+ * landing mid-wait.
239
258
  *
240
259
  * `withRetry` waits up to `maxDelayMs` between attempts and emits nothing while
241
260
  * it does. That is indistinguishable from a wedged network call to the
@@ -249,7 +268,15 @@ export class Agent {
249
268
  * clears `paused` for exactly this reason. DO NOT "simplify" that line out of
250
269
  * `stop()`.
251
270
  */
252
- applyRetryWatchdogPolicy(event) {
271
+ applyWatchdogPolicy(event) {
272
+ if (event.type === 'compaction_start') {
273
+ this.watchdog.pause();
274
+ return;
275
+ }
276
+ if (event.type === 'compaction_end') {
277
+ this.watchdog.resume();
278
+ return;
279
+ }
253
280
  if (event.type !== 'message_update')
254
281
  return;
255
282
  const t = event.streamEvent.type;
@@ -303,11 +330,19 @@ export class Agent {
303
330
  systemPrompt: this.systemPrompt,
304
331
  thinkingLevel: this.thinkingLevel,
305
332
  maxTokens: this.maxTokens,
333
+ // SPREAD, never `contextManager: this.contextManager`. With no manager the
334
+ // loop context has no such key, so `if (!ctx.contextManager)` tests a field
335
+ // that is genuinely absent rather than a property holding `undefined` —
336
+ // which is what makes the byte-identity claim in AC-1 provable.
337
+ ...(this.contextManager ? { contextManager: this.contextManager } : {}),
306
338
  signal: this.abortController.signal,
307
339
  timeouts: {
308
340
  llmCallTimeout: this.llmCallTimeout,
309
341
  toolTimeout: this.toolTimeout,
310
342
  codeTimeout: this.codeTimeout,
343
+ ...(this.compactionHardTimeout !== undefined
344
+ ? { compactionHardTimeout: this.compactionHardTimeout }
345
+ : {}),
311
346
  },
312
347
  });
313
348
  }
@@ -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) {
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AssistantMessage, Message, StreamEvent, TokenUsage } from './llm/types.js';
8
8
  import type { ToolResult } from './tools/types.js';
9
+ import type { CompactionTrigger } from './engine/context-manager.js';
9
10
  export interface AgentStartEvent {
10
11
  type: 'agent_start';
11
12
  }
@@ -50,7 +51,62 @@ export interface CodeExecutionEndEvent {
50
51
  error?: string;
51
52
  duration: number;
52
53
  }
54
+ /**
55
+ * Context compaction is about to run (context-auto-compaction §5.1).
56
+ *
57
+ * IT PAUSES THE IDLE WATCHDOG (`agent.ts::applyWatchdogPolicy`), because a
58
+ * summarization is a normal LLM call and can exceed `DEFAULT_IDLE_TIMEOUT`.
59
+ */
60
+ export interface CompactionStartEvent {
61
+ type: 'compaction_start';
62
+ trigger: CompactionTrigger;
63
+ messageCount: number;
64
+ }
65
+ /**
66
+ * Context compaction finished, applied or not (context-auto-compaction §5.1).
67
+ *
68
+ * EMITTED FROM A `finally`, ON EVERY EXIT PATH. It is what RESUMES the watchdog,
69
+ * so a compaction that failed to emit its `end` would leave the run permanently
70
+ * deaf (P1-3).
71
+ */
72
+ export interface CompactionEndEvent {
73
+ type: 'compaction_end';
74
+ applied: boolean;
75
+ mode: 'summarized' | 'truncated' | 'none';
76
+ /** Set whenever `applied` is false, and on any degraded splice. */
77
+ reason?: string;
78
+ messagesBefore: number;
79
+ messagesAfter: number;
80
+ droppedMessages: number;
81
+ /**
82
+ * Core's own `estimatePromptTokens`, BOTH SIDES — comparable by construction.
83
+ *
84
+ * A "before" from provider usage and an "after" from an estimate are two
85
+ * different units, and the card's headline claim ("118k -> 23k") would be
86
+ * comparing a measurement to a guess (§5.1).
87
+ */
88
+ estimatedTokensBefore: number;
89
+ estimatedTokensAfter: number;
90
+ /** The summary body, so the host needs no second channel to render its card. */
91
+ summary?: string;
92
+ /**
93
+ * Set only when the host clipped oversized `tool_result` bodies inside the
94
+ * RETAINED tail because the tail alone did not fit
95
+ * (context-auto-compaction-hardening §3.3.4 / W2).
96
+ *
97
+ * OPTIONAL, SO NO EXISTING SUBSCRIBER CHANGES. It is here rather than on
98
+ * `CompactionOutcome` because the engine has no use for the distinction — it
99
+ * validates and adopts a history like any other — while `aragon exec`'s JSON
100
+ * stream and the headless stderr line report a bounded, announced data loss
101
+ * the user is entitled to know about.
102
+ */
103
+ tailRelief?: {
104
+ messages: number;
105
+ charsRemoved: number;
106
+ };
107
+ durationMs: number;
108
+ }
53
109
  /** Union of all events emitted by the Agent engine. */
54
- export type AgentEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | MessageUpdateEvent | ToolExecutionStartEvent | ToolExecutionEndEvent | CodeExecutionStartEvent | CodeExecutionEndEvent;
110
+ export type AgentEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | MessageUpdateEvent | ToolExecutionStartEvent | ToolExecutionEndEvent | CodeExecutionStartEvent | CodeExecutionEndEvent | CompactionStartEvent | CompactionEndEvent;
55
111
  /** Callback signature for Agent event listeners. */
56
112
  export type AgentEventListener = (event: AgentEvent) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/core",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "AragonAgent core engine — LLM provider adapters, streaming, tool registry/execution, steering, idle watchdog, and an optional isolated-vm sandbox.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",