@mono-agent/agent-runtime 0.3.0 → 0.4.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.
- package/ARCHITECTURE.md +48 -3
- package/MIGRATION.md +115 -0
- package/README.md +24 -8
- package/package.json +3 -2
- package/src/agent/compaction.js +11 -677
- package/src/agent/index.js +1 -1
- package/src/agent/tools/pi-bridge.js +46 -10
- package/src/agent/tools/web-fetch.js +48 -12
- package/src/ai/failure.js +1 -1
- package/src/ai/index.js +1 -0
- package/src/ai/providers/claude-cli.js +5 -1
- package/src/ai/providers/pi-errors.js +65 -0
- package/src/ai/providers/pi-native.js +1445 -0
- package/src/ai/providers/pi-sdk.js +26 -1301
- package/src/ai/runtime/capabilities.js +3 -0
- package/src/ai/runtime/registry.js +4 -2
- package/src/ai/runtime/router.js +23 -7
- package/src/ai/runtime/sessions.js +3 -2
- package/src/ai/types.js +0 -1
- package/src/runtime.js +3 -2
package/src/agent/compaction.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// Context-compaction policy + heuristics for the pi-native bridge.
|
|
2
|
+
//
|
|
3
|
+
// The hand-rolled in-loop compaction manager (transformContext/afterToolCall)
|
|
4
|
+
// was retired with the legacy pi-sdk Agent path — the sole pi bridge uses
|
|
5
|
+
// pi-agent-core's AgentHarness, which owns compaction via harness.compact().
|
|
6
|
+
// What remains here are two pure helpers the bridge still consumes:
|
|
7
|
+
// - resolveAgentCompactionPolicy: derives the context-window compaction
|
|
8
|
+
// trigger and the tool-output payload limits from settings + the running
|
|
9
|
+
// model. Pure (no Agent loop), so the bridge computes it directly.
|
|
10
|
+
// - isLikelyContextTermination: classifies a provider error/termination as a
|
|
11
|
+
// context-pressure event.
|
|
4
12
|
|
|
5
13
|
const DEFAULT_CONTEXT_WINDOW = 128000;
|
|
6
14
|
const DEFAULT_TRIGGER_RATIO = 0.85;
|
|
@@ -31,81 +39,6 @@ function clampInteger(value, fallback, min, max) {
|
|
|
31
39
|
return Math.floor(clampNumber(value, fallback, min, max));
|
|
32
40
|
}
|
|
33
41
|
|
|
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
42
|
export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
|
|
110
43
|
const contextWindow = clampInteger(model?.contextWindow, DEFAULT_CONTEXT_WINDOW, 32000, 10_000_000);
|
|
111
44
|
const triggerRatio = clampNumber(
|
|
@@ -142,315 +75,6 @@ export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
|
|
|
142
75
|
};
|
|
143
76
|
}
|
|
144
77
|
|
|
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
78
|
export function isLikelyContextTermination(message, diagnostics = {}) {
|
|
455
79
|
const text = String(message || "");
|
|
456
80
|
if (!/terminated|aborted before final output|aborted before final|stream.*aborted|context window|context budget/i.test(text)) return false;
|
|
@@ -460,293 +84,3 @@ export function isLikelyContextTermination(message, diagnostics = {}) {
|
|
|
460
84
|
const trigger = Number(diagnostics.context_compaction_trigger_tokens || 0);
|
|
461
85
|
return Boolean(trigger > 0 && estimate >= trigger * 0.85);
|
|
462
86
|
}
|
|
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
|
-
}
|