@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.
@@ -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.
@@ -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
- if (m.role === "user") {
113
- if (m.summary || m.text.length <= maxTextChars)
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 { ...m, result: { ...m.result, content } };
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.
@@ -1,5 +1,5 @@
1
1
  import { textResult, } from "@alisio/sdk";
2
- import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessageSizes, 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;
@@ -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: oversized
362
- // kept content is cut per message (roles/callIds/boundaries untouched, so the transcript
363
- // stays valid and replayable). Only if even the reduced tail exceeds the limit the run
364
- // fails with an actionable error; the reduction is persisted either way, so the session
365
- // stays usable for later prompts.
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 reduction = reduceMessageSizes(messages);
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.7",
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",