@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.
@@ -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.
@@ -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.
@@ -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;
@@ -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), o.maxContextChars ?? 160_000, compaction.threshold ?? 0.85)) {
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
- if (chars() > (o.maxContextChars ?? 160_000))
360
- throw new Error("Context budget exceeded and compaction could not reduce it; start a new session.");
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;
@@ -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;
@@ -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.6",
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",