@mono-agent/agent-runtime 0.11.4 → 0.12.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 +9 -8
- package/MIGRATION.md +10 -5
- package/README.md +31 -10
- package/package.json +1 -1
- package/src/agent/compaction.js +28 -10
- package/src/agent/tools/node-repl.js +406 -0
- package/src/agent/tools/pi-bridge.js +13 -2
- package/src/ai/failure.js +28 -3
- package/src/ai/providers/opencode-app.js +2 -1
- package/src/ai/providers/pi-errors.js +6 -11
- package/src/ai/providers/pi-native/compaction-driver.js +304 -52
- package/src/ai/providers/pi-native/result-builder.js +5 -5
- package/src/ai/providers/pi-native/stream-subscriber.js +0 -15
- package/src/ai/providers/pi-native/turn-runner.js +20 -3
- package/src/ai/providers/pi-native.js +14 -3
- package/src/ai/types.js +5 -4
- package/types/agent/tools/node-repl.d.ts +19 -0
- package/types/agent/tools/pi-bridge.d.ts +3 -2
- package/types/ai/failure.d.ts +11 -2
- package/types/ai/providers/opencode-app.d.ts +1 -1
- package/types/ai/providers/pi-native/compaction-driver.d.ts +20 -6
- package/types/ai/providers/pi-native/result-builder.d.ts +3 -2
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +0 -7
- package/types/ai/providers/pi-native/turn-runner.d.ts +4 -2
- package/types/ai/types.d.ts +10 -8
|
@@ -252,7 +252,7 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
|
|
|
252
252
|
label,
|
|
253
253
|
description,
|
|
254
254
|
parameters,
|
|
255
|
-
executionMode: name === "Write" || name === "Edit" || name === "Bash" ? "sequential" : undefined,
|
|
255
|
+
executionMode: name === "Write" || name === "Edit" || name === "Bash" || name === "NodeRepl" ? "sequential" : undefined,
|
|
256
256
|
async execute(toolCallId, params, signal) {
|
|
257
257
|
if (signal?.aborted) throw new Error("tool execution aborted");
|
|
258
258
|
const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx });
|
|
@@ -379,7 +379,7 @@ export function createStructuredOutputTool(outputSchema, onStructuredOutput) {
|
|
|
379
379
|
|
|
380
380
|
/**
|
|
381
381
|
* @param {any} allowedTools
|
|
382
|
-
* @param {{disallowedTools?: any[], skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, ctx?: any}} [options]
|
|
382
|
+
* @param {{disallowedTools?: any[], skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, nodeReplController?: any, ctx?: any}} [options]
|
|
383
383
|
*/
|
|
384
384
|
export function getPiBuiltinTools(allowedTools, {
|
|
385
385
|
disallowedTools = [],
|
|
@@ -399,6 +399,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
399
399
|
sandboxEngine = null,
|
|
400
400
|
approvalManager = null,
|
|
401
401
|
approvalModel = null,
|
|
402
|
+
nodeReplController = null,
|
|
402
403
|
ctx = null,
|
|
403
404
|
} = {}) {
|
|
404
405
|
const textLimitSchema = integerSchema();
|
|
@@ -457,6 +458,16 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
457
458
|
timeout: bashTimeoutSchema,
|
|
458
459
|
max_output_chars: bashLimitSchema,
|
|
459
460
|
}, ["command"]), bashToolImpl, toolContext),
|
|
461
|
+
NodeRepl: nodeReplController
|
|
462
|
+
? createBuiltinTool(
|
|
463
|
+
"NodeRepl",
|
|
464
|
+
"Node REPL",
|
|
465
|
+
"Evaluate JavaScript in a run-scoped Node.js REPL. Variables persist across NodeRepl calls in this run.",
|
|
466
|
+
objectSchema({ code: { type: "string", minLength: 1 } }, ["code"]),
|
|
467
|
+
(params, { signal }) => nodeReplController.execute(params, { signal }),
|
|
468
|
+
toolContext,
|
|
469
|
+
)
|
|
470
|
+
: null,
|
|
460
471
|
WebFetch: createBuiltinTool("WebFetch", "Web Fetch", "Fetch a URL and return text.", objectSchema({
|
|
461
472
|
url: { type: "string" },
|
|
462
473
|
headers: { type: "object", additionalProperties: { type: "string" } },
|
package/src/ai/failure.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* @typedef {"spawn" | "timeout" | "stall" | "usage_limit" | "invalid_result"
|
|
11
|
+
* @typedef {"spawn" | "timeout" | "stall" | "context_limit" | "usage_limit" | "invalid_result"
|
|
12
12
|
* | "invalid_delegation" | "tool_failure" | "provider_unavailable"
|
|
13
13
|
* | "provider_unavailable_exhausted" | "provider_auth"
|
|
14
14
|
* | "skipped_capability_mismatch" | "cancelled" | "cancelled_user"
|
|
@@ -41,6 +41,7 @@ export const FAILURE_KINDS = [
|
|
|
41
41
|
"spawn",
|
|
42
42
|
"timeout",
|
|
43
43
|
"stall",
|
|
44
|
+
"context_limit",
|
|
44
45
|
"usage_limit",
|
|
45
46
|
"invalid_result",
|
|
46
47
|
"invalid_delegation",
|
|
@@ -69,7 +70,8 @@ export const FAILURE_KINDS = [
|
|
|
69
70
|
"session_busy",
|
|
70
71
|
];
|
|
71
72
|
|
|
72
|
-
const
|
|
73
|
+
const CONTEXT_LIMIT_RE = /(?:context[_ ](?:length|window|budget)|token[_ ]limit|(?:input|prompt)(?:[_ ]tokens?)?[_ ](?:is[_ ])?too[_ ]long|(?:input|prompt|request)(?:[_ ]tokens?)?[_ ]exceeds?[_ ](?:the[_ ])?(?:context|maximum|max|limit|allowed[_ ]size)|too[_ ]many[_ ](?:input[_ ])?tokens?|tokens?[_ ]exceed(?:s|ed)?[_ ](?:the[_ ])?(?:context|maximum|max|(?:model[_ ])?limit))/i;
|
|
74
|
+
const USAGE_LIMIT_RE = /(rate limit|usage limit|max(?:imum)?(?:[_ ]output)?[_ ]tokens?|max turns)/i;
|
|
73
75
|
const PROVIDER_AUTH_RE = /(no api key|missing api key|api key required|invalid api key|incorrect api key|authentication|authorization|not authorized|forbidden|oauth (?:refresh|auth|authentication|token).*failed|credential store (?:read|modify) failed|401|403)/i;
|
|
74
76
|
// Mirrors the conservative connection-error/refused/failed alternation added to
|
|
75
77
|
// RETRYABLE_PROVIDER_RE / retryableProviderSubkind below for pi 0.80's terse
|
|
@@ -96,6 +98,21 @@ export function isProviderAuthFailureText(text = "") {
|
|
|
96
98
|
return PROVIDER_AUTH_RE.test(text || "");
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Identify request-input/context-window overflows without conflating provider
|
|
103
|
+
* throttling or output-token ceilings. Context overflows are route-local: a
|
|
104
|
+
* fallback model may have a larger usable window, while rate/quota/max-turn
|
|
105
|
+
* failures retain the terminal `usage_limit` classification.
|
|
106
|
+
* @param {string} text
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function isContextLimitFailureText(text = "") {
|
|
110
|
+
const value = String(text || "");
|
|
111
|
+
if (/rate limit|too many requests/i.test(value)) return false;
|
|
112
|
+
if (/output[_ ]tokens?|output[_ ]token[_ ]limit/i.test(value)) return false;
|
|
113
|
+
return CONTEXT_LIMIT_RE.test(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
function requestIdFromText(text) {
|
|
100
117
|
const match = /\b(?:request[_ -]?id|req[_ -]?id)\s*[:#]?\s*([A-Za-z0-9._:-]{8,})/i.exec(text || "");
|
|
101
118
|
return match?.[1]?.replace(/[.,;:]+$/, "") || null;
|
|
@@ -125,10 +142,17 @@ export function retryableProviderFailureInfo({
|
|
|
125
142
|
stderrTail = "",
|
|
126
143
|
failureKind = null,
|
|
127
144
|
} = {}) {
|
|
145
|
+
const haystack = `${errorText || ""}\n${stderrTail || ""}`.trim();
|
|
146
|
+
if (failureKind === "context_limit") {
|
|
147
|
+
return {
|
|
148
|
+
retryable: true,
|
|
149
|
+
subkind: "context_limit",
|
|
150
|
+
requestId: requestIdFromText(haystack),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
128
153
|
if (failureKind && failureKind !== "provider_unavailable") {
|
|
129
154
|
return { retryable: false, subkind: null, requestId: null };
|
|
130
155
|
}
|
|
131
|
-
const haystack = `${errorText || ""}\n${stderrTail || ""}`.trim();
|
|
132
156
|
if (!haystack) return { retryable: false, subkind: null, requestId: null };
|
|
133
157
|
const requestId = requestIdFromText(haystack);
|
|
134
158
|
if (NON_RETRYABLE_PROVIDER_RE.test(haystack)) {
|
|
@@ -206,6 +230,7 @@ export function classifyFailure({
|
|
|
206
230
|
if (hint && FAILURE_KINDS.includes(hint)) return hint;
|
|
207
231
|
|
|
208
232
|
const haystack = `${errorText || ""}\n${stderrTail || ""}`;
|
|
233
|
+
if (isContextLimitFailureText(haystack)) return "context_limit";
|
|
209
234
|
if (USAGE_LIMIT_RE.test(haystack)) return "usage_limit";
|
|
210
235
|
if (TOOL_FAILURE_RE.test(haystack)) return "tool_failure";
|
|
211
236
|
if (PROVIDER_AUTH_RE.test(haystack)) return "provider_auth";
|
|
@@ -364,7 +364,8 @@ function finalTextFromParts(parts) {
|
|
|
364
364
|
export function mapErrorFailureKind(error) {
|
|
365
365
|
const name = error?.name || error?.data?.name || "";
|
|
366
366
|
if (name === "MessageAbortedError") return "cancelled";
|
|
367
|
-
if (name === "
|
|
367
|
+
if (name === "ContextOverflowError") return "context_limit";
|
|
368
|
+
if (name === "MessageOutputLengthError") return "usage_limit";
|
|
368
369
|
if (name === "ProviderAuthError") return "provider_auth";
|
|
369
370
|
return "provider_unavailable";
|
|
370
371
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Shared pi error-normalization helpers.
|
|
2
2
|
//
|
|
3
3
|
// Extracted so the pi-native bridge does not have to import from a sibling
|
|
4
|
-
// provider module.
|
|
5
|
-
// envelopes
|
|
6
|
-
//
|
|
4
|
+
// provider module. The message normalizer unwraps nested provider error
|
|
5
|
+
// envelopes; the context-limit classifier delegates to the runtime-wide
|
|
6
|
+
// taxonomy so every bridge makes the same fallback decision.
|
|
7
|
+
|
|
8
|
+
import { isContextLimitFailureText } from "../failure.js";
|
|
7
9
|
|
|
8
10
|
function tryParseJson(text) {
|
|
9
11
|
try { return JSON.parse(text); } catch { return null; }
|
|
@@ -21,14 +23,7 @@ export function normalizePiErrorMessage(message) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function isContextLimitError(message) {
|
|
24
|
-
|
|
25
|
-
// Rate-limit wording takes precedence: it is a throttle, not a context overflow.
|
|
26
|
-
if (/rate limit|too many requests/i.test(text)) return false;
|
|
27
|
-
// Broadened to catch the many ways providers phrase a context/token overflow:
|
|
28
|
-
// "context length/window/budget", "max(imum) tokens", "token limit",
|
|
29
|
-
// "too many tokens", "prompt (is) too long", "exceeds the context/maximum/max",
|
|
30
|
-
// "input tokens/exceeds", "output token(s)", "token(s) exceed".
|
|
31
|
-
return /context[_ ](?:length|window|budget)|max(?:imum)?[_ ]?tokens?|token[_ ]limit|too[_ ]many[_ ]tokens?|prompt[_ ](?:is[_ ])?too[_ ]long|exceeds?[_ ](?:the context|maximum|max)|input[_ ](?:tokens?|exceeds)|output[_ ]tokens?|tokens?[_ ]exceed/i.test(text);
|
|
26
|
+
return isContextLimitFailureText(message);
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
// Best-effort extraction of the model's real context-window ceiling from an
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// bridge DRIVES it: proactively before a turn when the running model's context
|
|
7
7
|
// is near the window, and reactively (compact + single re-prompt) if a turn
|
|
8
8
|
// still overflows. The window auto-tracks the model actually serving the request
|
|
9
|
-
// and
|
|
9
|
+
// and learns lower effective ceilings from numeric or generic overflow errors.
|
|
10
10
|
//
|
|
11
11
|
// DELEGATED to pi where pi provides the primitive: the proactive trigger
|
|
12
12
|
// DECISION runs through pi's shouldCompact() (via piCompactionSettings) and the
|
|
@@ -24,9 +24,13 @@
|
|
|
24
24
|
// on the caller-owned runState.compaction.
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
|
+
buildSessionContext,
|
|
27
28
|
calculateContextTokens,
|
|
29
|
+
compact as compactPreparedContext,
|
|
28
30
|
estimateContextTokens,
|
|
31
|
+
estimateTokens,
|
|
29
32
|
getLastAssistantUsage,
|
|
33
|
+
prepareCompaction,
|
|
30
34
|
shouldCompact,
|
|
31
35
|
} from "@earendil-works/pi-agent-core";
|
|
32
36
|
import {
|
|
@@ -61,8 +65,11 @@ function liveModelContextWindow(harness, runtime) {
|
|
|
61
65
|
return win > 0 ? win : 0;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
function effectiveContextWindow(harness, runtime, resolved) {
|
|
65
|
-
const
|
|
68
|
+
function effectiveContextWindow(harness, runtime, resolved, contextWindowOverride) {
|
|
69
|
+
const override = Number(contextWindowOverride);
|
|
70
|
+
const declared = Number.isFinite(override) && override > 0
|
|
71
|
+
? override
|
|
72
|
+
: liveModelContextWindow(harness, runtime);
|
|
66
73
|
const discovered = discoveredContextWindows.get(modelWindowKey(harness, runtime, resolved));
|
|
67
74
|
if (Number.isFinite(discovered) && discovered > 0) {
|
|
68
75
|
return declared > 0 ? Math.min(declared, discovered) : discovered;
|
|
@@ -97,11 +104,12 @@ function recordDiscoveredContextWindow(harness, runtime, resolved, limit) {
|
|
|
97
104
|
// `fixedOverheadTokens` is the system-prompt + tool-schema + per-turn user
|
|
98
105
|
// message overhead the provider meters but the transcript estimate (which covers
|
|
99
106
|
// only session.buildContext().messages) excludes. It is added to the ESTIMATE
|
|
100
|
-
// branch
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
107
|
+
// branch. The usage-based count already includes the prior request's system/tool
|
|
108
|
+
// overhead, but a proactive check runs before the current user turn is appended;
|
|
109
|
+
// `usageIncrementTokens` adds that new turn without double-counting the stable
|
|
110
|
+
// system/tool portion. With stale/0 usage and a seeded session the estimate
|
|
111
|
+
// branch wins, and without this the trigger under-counts the real request.
|
|
112
|
+
export async function estimateCurrentContextTokens(session, fixedOverheadTokens = 0, usageIncrementTokens = 0) {
|
|
105
113
|
let usageTokens = 0;
|
|
106
114
|
let rawTokens = 0;
|
|
107
115
|
try {
|
|
@@ -110,31 +118,204 @@ export async function estimateCurrentContextTokens(session, fixedOverheadTokens
|
|
|
110
118
|
} catch { /* ignore — fall back to the transcript estimate */ }
|
|
111
119
|
try {
|
|
112
120
|
const context = await session.buildContext();
|
|
113
|
-
|
|
121
|
+
const messages = context?.messages || [];
|
|
122
|
+
const piEstimate = estimateContextTokens(messages);
|
|
123
|
+
// When a valid provider usage record exists, Pi adds estimates for messages
|
|
124
|
+
// trailing that record. Prefer that request-shaped count over the bare entry
|
|
125
|
+
// usage gathered above.
|
|
126
|
+
if (Number(piEstimate.usageTokens) > 0) {
|
|
127
|
+
usageTokens = Number(piEstimate.tokens) || usageTokens;
|
|
128
|
+
}
|
|
129
|
+
// Keep the independent transcript branch genuinely usage-free. Calling
|
|
130
|
+
// estimateContextTokens() here would fold provider usage in a second time,
|
|
131
|
+
// then adding fixed overhead would double-count prior system/tool tokens.
|
|
132
|
+
rawTokens = messages.reduce((total, message) => total + (Number(estimateTokens(message)) || 0), 0);
|
|
114
133
|
} catch { /* ignore — usage-based estimate stands */ }
|
|
115
134
|
// Apply the fixed overhead to the transcript estimate only (see note above).
|
|
116
135
|
rawTokens += Number(fixedOverheadTokens) || 0;
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
|
|
136
|
+
const currentUsageTokens = usageTokens > 0 ? usageTokens + (Number(usageIncrementTokens) || 0) : 0;
|
|
137
|
+
if (currentUsageTokens === 0 && rawTokens === 0) return { tokens: 0, source: "unavailable" };
|
|
138
|
+
return currentUsageTokens >= rawTokens
|
|
139
|
+
? { tokens: currentUsageTokens, source: "usage" }
|
|
120
140
|
: { tokens: rawTokens, source: "estimate" };
|
|
121
141
|
}
|
|
122
142
|
|
|
143
|
+
// Compaction effectiveness must be measured independently of the provider's
|
|
144
|
+
// last-assistant usage. That usage can describe the pre-compaction request and
|
|
145
|
+
// remain attached to a retained message, making estimateContextTokens() report
|
|
146
|
+
// the old large value even after the transcript prefix was summarized. Summing
|
|
147
|
+
// pi's per-message estimator gives a stable before/after comparison over the
|
|
148
|
+
// actual context the session will build next.
|
|
149
|
+
async function estimateSessionMessageTokens(session) {
|
|
150
|
+
if (!session || typeof session.buildContext !== "function") return null;
|
|
151
|
+
try {
|
|
152
|
+
const context = await session.buildContext();
|
|
153
|
+
return (context?.messages || []).reduce((total, message) => total + (Number(estimateTokens(message)) || 0), 0);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function estimateBuiltContextTokens(branchEntries) {
|
|
160
|
+
try {
|
|
161
|
+
return buildSessionContext(branchEntries).messages.reduce(
|
|
162
|
+
(total, message) => total + (Number(estimateTokens(message)) || 0),
|
|
163
|
+
0,
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function piSummaryGenerationLimit(reserveTokens, isSplitTurn) {
|
|
171
|
+
return Math.floor(0.8 * reserveTokens) + (isSplitTurn ? Math.floor(0.5 * reserveTokens) : 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Pi derives its summary output limit from reserveTokens. A normal compaction
|
|
176
|
+
* uses floor(0.8 * reserve); a split-turn compaction may generate both that
|
|
177
|
+
* history summary and floor(0.5 * reserve) for the turn prefix. Return the
|
|
178
|
+
* largest reserve whose derived generation budget does not exceed the public
|
|
179
|
+
* summaryMaxTokens setting.
|
|
180
|
+
* @param {number} summaryMaxTokens
|
|
181
|
+
* @param {boolean} isSplitTurn
|
|
182
|
+
*/
|
|
183
|
+
export function piSummaryReserveTokens(summaryMaxTokens, isSplitTurn) {
|
|
184
|
+
const budget = Math.max(1, Math.floor(Number(summaryMaxTokens) || 1));
|
|
185
|
+
const factor = isSplitTurn ? 1.3 : 0.8;
|
|
186
|
+
let reserve = Math.max(1, Math.floor(budget / factor));
|
|
187
|
+
while (piSummaryGenerationLimit(reserve + 1, isSplitTurn) <= budget) reserve += 1;
|
|
188
|
+
while (reserve > 1 && piSummaryGenerationLimit(reserve, isSplitTurn) > budget) reserve -= 1;
|
|
189
|
+
return reserve;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function previewCompactedContext(branchEntries, result) {
|
|
193
|
+
const previewEntry = {
|
|
194
|
+
type: "compaction",
|
|
195
|
+
id: "mono-agent-compaction-preview",
|
|
196
|
+
parentId: branchEntries.at(-1)?.id || null,
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
summary: result.summary,
|
|
199
|
+
firstKeptEntryId: result.firstKeptEntryId,
|
|
200
|
+
tokensBefore: result.tokensBefore,
|
|
201
|
+
details: result.details,
|
|
202
|
+
fromHook: true,
|
|
203
|
+
};
|
|
204
|
+
return estimateBuiltContextTokens([...branchEntries, previewEntry]);
|
|
205
|
+
}
|
|
206
|
+
|
|
123
207
|
// Run a single guarded compaction. Requires the harness idle (callers
|
|
124
208
|
// waitForIdle first). Never throws — classifies AgentHarnessError into a warning
|
|
125
209
|
// and reports back whether anything was compacted. Fires onCompactionRecorded on
|
|
126
210
|
// success so a host can persist the compaction row.
|
|
127
|
-
export async function tryCompact(harness, {
|
|
211
|
+
export async function tryCompact(harness, {
|
|
212
|
+
trigger,
|
|
213
|
+
onEvent,
|
|
214
|
+
runtimeWarnings,
|
|
215
|
+
onCompactionRecorded,
|
|
216
|
+
runId,
|
|
217
|
+
model,
|
|
218
|
+
session,
|
|
219
|
+
policy,
|
|
220
|
+
}) {
|
|
221
|
+
const adaptivePolicy = resolveAgentCompactionPolicy({}, {
|
|
222
|
+
contextWindow: typeof harness?.getModel === "function" ? harness.getModel()?.contextWindow : undefined,
|
|
223
|
+
});
|
|
224
|
+
const effectivePolicy = { ...adaptivePolicy, ...(policy || {}) };
|
|
225
|
+
/** @type {null | {kind: string, tokensBefore?: number|null, tokensAfter?: number|null, savings?: number|null, error?: any}} */
|
|
226
|
+
let hookDecision = null;
|
|
227
|
+
let removeHook = null;
|
|
128
228
|
try {
|
|
229
|
+
if (typeof harness?.on !== "function") {
|
|
230
|
+
throw new Error("Pi AgentHarness does not expose session_before_compact hooks");
|
|
231
|
+
}
|
|
232
|
+
removeHook = harness.on("session_before_compact", async (event) => {
|
|
233
|
+
try {
|
|
234
|
+
let settings = {
|
|
235
|
+
enabled: true,
|
|
236
|
+
reserveTokens: piSummaryReserveTokens(effectivePolicy.summaryMaxTokens, false),
|
|
237
|
+
keepRecentTokens: effectivePolicy.keepRecentTokens,
|
|
238
|
+
};
|
|
239
|
+
let prepared = prepareCompaction(event.branchEntries, settings);
|
|
240
|
+
if (prepared.ok === false) {
|
|
241
|
+
hookDecision = { kind: "failed", error: prepared.error };
|
|
242
|
+
return { cancel: true };
|
|
243
|
+
}
|
|
244
|
+
if (!prepared.value) {
|
|
245
|
+
hookDecision = { kind: "nothing_to_compact" };
|
|
246
|
+
return { cancel: true };
|
|
247
|
+
}
|
|
248
|
+
if (prepared.value.isSplitTurn) {
|
|
249
|
+
settings = {
|
|
250
|
+
...settings,
|
|
251
|
+
reserveTokens: piSummaryReserveTokens(effectivePolicy.summaryMaxTokens, true),
|
|
252
|
+
};
|
|
253
|
+
prepared = prepareCompaction(event.branchEntries, settings);
|
|
254
|
+
if (prepared.ok === false) {
|
|
255
|
+
hookDecision = { kind: "failed", error: prepared.error };
|
|
256
|
+
return { cancel: true };
|
|
257
|
+
}
|
|
258
|
+
if (!prepared.value) {
|
|
259
|
+
hookDecision = { kind: "nothing_to_compact" };
|
|
260
|
+
return { cancel: true };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const compacted = await compactPreparedContext(
|
|
264
|
+
prepared.value,
|
|
265
|
+
harness.models,
|
|
266
|
+
harness.getModel(),
|
|
267
|
+
event.customInstructions,
|
|
268
|
+
event.signal,
|
|
269
|
+
typeof harness.getThinkingLevel === "function" ? harness.getThinkingLevel() : undefined,
|
|
270
|
+
);
|
|
271
|
+
if (compacted.ok === false) {
|
|
272
|
+
hookDecision = { kind: "failed", error: compacted.error };
|
|
273
|
+
return { cancel: true };
|
|
274
|
+
}
|
|
275
|
+
const tokensBefore = estimateBuiltContextTokens(event.branchEntries);
|
|
276
|
+
const tokensAfter = previewCompactedContext(event.branchEntries, compacted.value);
|
|
277
|
+
const savings = tokensBefore === null || tokensAfter === null ? null : tokensBefore - tokensAfter;
|
|
278
|
+
if (savings === null || savings <= 0) {
|
|
279
|
+
hookDecision = { kind: "not_reducible", tokensBefore, tokensAfter, savings };
|
|
280
|
+
return { cancel: true };
|
|
281
|
+
}
|
|
282
|
+
if (trigger === "proactive" && savings < effectivePolicy.compactionMinSavingsTokens) {
|
|
283
|
+
hookDecision = { kind: "insufficient_savings", tokensBefore, tokensAfter, savings };
|
|
284
|
+
return { cancel: true };
|
|
285
|
+
}
|
|
286
|
+
hookDecision = { kind: "accepted", tokensBefore, tokensAfter, savings };
|
|
287
|
+
return { compaction: compacted.value };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
hookDecision = { kind: "failed", error };
|
|
290
|
+
return { cancel: true };
|
|
291
|
+
}
|
|
292
|
+
});
|
|
129
293
|
const result = await harness.compact();
|
|
294
|
+
const measuredTokensBefore = hookDecision?.tokensBefore ?? null;
|
|
130
295
|
const tokensBefore = Number(result?.tokensBefore) || null;
|
|
296
|
+
const measuredTokensAfter = await estimateSessionMessageTokens(session);
|
|
297
|
+
const tokensAfter = measuredTokensAfter ?? hookDecision?.tokensAfter ?? null;
|
|
298
|
+
const reduced = measuredTokensBefore === null || tokensAfter === null
|
|
299
|
+
? null
|
|
300
|
+
: tokensAfter < measuredTokensBefore;
|
|
131
301
|
onEvent?.({
|
|
132
302
|
type: "runtime_warning",
|
|
133
303
|
warning_kind: "context_compaction_applied",
|
|
134
304
|
source: "pi",
|
|
135
305
|
trigger,
|
|
136
306
|
tokens_before: tokensBefore,
|
|
307
|
+
tokens_after: tokensAfter,
|
|
308
|
+
reduced,
|
|
137
309
|
});
|
|
310
|
+
if (reduced === false) {
|
|
311
|
+
runtimeWarnings?.push({
|
|
312
|
+
warning_kind: "context_compaction_not_reducible",
|
|
313
|
+
source: "pi",
|
|
314
|
+
trigger,
|
|
315
|
+
tokens_before: measuredTokensBefore,
|
|
316
|
+
tokens_after: tokensAfter,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
138
319
|
if (typeof onCompactionRecorded === "function") {
|
|
139
320
|
try {
|
|
140
321
|
onCompactionRecorded({
|
|
@@ -149,17 +330,52 @@ export async function tryCompact(harness, { trigger, onEvent, runtimeWarnings, o
|
|
|
149
330
|
created_at: Date.now(),
|
|
150
331
|
});
|
|
151
332
|
} catch (err) {
|
|
152
|
-
runtimeWarnings
|
|
333
|
+
runtimeWarnings?.push({
|
|
153
334
|
warning_kind: "context_compaction_record_failed",
|
|
154
335
|
source: "pi",
|
|
155
336
|
message: err?.message || String(err),
|
|
156
337
|
});
|
|
157
338
|
}
|
|
158
339
|
}
|
|
159
|
-
return { applied: true, tokensBefore, nothingToCompact: false };
|
|
340
|
+
return { applied: true, tokensBefore, tokensAfter, reduced, nothingToCompact: false };
|
|
160
341
|
} catch (err) {
|
|
161
|
-
|
|
162
|
-
|
|
342
|
+
if (hookDecision?.kind === "not_reducible" || hookDecision?.kind === "insufficient_savings") {
|
|
343
|
+
const warningKind = hookDecision.kind === "not_reducible"
|
|
344
|
+
? "context_compaction_not_reducible"
|
|
345
|
+
: "context_compaction_insufficient_savings";
|
|
346
|
+
runtimeWarnings?.push({
|
|
347
|
+
warning_kind: warningKind,
|
|
348
|
+
source: "pi",
|
|
349
|
+
trigger,
|
|
350
|
+
tokens_before: hookDecision.tokensBefore ?? null,
|
|
351
|
+
tokens_after: hookDecision.tokensAfter ?? null,
|
|
352
|
+
savings_tokens: hookDecision.savings ?? null,
|
|
353
|
+
...(hookDecision.kind === "insufficient_savings"
|
|
354
|
+
? { minimum_savings_tokens: effectivePolicy.compactionMinSavingsTokens }
|
|
355
|
+
: {}),
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
applied: false,
|
|
359
|
+
tokensBefore: hookDecision.tokensBefore ?? null,
|
|
360
|
+
tokensAfter: hookDecision.tokensAfter ?? null,
|
|
361
|
+
reduced: false,
|
|
362
|
+
nothingToCompact: false,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (hookDecision?.kind === "nothing_to_compact") {
|
|
366
|
+
runtimeWarnings?.push({
|
|
367
|
+
warning_kind: "context_compaction_nothing_to_compact",
|
|
368
|
+
source: "pi",
|
|
369
|
+
trigger,
|
|
370
|
+
message: "Nothing to compact",
|
|
371
|
+
});
|
|
372
|
+
return { applied: false, tokensBefore: null, tokensAfter: null, reduced: null, nothingToCompact: true };
|
|
373
|
+
}
|
|
374
|
+
const effectiveError = hookDecision?.kind === "failed" && hookDecision.error
|
|
375
|
+
? hookDecision.error
|
|
376
|
+
: err;
|
|
377
|
+
const message = effectiveError?.message || String(effectiveError);
|
|
378
|
+
const code = effectiveError?.code;
|
|
163
379
|
const nothingToCompact = code === "compaction" && /nothing to compact/i.test(message);
|
|
164
380
|
const warningKind = nothingToCompact
|
|
165
381
|
? "context_compaction_nothing_to_compact"
|
|
@@ -168,8 +384,10 @@ export async function tryCompact(harness, { trigger, onEvent, runtimeWarnings, o
|
|
|
168
384
|
: code === "busy"
|
|
169
385
|
? "context_compaction_busy"
|
|
170
386
|
: "context_compaction_failed";
|
|
171
|
-
runtimeWarnings
|
|
172
|
-
return { applied: false, tokensBefore: null, nothingToCompact };
|
|
387
|
+
runtimeWarnings?.push({ warning_kind: warningKind, source: "pi", trigger, message });
|
|
388
|
+
return { applied: false, tokensBefore: null, tokensAfter: null, reduced: null, nothingToCompact };
|
|
389
|
+
} finally {
|
|
390
|
+
removeHook?.();
|
|
173
391
|
}
|
|
174
392
|
}
|
|
175
393
|
|
|
@@ -211,17 +429,14 @@ export function piCompactionSettings(policy) {
|
|
|
211
429
|
* Resolve the compaction policy against the LIVE model's context window
|
|
212
430
|
* (auto-recognized from the model actually serving the request, lowered by any
|
|
213
431
|
* ceiling learned from a prior overflow). A positive `contextWindowOverride`
|
|
214
|
-
* (from the typed `compaction` policy object) replaces
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* reactive recovery.
|
|
432
|
+
* (from the typed `compaction` policy object) replaces provider metadata, but
|
|
433
|
+
* process-local overflow evidence can still lower it. It is not a legacy
|
|
434
|
+
* `settings` key, so it is applied here directly rather than through the
|
|
435
|
+
* settings shim. Drives the proactive trigger + reactive recovery.
|
|
218
436
|
* @param {{harness: any, runtime: any, resolved: any, settings: any, contextWindowOverride?: number}} params
|
|
219
437
|
*/
|
|
220
438
|
export function resolveLiveCompactionPolicy({ harness, runtime, resolved, settings, contextWindowOverride }) {
|
|
221
|
-
const
|
|
222
|
-
const contextWindow = Number.isFinite(overrideWindow) && overrideWindow > 0
|
|
223
|
-
? overrideWindow
|
|
224
|
-
: effectiveContextWindow(harness, runtime, resolved);
|
|
439
|
+
const contextWindow = effectiveContextWindow(harness, runtime, resolved, contextWindowOverride);
|
|
225
440
|
return resolveAgentCompactionPolicy(settings || {}, { contextWindow });
|
|
226
441
|
}
|
|
227
442
|
|
|
@@ -252,9 +467,8 @@ export async function runProactiveCompaction(runState, {
|
|
|
252
467
|
// sends to the provider, then folded into the raw estimate so the trigger
|
|
253
468
|
// reflects the real request size. ON by default (this corrects a real
|
|
254
469
|
// undercount that lets seeded sessions overflow); set
|
|
255
|
-
// compaction.fixedOverheadEnabled:false
|
|
256
|
-
//
|
|
257
|
-
// transcript-only trigger (overhead = 0). The flag is already resolved onto
|
|
470
|
+
// compaction.fixedOverheadEnabled:false to restore the prior transcript-only
|
|
471
|
+
// trigger (overhead = 0). The flag is already resolved onto
|
|
258
472
|
// policy.fixedOverheadEnabled, so read it there rather than re-sniffing the
|
|
259
473
|
// raw settings bag. See estimateFixedOverheadTokens.
|
|
260
474
|
//
|
|
@@ -276,7 +490,26 @@ export async function runProactiveCompaction(runState, {
|
|
|
276
490
|
messages: [{ role: "user", content: perTurnContent }],
|
|
277
491
|
})
|
|
278
492
|
: { systemPromptTokens: 0, toolSchemaTokens: 0, userMessageTokens: 0, fixedOverheadTokens: 0 };
|
|
279
|
-
const est = await estimateCurrentContextTokens(
|
|
493
|
+
const est = await estimateCurrentContextTokens(
|
|
494
|
+
runState.session,
|
|
495
|
+
fixedOverhead.fixedOverheadTokens,
|
|
496
|
+
fixedOverhead.userMessageTokens,
|
|
497
|
+
);
|
|
498
|
+
// Record the complete request estimate on every proactive check, including
|
|
499
|
+
// below-threshold runs. This is also the failed-request baseline used when a
|
|
500
|
+
// provider reports a generic overflow without a numeric ceiling.
|
|
501
|
+
Object.assign(runState.compaction.diagnostics, {
|
|
502
|
+
context_compaction_estimate_source: est.source,
|
|
503
|
+
context_window: policy.contextWindow,
|
|
504
|
+
context_fixed_overhead_tokens: fixedOverhead.fixedOverheadTokens,
|
|
505
|
+
context_system_prompt_tokens: fixedOverhead.systemPromptTokens,
|
|
506
|
+
context_tool_schema_tokens: fixedOverhead.toolSchemaTokens,
|
|
507
|
+
context_user_message_tokens: fixedOverhead.userMessageTokens,
|
|
508
|
+
context_compaction_trigger_tokens: policy.triggerTokens,
|
|
509
|
+
context_request_estimate_tokens: est.tokens,
|
|
510
|
+
// Retained for compatibility with the diagnostics introduced in PR #489.
|
|
511
|
+
context_transcript_estimate: est.tokens,
|
|
512
|
+
});
|
|
280
513
|
// DELEGATED trigger decision: pi's shouldCompact() with the policy mapped to
|
|
281
514
|
// pi CompactionSettings (see piCompactionSettings — exact `>=`-preserving
|
|
282
515
|
// mapping). Equivalent to the prior `est.tokens >= policy.triggerTokens`.
|
|
@@ -290,24 +523,19 @@ export async function runProactiveCompaction(runState, {
|
|
|
290
523
|
onCompactionRecorded: options.onCompactionRecorded,
|
|
291
524
|
runId: options.runId,
|
|
292
525
|
model: reference,
|
|
526
|
+
session: runState.session,
|
|
527
|
+
policy,
|
|
528
|
+
});
|
|
529
|
+
Object.assign(runState.compaction.diagnostics, {
|
|
530
|
+
context_compaction_tokens_before: res.tokensBefore,
|
|
531
|
+
context_compaction_tokens_after: res.tokensAfter,
|
|
532
|
+
context_compaction_reduced: res.reduced,
|
|
293
533
|
});
|
|
294
534
|
if (res.applied) {
|
|
295
535
|
runState.compaction.applied = true;
|
|
296
536
|
runState.compaction.compactedThisRun = true;
|
|
297
537
|
Object.assign(runState.compaction.diagnostics, {
|
|
298
538
|
context_compaction_proactive: true,
|
|
299
|
-
context_compaction_tokens_before: res.tokensBefore,
|
|
300
|
-
context_compaction_estimate_source: est.source,
|
|
301
|
-
context_window: policy.contextWindow,
|
|
302
|
-
// Additive observability (A4): the overhead components folded into
|
|
303
|
-
// the trigger comparison, the trigger itself (read back by
|
|
304
|
-
// isLikelyContextTermination but otherwise never set), and the
|
|
305
|
-
// transcript-plus-overhead estimate that fired this compaction.
|
|
306
|
-
context_fixed_overhead_tokens: fixedOverhead.fixedOverheadTokens,
|
|
307
|
-
context_system_prompt_tokens: fixedOverhead.systemPromptTokens,
|
|
308
|
-
context_tool_schema_tokens: fixedOverhead.toolSchemaTokens,
|
|
309
|
-
context_compaction_trigger_tokens: policy.triggerTokens,
|
|
310
|
-
context_transcript_estimate: est.tokens,
|
|
311
539
|
});
|
|
312
540
|
// Compaction collapses the transcript prefix, so the pre-run baseline
|
|
313
541
|
// no longer aligns. Re-anchor it to the compacted length so the run's
|
|
@@ -357,9 +585,24 @@ export async function runReactiveCompaction(runState, {
|
|
|
357
585
|
const provisionalError = normalizePiErrorMessage(provisionalRaw);
|
|
358
586
|
if (provisionalError && isReactiveCompactionCandidate(provisionalError, c.diagnostics)) {
|
|
359
587
|
c.reactiveAttempted = true;
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
588
|
+
c.diagnostics.context_compaction_reactive_attempted = true;
|
|
589
|
+
// Learn the real ceiling from the error so future runs trigger proactively
|
|
590
|
+
// even when provider metadata was wrong. Numeric limits are authoritative;
|
|
591
|
+
// a generic overflow lowers the process-local ceiling to 90% of the failed
|
|
592
|
+
// request estimate so the next run creates meaningful headroom.
|
|
593
|
+
const statedLimit = parseContextLimitFromError(provisionalError);
|
|
594
|
+
const failedEstimate = await estimateCurrentContextTokens(
|
|
595
|
+
runState.session,
|
|
596
|
+
Number(c.diagnostics.context_fixed_overhead_tokens) || 0,
|
|
597
|
+
);
|
|
598
|
+
const learnedLimit = statedLimit
|
|
599
|
+
|| (failedEstimate.tokens > 0 ? Math.floor(failedEstimate.tokens * 0.90) : null);
|
|
600
|
+
recordDiscoveredContextWindow(harness, runtime, resolved, learnedLimit);
|
|
601
|
+
Object.assign(c.diagnostics, {
|
|
602
|
+
context_failed_request_estimate_tokens: failedEstimate.tokens,
|
|
603
|
+
context_learned_window: learnedLimit,
|
|
604
|
+
context_learned_window_source: statedLimit ? "provider" : (learnedLimit ? "generic_overflow" : "unavailable"),
|
|
605
|
+
});
|
|
363
606
|
// A second compaction immediately after a fresh proactive one is almost
|
|
364
607
|
// always "nothing to compact"; skip it and surface the original error.
|
|
365
608
|
if (!c.compactedThisRun) {
|
|
@@ -371,22 +614,31 @@ export async function runReactiveCompaction(runState, {
|
|
|
371
614
|
onCompactionRecorded: options.onCompactionRecorded,
|
|
372
615
|
runId: options.runId,
|
|
373
616
|
model: reference,
|
|
617
|
+
session: runState.session,
|
|
618
|
+
policy: c.policy,
|
|
619
|
+
});
|
|
620
|
+
Object.assign(c.diagnostics, {
|
|
621
|
+
context_compaction_tokens_before: res.tokensBefore,
|
|
622
|
+
context_compaction_tokens_after: res.tokensAfter,
|
|
623
|
+
context_compaction_reduced: res.reduced,
|
|
374
624
|
});
|
|
375
625
|
if (res.applied) {
|
|
376
626
|
c.applied = true;
|
|
377
627
|
c.compactedThisRun = true;
|
|
378
628
|
Object.assign(c.diagnostics, {
|
|
379
629
|
context_compaction_reactive: true,
|
|
380
|
-
context_compaction_tokens_before: res.tokensBefore,
|
|
381
630
|
});
|
|
382
631
|
// Re-anchor the transcript baseline to the compacted length so the
|
|
383
632
|
// re-prompt's turn (and its stopReason/usage) slices out correctly.
|
|
384
633
|
runState.sessionBaselineCount = (await runState.session.buildContext()).messages.length;
|
|
385
|
-
// Re-prompt ONCE
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
634
|
+
// Re-prompt ONCE only after a verified positive reduction. Re-sending
|
|
635
|
+
// an unchanged or unmeasurable oversized request only repeats the same
|
|
636
|
+
// provider error.
|
|
637
|
+
if (res.reduced === true) {
|
|
638
|
+
const rerun = await runHarnessPrompt(harness, promptText, promptImages);
|
|
639
|
+
runError = rerun.runError;
|
|
640
|
+
state = await captureState();
|
|
641
|
+
}
|
|
390
642
|
}
|
|
391
643
|
}
|
|
392
644
|
}
|