@aragon-agent/core 0.2.14 → 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 +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/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
|
}
|
|
@@ -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,
|
|
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
|
-
/**
|
|
226
|
-
|
|
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.
|
|
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",
|