@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 +61 -0
- package/dist/engine/agent-loop.d.ts +35 -0
- package/dist/engine/agent-loop.js +277 -10
- package/dist/engine/agent.d.ts +40 -3
- package/dist/engine/agent.js +39 -4
- package/dist/engine/compaction.d.ts +146 -0
- package/dist/engine/compaction.js +226 -0
- package/dist/engine/context-manager.d.ts +91 -0
- package/dist/engine/context-manager.js +13 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +18 -1
- package/dist/llm/output-limits.d.ts +10 -0
- package/dist/llm/output-limits.js +10 -2
- package/dist/tools/executor.d.ts +15 -0
- package/dist/tools/executor.js +69 -2
- package/dist/tools/types.d.ts +17 -0
- package/dist/types.d.ts +57 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
//
|
|
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);
|
package/dist/engine/agent.d.ts
CHANGED
|
@@ -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
|
|
136
|
-
*
|
|
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
|
|
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.
|
package/dist/engine/agent.js
CHANGED
|
@@ -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.
|
|
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
|
|
238
|
-
*
|
|
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
|
-
|
|
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
|
}
|