@alisio/core 0.1.0-alpha.6 → 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 +53 -0
- package/dist/core/compaction.js +149 -0
- package/dist/core/contracts.d.ts +6 -0
- package/dist/core/runner.js +30 -4
- package/dist/runtime/store.d.ts +1 -0
- package/dist/runtime/store.js +9 -0
- package/package.json +1 -1
|
@@ -44,6 +44,59 @@ export interface CompactionPlan {
|
|
|
44
44
|
kept: Message[];
|
|
45
45
|
}
|
|
46
46
|
export declare function planCompaction(messages: Message[], options?: CompactionOptions): CompactionPlan | undefined;
|
|
47
|
+
/** Marker appended to message content cut by the context-budget reduction. */
|
|
48
|
+
export declare const TRUNCATION_MARKER = "\u2026 [truncated by context budget]";
|
|
49
|
+
/** Default per-message cap for kept tool result text after a failed compaction. */
|
|
50
|
+
export declare const MAX_TOOL_RESULT_CHARS = 8000;
|
|
51
|
+
/** Default per-message cap for kept user/assistant text after a failed compaction. */
|
|
52
|
+
export declare const MAX_TEXT_CHARS = 16000;
|
|
53
|
+
export interface MessageReductionOptions {
|
|
54
|
+
/** Keep at most this many characters of text per tool result (default 8_000). */
|
|
55
|
+
maxToolResultChars?: number;
|
|
56
|
+
/** Keep at most this many characters per user/assistant text (default 16_000). */
|
|
57
|
+
maxTextChars?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface MessageReduction {
|
|
60
|
+
messages: Message[];
|
|
61
|
+
/** Number of messages whose content was truncated. */
|
|
62
|
+
truncated: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Cuts oversized retained content so the session fits the context-budget hard limit after a
|
|
66
|
+
* compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
|
|
67
|
+
* of tool results); roles, call IDs, order and message boundaries stay identical, so the
|
|
68
|
+
* transcript remains valid and replayable and a tool call is never separated from its results.
|
|
69
|
+
* Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
|
|
70
|
+
*/
|
|
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;
|
|
47
100
|
/**
|
|
48
101
|
* Plain-text transcript used as summarizer input. Provider continuation data (for example
|
|
49
102
|
* encrypted reasoning) is opaque and is intentionally not included.
|
package/dist/core/compaction.js
CHANGED
|
@@ -91,6 +91,155 @@ export function planCompaction(messages, options = {}) {
|
|
|
91
91
|
return { cut, summarized: messages.slice(0, cut), kept: messages.slice(cut) };
|
|
92
92
|
}
|
|
93
93
|
const clip = (text, max) => text.length > max ? `${text.slice(0, max)}… [truncated]` : text;
|
|
94
|
+
/** Marker appended to message content cut by the context-budget reduction. */
|
|
95
|
+
export const TRUNCATION_MARKER = "… [truncated by context budget]";
|
|
96
|
+
/** Default per-message cap for kept tool result text after a failed compaction. */
|
|
97
|
+
export const MAX_TOOL_RESULT_CHARS = 8_000;
|
|
98
|
+
/** Default per-message cap for kept user/assistant text after a failed compaction. */
|
|
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
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Cuts oversized retained content so the session fits the context-budget hard limit after a
|
|
137
|
+
* compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
|
|
138
|
+
* of tool results); roles, call IDs, order and message boundaries stay identical, so the
|
|
139
|
+
* transcript remains valid and replayable and a tool call is never separated from its results.
|
|
140
|
+
* Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
|
|
141
|
+
*/
|
|
142
|
+
export function reduceMessageSizes(messages, options = {}) {
|
|
143
|
+
const maxToolResultChars = options.maxToolResultChars ?? MAX_TOOL_RESULT_CHARS;
|
|
144
|
+
const maxTextChars = options.maxTextChars ?? MAX_TEXT_CHARS;
|
|
145
|
+
let truncated = 0;
|
|
146
|
+
const reduced = messages.map((m) => {
|
|
147
|
+
const cut = clampMessage(m, maxToolResultChars, maxTextChars);
|
|
148
|
+
if (cut === m)
|
|
149
|
+
return m;
|
|
150
|
+
truncated++;
|
|
151
|
+
return cut;
|
|
152
|
+
});
|
|
153
|
+
return truncated ? { messages: reduced, truncated } : { messages, truncated: 0 };
|
|
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
|
+
}
|
|
94
243
|
/**
|
|
95
244
|
* Plain-text transcript used as summarizer input. Provider continuation data (for example
|
|
96
245
|
* encrypted reasoning) is opaque and is intentionally not included.
|
package/dist/core/contracts.d.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface SessionStore {
|
|
|
56
56
|
* rest verbatim and in order. Callers must never split a tool call from its results.
|
|
57
57
|
*/
|
|
58
58
|
compact(id: string, replaced: number, summary: Message): void;
|
|
59
|
+
/**
|
|
60
|
+
* Atomically replace the whole active history with `messages` (like `compact` without a
|
|
61
|
+
* summary): used after a context-budget reduction so the persisted transcript matches what
|
|
62
|
+
* was actually sent. Callers must never split a tool call from its results.
|
|
63
|
+
*/
|
|
64
|
+
overwrite(id: string, messages: Message[]): void;
|
|
59
65
|
acquire(id: string): void;
|
|
60
66
|
release(id: string): void;
|
|
61
67
|
beginCall(session: string, call: ToolCall): void;
|
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, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
|
|
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;
|
|
@@ -263,6 +269,7 @@ export class AgentRunner {
|
|
|
263
269
|
async run(sessionId, prompt, signal, options = {}) {
|
|
264
270
|
const controller = this.claim(sessionId);
|
|
265
271
|
const o = this.options, emit = this.emitter(sessionId);
|
|
272
|
+
const limit = o.maxContextChars ?? 160_000;
|
|
266
273
|
let tokens = 0, lastText = "", acquired = false;
|
|
267
274
|
const usageTotal = { input: 0, output: 0 };
|
|
268
275
|
const timeout = AbortSignal.timeout(options.timeoutMs ?? o.timeoutMs ?? 300_000);
|
|
@@ -351,13 +358,32 @@ export class AgentRunner {
|
|
|
351
358
|
// estimate (`chars / 4 >= maxContextChars / 4`). `maxContextChars` below stays as the
|
|
352
359
|
// post-compaction hard limit.
|
|
353
360
|
if (compaction.auto !== false &&
|
|
354
|
-
shouldCompactContext(used(), chars(), o.contextWindow?.(model),
|
|
361
|
+
shouldCompactContext(used(), chars(), o.contextWindow?.(model), limit, compaction.threshold ?? 0.85)) {
|
|
355
362
|
await this.compactLocked(sessionId, emit, model, combined, "auto");
|
|
356
363
|
instructions = await withPersona();
|
|
357
364
|
messages = o.store.messages(sessionId);
|
|
358
365
|
}
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
// A compaction that still leaves the session over the hard limit (typically a kept tail
|
|
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.
|
|
376
|
+
if (chars() > limit) {
|
|
377
|
+
const targetChars = Math.max(MIN_MESSAGE_BUDGET_FLOOR, limit - instructions.length - toolsText.length);
|
|
378
|
+
const reduction = reduceMessagesToBudget(messages, targetChars);
|
|
379
|
+
if (reduction.truncated > 0) {
|
|
380
|
+
o.store.overwrite(sessionId, reduction.messages);
|
|
381
|
+
messages = reduction.messages;
|
|
382
|
+
emit("context_reduced", { messages: reduction.truncated });
|
|
383
|
+
}
|
|
384
|
+
if (chars() > limit)
|
|
385
|
+
throw new Error(`Context budget exceeded and compaction could not reduce it (approximately ${chars()} characters; limit ${limit}). Run /compact, trim large tool outputs, or start a new session.`);
|
|
386
|
+
}
|
|
361
387
|
let completion;
|
|
362
388
|
let truncated = false;
|
|
363
389
|
let usage;
|
package/dist/runtime/store.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare class SQLiteStore implements SessionStore {
|
|
|
25
25
|
append(id: string, message: Message): void;
|
|
26
26
|
setModel(id: string, model: string): void;
|
|
27
27
|
compact(id: string, replaced: number, summary: Message): void;
|
|
28
|
+
overwrite(id: string, messages: Message[]): void;
|
|
28
29
|
acquire(id: string): void;
|
|
29
30
|
release(id: string): void;
|
|
30
31
|
beginCall(id: string, call: ToolCall): void;
|
package/dist/runtime/store.js
CHANGED
|
@@ -168,6 +168,15 @@ export class SQLiteStore {
|
|
|
168
168
|
this.db.prepare("INSERT INTO messages(session,body) VALUES(?,?)").run(id, row.body);
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
|
+
overwrite(id, messages) {
|
|
172
|
+
this.db.transaction(() => {
|
|
173
|
+
// Retire the active rows (originals stay marked compacted for auditing), then rewrite
|
|
174
|
+
// the reduced transcript in the same order.
|
|
175
|
+
this.db.prepare("UPDATE messages SET compacted=1 WHERE session=? AND compacted=0").run(id);
|
|
176
|
+
for (const message of messages)
|
|
177
|
+
this.append(id, message);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
171
180
|
acquire(id) {
|
|
172
181
|
this.db.transaction(() => {
|
|
173
182
|
this.get(id);
|
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",
|