@mono-agent/agent-runtime 0.1.0

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.
Files changed (60) hide show
  1. package/ARCHITECTURE.md +219 -0
  2. package/LICENSE +674 -0
  3. package/README.md +430 -0
  4. package/package.json +46 -0
  5. package/src/agent/allowlists.js +49 -0
  6. package/src/agent/approval.js +211 -0
  7. package/src/agent/compaction.js +752 -0
  8. package/src/agent/index.js +40 -0
  9. package/src/agent/prompt/skill-index.js +66 -0
  10. package/src/agent/tool-bloat.js +164 -0
  11. package/src/agent/tools/bash.js +156 -0
  12. package/src/agent/tools/edit.js +15 -0
  13. package/src/agent/tools/glob.js +71 -0
  14. package/src/agent/tools/grep.js +84 -0
  15. package/src/agent/tools/index.js +17 -0
  16. package/src/agent/tools/pi-bridge.js +638 -0
  17. package/src/agent/tools/read.js +39 -0
  18. package/src/agent/tools/shared/constants.js +21 -0
  19. package/src/agent/tools/shared/dedup.js +31 -0
  20. package/src/agent/tools/shared/output-truncation.js +54 -0
  21. package/src/agent/tools/shared/path-resolver.js +156 -0
  22. package/src/agent/tools/shared/ripgrep.js +130 -0
  23. package/src/agent/tools/shared/runtime-context.js +69 -0
  24. package/src/agent/tools/web-fetch.js +59 -0
  25. package/src/agent/tools/web-search.js +21 -0
  26. package/src/agent/tools/write.js +14 -0
  27. package/src/agent/transcript.js +227 -0
  28. package/src/ai/backend.js +17 -0
  29. package/src/ai/cost.js +164 -0
  30. package/src/ai/failure.js +165 -0
  31. package/src/ai/file-change-stats.js +234 -0
  32. package/src/ai/index.js +16 -0
  33. package/src/ai/live-input-prompt.js +15 -0
  34. package/src/ai/observer.js +233 -0
  35. package/src/ai/providers/claude-cli.js +694 -0
  36. package/src/ai/providers/claude-sdk.js +864 -0
  37. package/src/ai/providers/claude-subagents.js +67 -0
  38. package/src/ai/providers/codex-app.js +1045 -0
  39. package/src/ai/providers/opencode-app.js +356 -0
  40. package/src/ai/providers/opencode-discovery.js +39 -0
  41. package/src/ai/providers/pi-events.js +62 -0
  42. package/src/ai/providers/pi-messages.js +68 -0
  43. package/src/ai/providers/pi-models.js +111 -0
  44. package/src/ai/providers/pi-sdk.js +1310 -0
  45. package/src/ai/registry.js +5 -0
  46. package/src/ai/runtime/capabilities-used.js +56 -0
  47. package/src/ai/runtime/capabilities.js +44 -0
  48. package/src/ai/runtime/context-windows.js +38 -0
  49. package/src/ai/runtime/fast-mode.js +8 -0
  50. package/src/ai/runtime/model-refs.js +144 -0
  51. package/src/ai/runtime/registry.js +57 -0
  52. package/src/ai/runtime/router.js +214 -0
  53. package/src/ai/runtime/sessions.js +126 -0
  54. package/src/ai/streaming/codex-events.js +139 -0
  55. package/src/ai/streaming/opencode-events.js +54 -0
  56. package/src/ai/types.js +70 -0
  57. package/src/index.js +23 -0
  58. package/src/pi-auth.js +80 -0
  59. package/src/runtime-brand.js +32 -0
  60. package/src/runtime.js +104 -0
@@ -0,0 +1,752 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export const COMPACTED_CONTEXT_MARKER = "Compacted prior agent context";
4
+
5
+ const DEFAULT_CONTEXT_WINDOW = 128000;
6
+ const DEFAULT_TRIGGER_RATIO = 0.85;
7
+ const DEFAULT_KEEP_RECENT_TOKENS = 24000;
8
+ const DEFAULT_SUMMARY_MAX_TOKENS = 16000;
9
+ const DEFAULT_MIN_SAVINGS_TOKENS = 20000;
10
+ const DEFAULT_TOOL_PAYLOAD_COMPACTION_TRIGGER_CHARS = 0;
11
+ const DEFAULT_TOOL_PRUNE_TRIGGER_TOKENS = 40000;
12
+ // intelligence-ramp Phase 3: lifted from 16K/20K/12K. Mid-task tool reads
13
+ // (large file edits, long bash output, deep MCP results) were being silently
14
+ // clipped before the agent could reason about them. The 256KB hard ceiling
15
+ // in tool-bloat.js still protects against runaway payloads.
16
+ const DEFAULT_TOOL_TEXT_LIMIT_CHARS = 64000;
17
+ const DEFAULT_BASH_OUTPUT_LIMIT_CHARS = 64000;
18
+ const DEFAULT_MCP_TEXT_LIMIT_CHARS = 48000;
19
+ const DEFAULT_SEARCH_RESULT_LIMIT = 100;
20
+ const DEFAULT_IMAGE_INLINE_MAX_BYTES = 250000;
21
+ const DEFAULT_TOOL_PAYLOAD_MAX_BYTES = 262144;
22
+ const DEFAULT_MCP_CALL_TIMEOUT_MS = 120000;
23
+
24
+ function clampNumber(value, fallback, min, max) {
25
+ const n = Number(value);
26
+ if (!Number.isFinite(n)) return fallback;
27
+ return Math.min(Math.max(n, min), max);
28
+ }
29
+
30
+ function clampInteger(value, fallback, min, max) {
31
+ return Math.floor(clampNumber(value, fallback, min, max));
32
+ }
33
+
34
+ function jsonString(value) {
35
+ try { return JSON.stringify(value); } catch { return String(value ?? ""); }
36
+ }
37
+
38
+ function base64Bytes(data) {
39
+ const text = String(data || "");
40
+ if (!text) return 0;
41
+ const clean = text.includes(",") ? text.slice(text.indexOf(",") + 1) : text;
42
+ return Math.floor(clean.length * 0.75);
43
+ }
44
+
45
+ function textPart(value) {
46
+ if (typeof value === "string") return value;
47
+ if (!value || typeof value !== "object") return String(value ?? "");
48
+ if (typeof value.text === "string") return value.text;
49
+ if (typeof value.thinking === "string") return value.thinking;
50
+ if (value.type === "image") return `[image ${base64Bytes(value.data)} bytes]`;
51
+ if (value.type === "toolCall") return `${value.name || "tool"} ${jsonString(value.arguments || value.input || {})}`;
52
+ return jsonString(value);
53
+ }
54
+
55
+ function contentText(content) {
56
+ if (typeof content === "string") return content;
57
+ if (Array.isArray(content)) return content.map(textPart).join("\n");
58
+ return textPart(content);
59
+ }
60
+
61
+ function messageText(message) {
62
+ if (!message) return "";
63
+ const base = contentText(message.content);
64
+ if (message.role === "toolResult") {
65
+ return [
66
+ `Tool result: ${message.toolName || "unknown"}`,
67
+ message.isError ? "Status: error" : "",
68
+ base,
69
+ message.details ? jsonString(message.details) : "",
70
+ ].filter(Boolean).join("\n");
71
+ }
72
+ return base;
73
+ }
74
+
75
+ export function estimateAgentMessageTokens(message) {
76
+ const text = messageText(message);
77
+ let chars = text.length + 12;
78
+ let imageBytes = 0;
79
+ const parts = Array.isArray(message?.content) ? message.content : [];
80
+ for (const part of parts) {
81
+ if (part?.type === "image") imageBytes += base64Bytes(part.data);
82
+ }
83
+ const tokens = Math.ceil(chars / 4) + Math.ceil(imageBytes / 3);
84
+ return { tokens, chars, imageBytes };
85
+ }
86
+
87
+ export function estimateAgentMessages(messages = []) {
88
+ return messages.reduce((acc, message) => {
89
+ const next = estimateAgentMessageTokens(message);
90
+ acc.tokens += next.tokens;
91
+ acc.chars += next.chars;
92
+ acc.imageBytes += next.imageBytes;
93
+ return acc;
94
+ }, { tokens: 0, chars: 0, imageBytes: 0 });
95
+ }
96
+
97
+ export function estimateFirstTurnInput({ systemPrompt = "", messages = [] } = {}) {
98
+ const overheadChars = String(systemPrompt || "").length;
99
+ const overheadTokens = Math.ceil(overheadChars / 4);
100
+ const messageEstimate = estimateAgentMessages(messages);
101
+ return {
102
+ overheadTokens,
103
+ overheadChars,
104
+ inputTokens: overheadTokens + messageEstimate.tokens,
105
+ inputChars: overheadChars + messageEstimate.chars,
106
+ };
107
+ }
108
+
109
+ export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
110
+ const contextWindow = clampInteger(model?.contextWindow, DEFAULT_CONTEXT_WINDOW, 32000, 10_000_000);
111
+ const triggerRatio = clampNumber(
112
+ settings.agent_compaction_trigger_ratio,
113
+ DEFAULT_TRIGGER_RATIO,
114
+ 0.2,
115
+ 0.95,
116
+ );
117
+ const reserveTokens = Math.max(16000, Math.min(64000, Math.floor(contextWindow * 0.25)));
118
+ const ratioTrigger = Math.floor(contextWindow * triggerRatio);
119
+ const reserveTrigger = Math.max(1, contextWindow - reserveTokens);
120
+ return {
121
+ enabled: settings.agent_compaction_enabled !== false,
122
+ contextWindow,
123
+ triggerRatio,
124
+ triggerTokens: Math.min(ratioTrigger, reserveTrigger),
125
+ keepRecentTokens: clampInteger(settings.agent_compaction_keep_recent_tokens, DEFAULT_KEEP_RECENT_TOKENS, 4000, 200000),
126
+ summaryMaxTokens: clampInteger(settings.agent_compaction_summary_max_tokens, DEFAULT_SUMMARY_MAX_TOKENS, 1000, 64000),
127
+ compactionMinSavingsTokens: clampInteger(settings.agent_compaction_min_savings_tokens, DEFAULT_MIN_SAVINGS_TOKENS, 0, 500000),
128
+ toolPayloadCompactionTriggerChars: clampInteger(
129
+ settings.agent_tool_payload_compaction_trigger_chars,
130
+ DEFAULT_TOOL_PAYLOAD_COMPACTION_TRIGGER_CHARS,
131
+ 0,
132
+ 10 * 1024 * 1024,
133
+ ),
134
+ toolPruneTriggerTokens: clampInteger(settings.agent_tool_prune_trigger_tokens, DEFAULT_TOOL_PRUNE_TRIGGER_TOKENS, 0, 500000),
135
+ toolTextLimitChars: clampInteger(settings.agent_tool_text_limit_chars, DEFAULT_TOOL_TEXT_LIMIT_CHARS, 1000, 200000),
136
+ bashOutputLimitChars: clampInteger(settings.agent_bash_output_limit_chars, DEFAULT_BASH_OUTPUT_LIMIT_CHARS, 1000, 200000),
137
+ mcpTextLimitChars: clampInteger(settings.agent_mcp_text_limit_chars, DEFAULT_MCP_TEXT_LIMIT_CHARS, 1000, 200000),
138
+ searchResultLimit: clampInteger(settings.agent_search_result_limit, DEFAULT_SEARCH_RESULT_LIMIT, 10, 1000),
139
+ imageInlineMaxBytes: clampInteger(settings.agent_image_inline_max_bytes, DEFAULT_IMAGE_INLINE_MAX_BYTES, 0, 10 * 1024 * 1024),
140
+ toolPayloadMaxBytes: clampInteger(settings.agent_tool_payload_max_bytes, DEFAULT_TOOL_PAYLOAD_MAX_BYTES, 0, 16 * 1024 * 1024),
141
+ mcpCallTimeoutMs: clampInteger(settings.agent_mcp_call_timeout_ms, DEFAULT_MCP_CALL_TIMEOUT_MS, 1000, Number.MAX_SAFE_INTEGER),
142
+ };
143
+ }
144
+
145
+ function assistantToolCalls(message) {
146
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
147
+ return message.content.filter((part) => part?.type === "toolCall");
148
+ }
149
+
150
+ function hasToolCalls(message) {
151
+ return assistantToolCalls(message).length > 0;
152
+ }
153
+
154
+ function chooseFirstKeptIndex(messages, keepRecentTokens) {
155
+ if (!Array.isArray(messages) || messages.length <= 2) return -1;
156
+ let tokens = 0;
157
+ let start = messages.length;
158
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
159
+ tokens += estimateAgentMessageTokens(messages[i]).tokens;
160
+ if (tokens >= keepRecentTokens) {
161
+ start = i;
162
+ break;
163
+ }
164
+ }
165
+ if (start <= 0 || start >= messages.length) return -1;
166
+
167
+ while (start > 0 && messages[start]?.role === "toolResult") start -= 1;
168
+ if (start > 0 && messages[start]?.role === "assistant" && !hasToolCalls(messages[start])) {
169
+ for (let i = start - 1; i >= 0; i -= 1) {
170
+ if (messages[i]?.role === "user") {
171
+ start = i;
172
+ break;
173
+ }
174
+ if (messages[i]?.role === "toolResult") break;
175
+ }
176
+ }
177
+ return start <= 0 ? -1 : start;
178
+ }
179
+
180
+ function oneLine(value, limit = 220) {
181
+ const text = String(value || "").replace(/\s+/g, " ").trim();
182
+ return text.length <= limit ? text : `${text.slice(0, limit - 3).trimEnd()}...`;
183
+ }
184
+
185
+ function extractPathish(value) {
186
+ if (!value || typeof value !== "object") return "";
187
+ return value.file_path || value.path || value.command || value.pattern || value.url || "";
188
+ }
189
+
190
+ function summarizeCompactedMessages(messages, { maxChars }) {
191
+ const previousSummaries = [];
192
+ const userNotes = [];
193
+ const assistantNotes = [];
194
+ const toolActions = [];
195
+ const toolErrors = [];
196
+ const changedFiles = new Set();
197
+
198
+ for (const message of messages) {
199
+ const text = messageText(message);
200
+ if (text.includes(COMPACTED_CONTEXT_MARKER)) {
201
+ previousSummaries.push(oneLine(text, 1000));
202
+ continue;
203
+ }
204
+ if (message.role === "user") {
205
+ userNotes.push(oneLine(text));
206
+ continue;
207
+ }
208
+ if (message.role === "assistant") {
209
+ const toolCalls = assistantToolCalls(message);
210
+ if (toolCalls.length) {
211
+ for (const call of toolCalls) {
212
+ const args = call.arguments || call.input || {};
213
+ const pathish = extractPathish(args);
214
+ toolActions.push(`${call.name || "tool"}${pathish ? `: ${oneLine(pathish, 180)}` : ""}`);
215
+ if (["Write", "Edit"].includes(call.name) && args.file_path) changedFiles.add(args.file_path);
216
+ }
217
+ } else {
218
+ assistantNotes.push(oneLine(text));
219
+ }
220
+ continue;
221
+ }
222
+ if (message.role === "toolResult") {
223
+ const details = message.details || {};
224
+ const params = details.params || details.input || {};
225
+ const pathish = extractPathish(params);
226
+ toolActions.push(`${message.toolName || details.tool || "tool"} result${pathish ? `: ${oneLine(pathish, 180)}` : ""}`);
227
+ const changes = details.changes || params.changes || [];
228
+ for (const change of Array.isArray(changes) ? changes : []) {
229
+ if (change?.path) changedFiles.add(change.path);
230
+ }
231
+ if (message.isError) toolErrors.push(oneLine(text, 300));
232
+ }
233
+ }
234
+
235
+ const sections = [
236
+ previousSummaries.length ? ["Historical context from previous compactions:", ...previousSummaries.slice(-6).map((item) => `- ${item}`)].join("\n") : "",
237
+ userNotes.length ? ["Recent user/task instructions from compacted prefix:", ...userNotes.slice(-8).map((item) => `- ${item}`)].join("\n") : "",
238
+ assistantNotes.length ? ["Agent progress from compacted prefix:", ...assistantNotes.slice(-8).map((item) => `- ${item}`)].join("\n") : "",
239
+ toolActions.length ? ["Tool activity from compacted prefix:", ...toolActions.slice(-20).map((item) => `- ${item}`)].join("\n") : "",
240
+ changedFiles.size ? ["Files likely touched before compaction:", ...[...changedFiles].slice(0, 20).map((item) => `- ${item}`)].join("\n") : "",
241
+ toolErrors.length ? ["Tool errors observed before compaction:", ...toolErrors.slice(-8).map((item) => `- ${item}`)].join("\n") : "",
242
+ ].filter(Boolean);
243
+
244
+ const summary = sections.join("\n\n") || "Prior context contained no compactable text.";
245
+ return summary.length <= maxChars
246
+ ? summary
247
+ : `${summary.slice(0, Math.max(0, maxChars - 40)).trimEnd()}\n[compaction summary truncated]`;
248
+ }
249
+
250
+ function buildCompactionMessage({ id, seq, metrics, firstKeptIndex, compactedCount, summary }) {
251
+ return {
252
+ role: "user",
253
+ timestamp: Date.now(),
254
+ content: [
255
+ `# ${COMPACTED_CONTEXT_MARKER}`,
256
+ "",
257
+ `Compaction id: ${id}`,
258
+ `Compaction sequence: ${seq}`,
259
+ `Compacted messages: ${compactedCount}`,
260
+ `First kept message index: ${firstKeptIndex}`,
261
+ `Estimated tokens before compaction: ${metrics.before.tokens}`,
262
+ `Estimated tokens after compaction: ${metrics.after.tokens}`,
263
+ "",
264
+ "The raw earlier transcript was compacted to keep this long autonomous session within the model context window. Continue from the current workspace state and the recent uncompressed messages below.",
265
+ "",
266
+ summary,
267
+ ].join("\n"),
268
+ };
269
+ }
270
+
271
+ function firstNonEmptyLine(text) {
272
+ for (const raw of String(text || "").split(/\r?\n/)) {
273
+ const line = raw.trim();
274
+ if (line) return line;
275
+ }
276
+ return "";
277
+ }
278
+
279
+ function summarizeToolResultBody(message) {
280
+ const parts = Array.isArray(message?.content) ? message.content : [];
281
+ let textChars = 0;
282
+ let firstSnippet = "";
283
+ let imageCount = 0;
284
+ let imageBytes = 0;
285
+ for (const part of parts) {
286
+ if (part?.type === "text") {
287
+ const t = String(part.text || "");
288
+ textChars += t.length;
289
+ if (!firstSnippet) firstSnippet = firstNonEmptyLine(t);
290
+ } else if (part?.type === "image") {
291
+ imageCount += 1;
292
+ imageBytes += base64Bytes(part.data);
293
+ }
294
+ }
295
+ const facts = [];
296
+ if (textChars > 0) facts.push(`${textChars} text chars`);
297
+ if (imageCount > 0) facts.push(`${imageCount} image${imageCount === 1 ? "" : "s"} (~${imageBytes} bytes)`);
298
+ if (message?.isError) facts.push("status: error");
299
+ if (firstSnippet) {
300
+ const snip = firstSnippet.length > 160 ? `${firstSnippet.slice(0, 160).trimEnd()}…` : firstSnippet;
301
+ facts.push(`first line: "${snip}"`);
302
+ }
303
+ return facts.join("; ");
304
+ }
305
+
306
+ // Replace older tool_result bodies with a 1–3 sentence lossy summary
307
+ // instead of dropping them. Keeps the agent able to reason about prior
308
+ // work (filename, byte count, error status, first-line excerpt) without
309
+ // paying the original payload cost. The full payload remains on disk
310
+ // under <runArtifactDir>/tool-output/ for explicit re-fetch via the
311
+ // artifact path that tool-bloat.js records in details.
312
+ function pruneToolResultContent(message, metrics) {
313
+ const details = {
314
+ ...(message.details || {}),
315
+ context_pruned: true,
316
+ pruned_tokens_estimate: metrics.tokens,
317
+ pruned_chars_estimate: metrics.chars,
318
+ };
319
+ const toolLabel = message.toolName || details.tool || "tool";
320
+ const artifact = details.artifact_path || details.full_output_path || details.path || null;
321
+ const summary = summarizeToolResultBody(message);
322
+ const lines = [
323
+ `[older ${toolLabel} result, summarized to free ~${metrics.tokens} tokens of context]`,
324
+ ];
325
+ if (summary) lines.push(summary);
326
+ if (artifact) lines.push(`Full output preserved at: ${artifact}`);
327
+ else lines.push("Re-issue a targeted tool call (narrower query / smaller range) if the raw payload is needed again.");
328
+ const content = [{ type: "text", text: lines.join("\n") }];
329
+ return { ...message, content, details };
330
+ }
331
+
332
+ function pruneOldToolResults(messages, policy) {
333
+ if (!Array.isArray(messages) || !policy?.toolPruneTriggerTokens) {
334
+ return { changed: false, messages, prunedCount: 0, tokensBefore: 0, tokensAfter: 0 };
335
+ }
336
+ const firstProtectedIndex = chooseFirstKeptIndex(messages, policy.keepRecentTokens);
337
+ if (firstProtectedIndex <= 0) {
338
+ return { changed: false, messages, prunedCount: 0, tokensBefore: 0, tokensAfter: 0 };
339
+ }
340
+
341
+ let prunableTokens = 0;
342
+ const metricsByIndex = new Map();
343
+ for (let i = 0; i < firstProtectedIndex; i += 1) {
344
+ if (messages[i]?.role !== "toolResult") continue;
345
+ if (messages[i]?.details?.context_pruned) continue;
346
+ const metrics = estimateAgentMessageTokens(messages[i]);
347
+ prunableTokens += metrics.tokens;
348
+ metricsByIndex.set(i, metrics);
349
+ }
350
+ if (prunableTokens < policy.toolPruneTriggerTokens) {
351
+ return { changed: false, messages, prunedCount: 0, tokensBefore: prunableTokens, tokensAfter: prunableTokens };
352
+ }
353
+
354
+ let prunedCount = 0;
355
+ const next = messages.map((message, index) => {
356
+ const metrics = metricsByIndex.get(index);
357
+ if (!metrics) return message;
358
+ prunedCount += 1;
359
+ return pruneToolResultContent(message, metrics);
360
+ });
361
+ const tokensAfter = [...metricsByIndex.keys()]
362
+ .reduce((sum, index) => sum + estimateAgentMessageTokens(next[index]).tokens, 0);
363
+ return {
364
+ changed: prunedCount > 0,
365
+ messages: next,
366
+ prunedCount,
367
+ tokensBefore: prunableTokens,
368
+ tokensAfter,
369
+ };
370
+ }
371
+
372
+ function replaceMessagesInPlace(target, next) {
373
+ if (!Array.isArray(target) || target === next) return next;
374
+ target.splice(0, target.length, ...next);
375
+ return target;
376
+ }
377
+
378
+ function truncateText(text, limit, label) {
379
+ const value = String(text || "");
380
+ if (value.length <= limit) {
381
+ return { text: value, truncated: false, originalLength: value.length };
382
+ }
383
+ // First-class truncation marker: tells the model exactly what was cut and
384
+ // what it should do about it. The audit found agents failing tasks because
385
+ // they didn't notice silent ellipses — be loud and actionable.
386
+ const totalKb = Math.round(value.length / 1024);
387
+ const shownKb = Math.round(limit / 1024);
388
+ const marker = [
389
+ "",
390
+ `[!!! ${label || "tool"} OUTPUT TRUNCATED — ${shownKb}KB of ${totalKb}KB shown above. The tool returned ${value.length - limit} more characters that you cannot see.]`,
391
+ "If those characters matter for the task, narrow the query (smaller range, more specific pattern) or paginate. Don't assume the missing tail is empty.",
392
+ ].join("\n");
393
+ return {
394
+ text: `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`,
395
+ truncated: true,
396
+ originalLength: value.length,
397
+ };
398
+ }
399
+
400
+ export function compactToolResultForContext(result, policy, { toolName = "tool" } = {}) {
401
+ if (!Array.isArray(result?.content) || !policy) return { changed: false, result };
402
+ const limit = toolName === "Bash" ? policy.bashOutputLimitChars : policy.toolTextLimitChars;
403
+ let changed = false;
404
+ let originalTextChars = 0;
405
+ let keptTextChars = 0;
406
+ let omittedImages = 0;
407
+ const content = result.content.map((part) => {
408
+ if (part?.type === "text") {
409
+ const truncated = truncateText(part.text || "", limit, toolName);
410
+ originalTextChars += truncated.originalLength;
411
+ keptTextChars += truncated.text.length;
412
+ if (truncated.truncated) changed = true;
413
+ return { ...part, text: truncated.text };
414
+ }
415
+ if (part?.type === "image") {
416
+ const bytes = base64Bytes(part.data);
417
+ if (policy.imageInlineMaxBytes >= 0 && bytes > policy.imageInlineMaxBytes) {
418
+ changed = true;
419
+ omittedImages += 1;
420
+ return {
421
+ type: "text",
422
+ text: `[omitted inline image from ${toolName}: ${bytes} bytes exceeds ${policy.imageInlineMaxBytes} byte context budget]`,
423
+ };
424
+ }
425
+ return part;
426
+ }
427
+ const serialized = jsonString(part);
428
+ const truncated = truncateText(serialized, limit, toolName);
429
+ originalTextChars += truncated.originalLength;
430
+ keptTextChars += truncated.text.length;
431
+ if (truncated.truncated || truncated.text !== serialized) changed = true;
432
+ return { type: "text", text: truncated.text };
433
+ });
434
+
435
+ if (!changed) return { changed: false, result };
436
+ return {
437
+ changed: true,
438
+ result: {
439
+ ...result,
440
+ content,
441
+ details: {
442
+ ...(result.details || {}),
443
+ context_compacted: true,
444
+ original_text_chars: originalTextChars || null,
445
+ kept_text_chars: keptTextChars || null,
446
+ omitted_images: omittedImages || null,
447
+ text_limit_chars: limit,
448
+ image_inline_max_bytes: policy.imageInlineMaxBytes,
449
+ },
450
+ },
451
+ };
452
+ }
453
+
454
+ export function isLikelyContextTermination(message, diagnostics = {}) {
455
+ const text = String(message || "");
456
+ if (!/terminated|aborted before final output|aborted before final|stream.*aborted|context window|context budget/i.test(text)) return false;
457
+ const compactions = Number(diagnostics.context_compactions) || 0;
458
+ if (compactions > 0) return true;
459
+ const estimate = Number(diagnostics.context_tokens_estimate_max || diagnostics.context_tokens_estimate || 0);
460
+ const trigger = Number(diagnostics.context_compaction_trigger_tokens || 0);
461
+ return Boolean(trigger > 0 && estimate >= trigger * 0.85);
462
+ }
463
+
464
+ export function createAgentCompactionManager({
465
+ runId,
466
+ providerKind,
467
+ modelReference,
468
+ model,
469
+ settings = {},
470
+ onEvent,
471
+ onCompactionRecorded,
472
+ } = {}) {
473
+ const policy = resolveAgentCompactionPolicy(settings, model);
474
+ let compactionCount = 0;
475
+ let toolResultsCompacted = 0;
476
+ let toolResultsPruned = 0;
477
+ let toolPayloadCharsSinceCompaction = 0;
478
+ let maxToolPayloadCharsSinceCompaction = 0;
479
+ let forcedCompactionReason = null;
480
+ let maxContextTokensEstimate = 0;
481
+ let lastCompactionId = null;
482
+ let lastError = null;
483
+ let skippedLowSavings = 0;
484
+ let lastLowSavingsSkipTokens = 0;
485
+
486
+ function emit(event) {
487
+ onEvent?.(event);
488
+ }
489
+
490
+ // The host owns persistence: it receives the structured
491
+ // record below and writes it into `run_compactions`. The kernel emits a
492
+ // runtime_warning if the host's callback throws.
493
+ function record(row) {
494
+ if (!onCompactionRecorded || !runId) return;
495
+ const persisted = {
496
+ id: row.id,
497
+ task_run_id: runId,
498
+ seq: row.seq,
499
+ trigger: row.trigger,
500
+ provider_kind: providerKind || null,
501
+ model: modelReference || model?.id || null,
502
+ tokens_before: row.tokensBefore || null,
503
+ tokens_after: row.tokensAfter || null,
504
+ chars_before: row.charsBefore || null,
505
+ chars_after: row.charsAfter || null,
506
+ first_kept_index: row.firstKeptIndex ?? null,
507
+ summary: row.summary || "",
508
+ metadata_json: JSON.stringify(row.metadata || {}),
509
+ status: row.status || "succeeded",
510
+ error_text: row.errorText || null,
511
+ created_at: Date.now(),
512
+ };
513
+ try {
514
+ onCompactionRecorded(persisted);
515
+ } catch (err) {
516
+ lastError = err?.message || String(err);
517
+ emit({
518
+ type: "runtime_warning",
519
+ warning_kind: "context_compaction_record_failed",
520
+ message: lastError,
521
+ });
522
+ }
523
+ }
524
+
525
+ async function transformContext(messages = [], signal) {
526
+ const original = estimateAgentMessages(messages);
527
+ maxContextTokensEstimate = Math.max(maxContextTokensEstimate, original.tokens);
528
+ if (!policy.enabled || signal?.aborted) return messages;
529
+
530
+ let workingMessages = messages;
531
+ const pruned = pruneOldToolResults(workingMessages, policy);
532
+ if (pruned.changed) {
533
+ workingMessages = replaceMessagesInPlace(messages, pruned.messages);
534
+ toolResultsPruned += pruned.prunedCount;
535
+ toolPayloadCharsSinceCompaction = 0;
536
+ const afterPrune = estimateAgentMessages(workingMessages);
537
+ emit({
538
+ type: "tool_context_pruned",
539
+ run_id: runId || null,
540
+ pruned_tool_results: pruned.prunedCount,
541
+ tokens_before: original.tokens,
542
+ tokens_after: afterPrune.tokens,
543
+ tokens_saved: original.tokens - afterPrune.tokens,
544
+ pruned_tool_tokens_before: pruned.tokensBefore,
545
+ pruned_tool_tokens_after: pruned.tokensAfter,
546
+ pruned_tool_tokens_saved: pruned.tokensBefore - pruned.tokensAfter,
547
+ });
548
+ }
549
+
550
+ const before = estimateAgentMessages(workingMessages);
551
+ const trigger = forcedCompactionReason || (before.tokens >= policy.triggerTokens ? "token_budget" : null);
552
+ const lowSavingsBackoff = policy.compactionMinSavingsTokens > 0
553
+ && before.tokens <= lastLowSavingsSkipTokens + Math.max(1000, Math.floor(policy.compactionMinSavingsTokens / 2));
554
+ if (!trigger || lowSavingsBackoff) return workingMessages;
555
+
556
+ const firstKeptIndex = chooseFirstKeptIndex(workingMessages, policy.keepRecentTokens);
557
+ if (firstKeptIndex <= 0) return workingMessages;
558
+
559
+ const id = `cmp_${randomUUID()}`;
560
+ const seq = compactionCount + 1;
561
+ emit({
562
+ type: "context_compaction_started",
563
+ id,
564
+ run_id: runId || null,
565
+ seq,
566
+ trigger,
567
+ tokens_before: before.tokens,
568
+ trigger_tokens: policy.triggerTokens,
569
+ keep_recent_tokens: policy.keepRecentTokens,
570
+ });
571
+
572
+ try {
573
+ const compacted = workingMessages.slice(0, firstKeptIndex);
574
+ const recent = workingMessages.slice(firstKeptIndex);
575
+ const summaryMaxChars = Math.max(4000, policy.summaryMaxTokens * 4);
576
+ const summary = summarizeCompactedMessages(compacted, { maxChars: summaryMaxChars });
577
+ const provisional = [
578
+ {
579
+ role: "user",
580
+ content: summary,
581
+ timestamp: Date.now(),
582
+ },
583
+ ...recent,
584
+ ];
585
+ const after = estimateAgentMessages(provisional);
586
+ const summaryMessage = buildCompactionMessage({
587
+ id,
588
+ seq,
589
+ metrics: { before, after },
590
+ firstKeptIndex,
591
+ compactedCount: compacted.length,
592
+ summary,
593
+ });
594
+ const nextMessages = [summaryMessage, ...recent];
595
+ const finalAfter = estimateAgentMessages(nextMessages);
596
+ const savingsTokens = before.tokens - finalAfter.tokens;
597
+ const emergencyTriggerTokens = Math.floor(policy.contextWindow * 0.95);
598
+ if (
599
+ policy.compactionMinSavingsTokens > 0
600
+ && savingsTokens < policy.compactionMinSavingsTokens
601
+ && before.tokens < emergencyTriggerTokens
602
+ ) {
603
+ skippedLowSavings += 1;
604
+ lastLowSavingsSkipTokens = before.tokens;
605
+ forcedCompactionReason = null;
606
+ emit({
607
+ type: "runtime_warning",
608
+ warning_kind: "context_compaction_skipped_low_savings",
609
+ message: `Skipped context compaction because estimated savings were ${savingsTokens} tokens below the ${policy.compactionMinSavingsTokens} token minimum.`,
610
+ diagnostics: {
611
+ trigger,
612
+ tokens_before: before.tokens,
613
+ tokens_after: finalAfter.tokens,
614
+ savings_tokens: savingsTokens,
615
+ min_savings_tokens: policy.compactionMinSavingsTokens,
616
+ },
617
+ });
618
+ return workingMessages;
619
+ }
620
+ compactionCount += 1;
621
+ lastCompactionId = id;
622
+ record({
623
+ id,
624
+ seq,
625
+ trigger,
626
+ tokensBefore: before.tokens,
627
+ tokensAfter: finalAfter.tokens,
628
+ charsBefore: before.chars,
629
+ charsAfter: finalAfter.chars,
630
+ firstKeptIndex,
631
+ summary,
632
+ metadata: {
633
+ context_window: policy.contextWindow,
634
+ trigger_tokens: policy.triggerTokens,
635
+ keep_recent_tokens: policy.keepRecentTokens,
636
+ min_savings_tokens: policy.compactionMinSavingsTokens,
637
+ savings_tokens: savingsTokens,
638
+ tool_payload_chars_since_compaction: toolPayloadCharsSinceCompaction,
639
+ compacted_messages: compacted.length,
640
+ kept_messages: recent.length,
641
+ },
642
+ });
643
+ toolPayloadCharsSinceCompaction = 0;
644
+ forcedCompactionReason = null;
645
+ emit({
646
+ type: "context_compaction_completed",
647
+ id,
648
+ run_id: runId || null,
649
+ seq,
650
+ tokens_before: before.tokens,
651
+ tokens_after: finalAfter.tokens,
652
+ tokens_saved: savingsTokens,
653
+ chars_before: before.chars,
654
+ chars_after: finalAfter.chars,
655
+ compacted_messages: compacted.length,
656
+ kept_messages: recent.length,
657
+ first_kept_index: firstKeptIndex,
658
+ });
659
+ return replaceMessagesInPlace(messages, nextMessages);
660
+ } catch (err) {
661
+ lastError = err?.message || String(err);
662
+ record({
663
+ id,
664
+ seq,
665
+ trigger,
666
+ tokensBefore: before.tokens,
667
+ charsBefore: before.chars,
668
+ firstKeptIndex,
669
+ summary: "",
670
+ status: "failed",
671
+ errorText: lastError,
672
+ });
673
+ emit({
674
+ type: "runtime_warning",
675
+ warning_kind: "context_compaction_failed",
676
+ message: lastError,
677
+ });
678
+ return workingMessages;
679
+ }
680
+ }
681
+
682
+ async function afterToolCall({ toolCall, result }, signal) {
683
+ if (signal?.aborted) return undefined;
684
+ const compacted = compactToolResultForContext(result, policy, { toolName: toolCall?.name || "tool" });
685
+ const visibleResult = compacted.changed ? compacted.result : result;
686
+ const payloadChars = estimateAgentMessageTokens({
687
+ role: "toolResult",
688
+ toolName: toolCall?.name || "tool",
689
+ content: visibleResult?.content || [],
690
+ details: visibleResult?.details || null,
691
+ }).chars;
692
+ toolPayloadCharsSinceCompaction += payloadChars;
693
+ maxToolPayloadCharsSinceCompaction = Math.max(maxToolPayloadCharsSinceCompaction, toolPayloadCharsSinceCompaction);
694
+ if (
695
+ policy.enabled
696
+ && policy.toolPayloadCompactionTriggerChars > 0
697
+ && toolPayloadCharsSinceCompaction >= policy.toolPayloadCompactionTriggerChars
698
+ ) {
699
+ forcedCompactionReason = forcedCompactionReason || "tool_payload_budget";
700
+ }
701
+ if (!compacted.changed) return undefined;
702
+ toolResultsCompacted += 1;
703
+ emit({
704
+ type: "tool_result_compacted",
705
+ tool_use_id: toolCall?.id || null,
706
+ name: toolCall?.name || null,
707
+ text_limit_chars: compacted.result.details?.text_limit_chars || null,
708
+ original_text_chars: compacted.result.details?.original_text_chars || null,
709
+ kept_text_chars: compacted.result.details?.kept_text_chars || null,
710
+ omitted_images: compacted.result.details?.omitted_images || null,
711
+ });
712
+ return {
713
+ content: compacted.result.content,
714
+ details: compacted.result.details,
715
+ };
716
+ }
717
+
718
+ function diagnostics() {
719
+ return {
720
+ context_compaction_enabled: policy.enabled,
721
+ context_compactions: compactionCount,
722
+ context_compaction_last_id: lastCompactionId,
723
+ context_compaction_last_error: lastError,
724
+ context_compactions_skipped_low_savings: skippedLowSavings,
725
+ context_window_tokens: policy.contextWindow,
726
+ context_compaction_trigger_tokens: policy.triggerTokens,
727
+ context_keep_recent_tokens: policy.keepRecentTokens,
728
+ context_compaction_min_savings_tokens: policy.compactionMinSavingsTokens,
729
+ context_tokens_estimate_max: maxContextTokensEstimate,
730
+ tool_results_compacted: toolResultsCompacted,
731
+ tool_results_pruned: toolResultsPruned,
732
+ tool_payload_chars_since_compaction: toolPayloadCharsSinceCompaction,
733
+ tool_payload_chars_since_compaction_max: maxToolPayloadCharsSinceCompaction,
734
+ tool_payload_compaction_trigger_chars: policy.toolPayloadCompactionTriggerChars,
735
+ tool_prune_trigger_tokens: policy.toolPruneTriggerTokens,
736
+ context_compaction_pending_reason: forcedCompactionReason,
737
+ tool_text_limit_chars: policy.toolTextLimitChars,
738
+ bash_output_limit_chars: policy.bashOutputLimitChars,
739
+ mcp_text_limit_chars: policy.mcpTextLimitChars,
740
+ search_result_limit: policy.searchResultLimit,
741
+ image_inline_max_bytes: policy.imageInlineMaxBytes,
742
+ mcp_call_timeout_ms: policy.mcpCallTimeoutMs,
743
+ };
744
+ }
745
+
746
+ return {
747
+ policy,
748
+ transformContext,
749
+ afterToolCall,
750
+ diagnostics,
751
+ };
752
+ }