@alisio/core 0.1.0-alpha.7 → 0.1.0-alpha.8
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/dist/core/compaction.d.ts +28 -0
- package/dist/core/compaction.js +126 -31
- package/dist/core/runner.js +18 -7
- package/package.json +1 -1
|
@@ -69,6 +69,34 @@ export interface MessageReduction {
|
|
|
69
69
|
* Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
|
|
70
70
|
*/
|
|
71
71
|
export declare function reduceMessageSizes(messages: Message[], options?: MessageReductionOptions): MessageReduction;
|
|
72
|
+
export interface BudgetReductionOptions extends MessageReductionOptions {
|
|
73
|
+
/**
|
|
74
|
+
* Descending per-round caps for tool result text, replacing the default
|
|
75
|
+
* 8192 → 4096 → 2048 → 1024 → 512 sequence.
|
|
76
|
+
*/
|
|
77
|
+
toolCapRounds?: number[];
|
|
78
|
+
/**
|
|
79
|
+
* Descending per-round caps for user/assistant text, replacing the default
|
|
80
|
+
* 16384 → 8192 → 4096 → 2048 → 1024 sequence.
|
|
81
|
+
*/
|
|
82
|
+
textCapRounds?: number[];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Budget-based reduction: iteratively clips the LARGEST retained content (tool results in
|
|
86
|
+
* practice, then long texts) in descending cap rounds until the serialized transcript fits
|
|
87
|
+
* `targetChars` or the minimum caps are reached. Unlike `reduceMessageSizes` (which only clips
|
|
88
|
+
* messages whose INDIVIDUAL size exceeds its cap), this also handles sessions with many MEDIUM
|
|
89
|
+
* tool results (for example MCP outputs of a few thousand characters each) that stay under the
|
|
90
|
+
* per-message caps but together exhaust the context budget.
|
|
91
|
+
*
|
|
92
|
+
* The invariants match `reduceMessageSizes`: only message content changes (roles, call IDs,
|
|
93
|
+
* order and message boundaries stay identical, so a tool call is never separated from its
|
|
94
|
+
* results), checkpoint summaries are never truncated, and every cut carries the
|
|
95
|
+
* `… [truncated by context budget]` marker. Within a round the most expensive messages are
|
|
96
|
+
* clipped first (ties by position), so the smallest number of changes achieves the target; the
|
|
97
|
+
* order and the round caps make the result a deterministic total order of the input.
|
|
98
|
+
*/
|
|
99
|
+
export declare function reduceMessagesToBudget(messages: Message[], targetChars: number, options?: BudgetReductionOptions): MessageReduction;
|
|
72
100
|
/**
|
|
73
101
|
* Plain-text transcript used as summarizer input. Provider continuation data (for example
|
|
74
102
|
* encrypted reasoning) is opaque and is intentionally not included.
|
package/dist/core/compaction.js
CHANGED
|
@@ -97,6 +97,41 @@ export const TRUNCATION_MARKER = "… [truncated by context budget]";
|
|
|
97
97
|
export const MAX_TOOL_RESULT_CHARS = 8_000;
|
|
98
98
|
/** Default per-message cap for kept user/assistant text after a failed compaction. */
|
|
99
99
|
export const MAX_TEXT_CHARS = 16_000;
|
|
100
|
+
/**
|
|
101
|
+
* Cuts ONE message's content down to the given per-message caps. Returns the same reference when
|
|
102
|
+
* nothing changes, so callers can detect a no-op by identity. See `reduceMessageSizes` for the
|
|
103
|
+
* invariants (roles/callIds/boundaries untouched, summaries never truncated).
|
|
104
|
+
*/
|
|
105
|
+
function clampMessage(message, maxToolResultChars, maxTextChars) {
|
|
106
|
+
if (message.role === "user") {
|
|
107
|
+
if (message.summary || message.text.length <= maxTextChars)
|
|
108
|
+
return message;
|
|
109
|
+
return { ...message, text: `${message.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
|
|
110
|
+
}
|
|
111
|
+
if (message.role === "assistant") {
|
|
112
|
+
if (message.text.length <= maxTextChars)
|
|
113
|
+
return message;
|
|
114
|
+
return { ...message, text: `${message.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
|
|
115
|
+
}
|
|
116
|
+
let used = 0;
|
|
117
|
+
let changed = false;
|
|
118
|
+
const content = message.result.content.map((part) => {
|
|
119
|
+
const room = maxToolResultChars - used;
|
|
120
|
+
const text = room <= 0
|
|
121
|
+
? TRUNCATION_MARKER
|
|
122
|
+
: part.text.length > room
|
|
123
|
+
? `${part.text.slice(0, room)}\n${TRUNCATION_MARKER}`
|
|
124
|
+
: part.text;
|
|
125
|
+
if (text !== part.text) {
|
|
126
|
+
changed = true;
|
|
127
|
+
used = maxToolResultChars;
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
used += part.text.length;
|
|
131
|
+
return { type: "text", text };
|
|
132
|
+
});
|
|
133
|
+
return changed ? { ...message, result: { ...message.result, content } } : message;
|
|
134
|
+
}
|
|
100
135
|
/**
|
|
101
136
|
* Cuts oversized retained content so the session fits the context-budget hard limit after a
|
|
102
137
|
* compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
|
|
@@ -109,42 +144,102 @@ export function reduceMessageSizes(messages, options = {}) {
|
|
|
109
144
|
const maxTextChars = options.maxTextChars ?? MAX_TEXT_CHARS;
|
|
110
145
|
let truncated = 0;
|
|
111
146
|
const reduced = messages.map((m) => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return m;
|
|
115
|
-
truncated++;
|
|
116
|
-
return { ...m, text: `${m.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
|
|
117
|
-
}
|
|
118
|
-
if (m.role === "assistant") {
|
|
119
|
-
if (m.text.length <= maxTextChars)
|
|
120
|
-
return m;
|
|
121
|
-
truncated++;
|
|
122
|
-
return { ...m, text: `${m.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
|
|
123
|
-
}
|
|
124
|
-
let used = 0;
|
|
125
|
-
let changed = false;
|
|
126
|
-
const content = m.result.content.map((part) => {
|
|
127
|
-
const room = maxToolResultChars - used;
|
|
128
|
-
const text = room <= 0
|
|
129
|
-
? TRUNCATION_MARKER
|
|
130
|
-
: part.text.length > room
|
|
131
|
-
? `${part.text.slice(0, room)}\n${TRUNCATION_MARKER}`
|
|
132
|
-
: part.text;
|
|
133
|
-
if (text !== part.text) {
|
|
134
|
-
changed = true;
|
|
135
|
-
used = maxToolResultChars;
|
|
136
|
-
}
|
|
137
|
-
else
|
|
138
|
-
used += part.text.length;
|
|
139
|
-
return { type: "text", text };
|
|
140
|
-
});
|
|
141
|
-
if (!changed)
|
|
147
|
+
const cut = clampMessage(m, maxToolResultChars, maxTextChars);
|
|
148
|
+
if (cut === m)
|
|
142
149
|
return m;
|
|
143
150
|
truncated++;
|
|
144
|
-
return
|
|
151
|
+
return cut;
|
|
145
152
|
});
|
|
146
153
|
return truncated ? { messages: reduced, truncated } : { messages, truncated: 0 };
|
|
147
154
|
}
|
|
155
|
+
/** Descending per-round tool-result caps used by `reduceMessagesToBudget` by default. */
|
|
156
|
+
const DEFAULT_TOOL_CAP_ROUNDS = [MAX_TOOL_RESULT_CHARS, 4_096, 2_048, 1_024, 512];
|
|
157
|
+
/** Descending per-round user/assistant text caps used by `reduceMessagesToBudget` by default. */
|
|
158
|
+
const DEFAULT_TEXT_CAP_ROUNDS = [MAX_TEXT_CHARS, 8_192, 4_096, 2_048, 1_024];
|
|
159
|
+
/** A strictly descending cap sequence starting at `start`, followed by the smaller defaults. */
|
|
160
|
+
function capRounds(start, defaults) {
|
|
161
|
+
return [start, ...defaults.filter((c) => c < start)];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Budget-based reduction: iteratively clips the LARGEST retained content (tool results in
|
|
165
|
+
* practice, then long texts) in descending cap rounds until the serialized transcript fits
|
|
166
|
+
* `targetChars` or the minimum caps are reached. Unlike `reduceMessageSizes` (which only clips
|
|
167
|
+
* messages whose INDIVIDUAL size exceeds its cap), this also handles sessions with many MEDIUM
|
|
168
|
+
* tool results (for example MCP outputs of a few thousand characters each) that stay under the
|
|
169
|
+
* per-message caps but together exhaust the context budget.
|
|
170
|
+
*
|
|
171
|
+
* The invariants match `reduceMessageSizes`: only message content changes (roles, call IDs,
|
|
172
|
+
* order and message boundaries stay identical, so a tool call is never separated from its
|
|
173
|
+
* results), checkpoint summaries are never truncated, and every cut carries the
|
|
174
|
+
* `… [truncated by context budget]` marker. Within a round the most expensive messages are
|
|
175
|
+
* clipped first (ties by position), so the smallest number of changes achieves the target; the
|
|
176
|
+
* order and the round caps make the result a deterministic total order of the input.
|
|
177
|
+
*/
|
|
178
|
+
export function reduceMessagesToBudget(messages, targetChars, options = {}) {
|
|
179
|
+
const target = Math.max(1, Math.floor(targetChars));
|
|
180
|
+
const toolCapRounds = options.toolCapRounds ??
|
|
181
|
+
capRounds(options.maxToolResultChars ?? MAX_TOOL_RESULT_CHARS, DEFAULT_TOOL_CAP_ROUNDS);
|
|
182
|
+
const textCapRounds = options.textCapRounds ??
|
|
183
|
+
capRounds(options.maxTextChars ?? MAX_TEXT_CHARS, DEFAULT_TEXT_CAP_ROUNDS);
|
|
184
|
+
const rounds = Math.max(toolCapRounds.length, textCapRounds.length);
|
|
185
|
+
// Working transcript; `messages` keeps the untouched originals for re-clipping each round.
|
|
186
|
+
const working = [...messages];
|
|
187
|
+
const sizeOf = (m) => JSON.stringify(m).length;
|
|
188
|
+
const sizes = working.map(sizeOf);
|
|
189
|
+
// Serialized array length == sum of message lengths + n-1 commas + 2 brackets (+n+1 total).
|
|
190
|
+
let size = sizes.reduce((a, b) => a + b, 0) + working.length + 1;
|
|
191
|
+
if (size <= target)
|
|
192
|
+
return { messages, truncated: 0 };
|
|
193
|
+
const clipped = new Set();
|
|
194
|
+
for (let round = 0; round < rounds && size > target; round++) {
|
|
195
|
+
const toolCap = toolCapRounds[Math.min(round, toolCapRounds.length - 1)];
|
|
196
|
+
const textCap = textCapRounds[Math.min(round, textCapRounds.length - 1)];
|
|
197
|
+
if (toolCap === undefined || textCap === undefined)
|
|
198
|
+
break;
|
|
199
|
+
let shrank = true;
|
|
200
|
+
while (size > target && shrank) {
|
|
201
|
+
shrank = false;
|
|
202
|
+
// Largest (by current serialized size) clip-able message first; ties by position.
|
|
203
|
+
let best = -1, bestSize = -1;
|
|
204
|
+
for (let i = 0; i < working.length; i++) {
|
|
205
|
+
const current = sizes[i];
|
|
206
|
+
if (current === undefined || current <= bestSize)
|
|
207
|
+
continue;
|
|
208
|
+
const m = messages[i];
|
|
209
|
+
if (!m)
|
|
210
|
+
continue;
|
|
211
|
+
const over = m.role === "user"
|
|
212
|
+
? !m.summary && m.text.length > textCap
|
|
213
|
+
: m.role === "assistant"
|
|
214
|
+
? m.text.length > textCap
|
|
215
|
+
: m.role === "tool" &&
|
|
216
|
+
m.result.content.reduce((a, p) => a + p.text.length, 0) > toolCap;
|
|
217
|
+
if (over) {
|
|
218
|
+
best = i;
|
|
219
|
+
bestSize = current;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (best < 0)
|
|
223
|
+
break;
|
|
224
|
+
const original = messages[best];
|
|
225
|
+
const oldSize = sizes[best];
|
|
226
|
+
if (!original || oldSize === undefined)
|
|
227
|
+
break;
|
|
228
|
+
// Re-clip from the ORIGINAL content so earlier markers are never embedded mid-result.
|
|
229
|
+
const cut = clampMessage(original, toolCap, textCap);
|
|
230
|
+
const newSize = sizeOf(cut);
|
|
231
|
+
const delta = oldSize - newSize;
|
|
232
|
+
sizes[best] = newSize;
|
|
233
|
+
if (delta <= 0)
|
|
234
|
+
continue; // already at this round's caps; no room to shrink further
|
|
235
|
+
working[best] = cut;
|
|
236
|
+
clipped.add(best);
|
|
237
|
+
size -= delta;
|
|
238
|
+
shrank = true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return clipped.size ? { messages: working, truncated: clipped.size } : { messages, truncated: 0 };
|
|
242
|
+
}
|
|
148
243
|
/**
|
|
149
244
|
* Plain-text transcript used as summarizer input. Provider continuation data (for example
|
|
150
245
|
* encrypted reasoning) is opaque and is intentionally not included.
|
package/dist/core/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { textResult, } from "@alisio/sdk";
|
|
2
|
-
import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction,
|
|
2
|
+
import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
|
|
3
3
|
/**
|
|
4
4
|
* Default cumulative token budget for one run: several context windows (each turn re-sends the
|
|
5
5
|
* context), clamped; 1M tokens when the window is unknown.
|
|
@@ -11,6 +11,12 @@ export function defaultTokenBudget(contextWindow) {
|
|
|
11
11
|
}
|
|
12
12
|
/** Extension output appended to history is capped regardless of what hooks return. */
|
|
13
13
|
const MAX_INJECT_CHARS = 24_000;
|
|
14
|
+
/**
|
|
15
|
+
* Floor for the post-compaction transcript target: retained messages are never reduced below
|
|
16
|
+
* this many characters, so a pathological session (instructions + tools alone crowding out the
|
|
17
|
+
* limit) fails with an actionable error instead of silently losing the whole history.
|
|
18
|
+
*/
|
|
19
|
+
const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
|
|
14
20
|
const allowed = (policy, effect) => effect === "read" || effect === "internal" || !!policy[effect];
|
|
15
21
|
export class AgentRunner {
|
|
16
22
|
options;
|
|
@@ -358,13 +364,18 @@ export class AgentRunner {
|
|
|
358
364
|
messages = o.store.messages(sessionId);
|
|
359
365
|
}
|
|
360
366
|
// A compaction that still leaves the session over the hard limit (typically a kept tail
|
|
361
|
-
// holding huge tool outputs) is reduced in place instead of failing outright:
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
367
|
+
// holding huge tool outputs) is reduced in place instead of failing outright: retained
|
|
368
|
+
// content is clipped against a TOTAL character target for the transcript
|
|
369
|
+
// (`limit − instructions − tools`), largest items first, tool results before texts, in
|
|
370
|
+
// descending cap rounds. That also covers sessions with many MEDIUM results (e.g. MCP
|
|
371
|
+
// outputs of a few thousand chars each) that individually stay under the per-message
|
|
372
|
+
// caps; roles/callIds/boundaries stay untouched, so the transcript remains valid and
|
|
373
|
+
// replayable. Only if even the reduction floor cannot fit (instructions + tools alone
|
|
374
|
+
// crowd out the limit) the run fails with an actionable error; the reduction is
|
|
375
|
+
// persisted either way, so the session stays usable for later prompts.
|
|
366
376
|
if (chars() > limit) {
|
|
367
|
-
const
|
|
377
|
+
const targetChars = Math.max(MIN_MESSAGE_BUDGET_FLOOR, limit - instructions.length - toolsText.length);
|
|
378
|
+
const reduction = reduceMessagesToBudget(messages, targetChars);
|
|
368
379
|
if (reduction.truncated > 0) {
|
|
369
380
|
o.store.overwrite(sessionId, reduction.messages);
|
|
370
381
|
messages = reduction.messages;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alisio/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
4
|
"description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
|
|
5
5
|
"author": "Gustavo Gutiérrez",
|
|
6
6
|
"license": "MIT",
|