@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
|
@@ -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/tools/executor.d.ts
CHANGED
|
@@ -19,6 +19,20 @@ export interface ToolExecutorOptions {
|
|
|
19
19
|
timeoutOverrides?: Record<string, number>;
|
|
20
20
|
/** Maximum size (in bytes) of combined text output before truncation. Defaults to 100_000 (100 KB). */
|
|
21
21
|
maxOutputSize?: number;
|
|
22
|
+
/**
|
|
23
|
+
* How long, after an abort is signalled, a tool has to settle before its
|
|
24
|
+
* promise is ABANDONED and an error result is returned in its place.
|
|
25
|
+
* Defaults to 5000.
|
|
26
|
+
*
|
|
27
|
+
* THE INVARIANT BEING PROTECTED IS "AN ABORT IS ALWAYS ANSWERABLE", AND AN
|
|
28
|
+
* INVARIANT THAT DEPENDS ON EVERY TOOL AUTHOR REMEMBERING IS NOT AN
|
|
29
|
+
* INVARIANT. Step 5 below used to be a bare `await tool.execute(...)`: the
|
|
30
|
+
* signal was delivered, the tool was free to ignore it, and the loop waited
|
|
31
|
+
* forever — so the host's "press Esc to stop" did nothing at all in exactly
|
|
32
|
+
* the situation where a user most needs it. Racing here makes the guarantee a
|
|
33
|
+
* property of the executor rather than of the toolset it happens to be given.
|
|
34
|
+
*/
|
|
35
|
+
abortGraceMs?: number;
|
|
22
36
|
}
|
|
23
37
|
/**
|
|
24
38
|
* Executes tool calls with validation, timeout management,
|
|
@@ -30,6 +44,7 @@ export declare class ToolExecutor {
|
|
|
30
44
|
private readonly defaultTimeout;
|
|
31
45
|
private readonly timeoutOverrides;
|
|
32
46
|
private readonly maxOutputSize;
|
|
47
|
+
private readonly abortGraceMs;
|
|
33
48
|
constructor(registry: ToolRegistry, options?: ToolExecutorOptions);
|
|
34
49
|
/**
|
|
35
50
|
* Execute a tool call end-to-end.
|