@mono-agent/agent-runtime 0.11.5 → 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.
@@ -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 USAGE_LIMIT_RE = /(rate limit|usage limit|max tokens|max turns|context length|too many tokens)/i;
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 === "MessageOutputLengthError" || name === "ContextOverflowError") return "usage_limit";
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. Both the message-normalizer (unwrap nested provider error
5
- // envelopes) and the context-limit classifier are pure string helpers with no
6
- // runtime dependencies.
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
- const text = String(message || "");
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 self-corrects from any real ceiling stated in an overflow error.
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 declared = liveModelContextWindow(harness, runtime);
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 ONLY: the usage-based count already includes that overhead (it is what
101
- // the provider actually counted), so adding it there would double-count. With a
102
- // stale/0 usage and a seeded session the estimate branch wins, and without this
103
- // the trigger under-counts and the real request overflows.
104
- export async function estimateCurrentContextTokens(session, fixedOverheadTokens = 0) {
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
- rawTokens = Number(estimateContextTokens(context?.messages || []).tokens) || 0;
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
- if (usageTokens === 0 && rawTokens === 0) return { tokens: 0, source: "unavailable" };
118
- return usageTokens >= rawTokens
119
- ? { tokens: usageTokens, source: "usage" }
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, { trigger, onEvent, runtimeWarnings, onCompactionRecorded, runId, model }) {
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.push({
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
- const message = err?.message || String(err);
162
- const code = err?.code;
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.push({ warning_kind: warningKind, source: "pi", trigger, message });
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 the auto-recognized
215
- * window it is not a legacy `settings` key, so it is applied here directly
216
- * rather than through the settings shim. Drives the proactive trigger +
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 overrideWindow = Number(contextWindowOverride);
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 (or the deprecated
256
- // agent_compaction_fixed_overhead_enabled:false) restores the prior
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(runState.session, fixedOverhead.fixedOverheadTokens);
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
- // Learn the real ceiling from the error so future runs trigger
361
- // proactively at it even when the configured contextWindow was wrong.
362
- recordDiscoveredContextWindow(harness, runtime, resolved, parseContextLimitFromError(provisionalError));
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 in the now-compacted session. The trailing user turn
386
- // is already persisted, so a fresh prompt continues against it.
387
- const rerun = await runHarnessPrompt(harness, promptText, promptImages);
388
- runError = rerun.runError;
389
- state = await captureState();
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
  }