@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.
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
  }