@mono-agent/agent-runtime 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/ARCHITECTURE.md +37 -16
  2. package/MIGRATION.md +185 -7
  3. package/README.md +24 -14
  4. package/package.json +103 -10
  5. package/src/agent/allowlists.js +3 -0
  6. package/src/agent/approval.js +3 -0
  7. package/src/agent/compaction.js +244 -1
  8. package/src/agent/prompt/skill-index.js +6 -0
  9. package/src/agent/sandbox-seam.js +227 -0
  10. package/src/agent/tool-bloat.js +8 -2
  11. package/src/agent/tools/bash.js +16 -8
  12. package/src/agent/tools/edit.js +7 -3
  13. package/src/agent/tools/glob.js +11 -5
  14. package/src/agent/tools/grep.js +11 -5
  15. package/src/agent/tools/pi-bridge.js +227 -45
  16. package/src/agent/tools/read.js +28 -3
  17. package/src/agent/tools/shared/output-truncation.js +8 -3
  18. package/src/agent/tools/shared/path-resolver.js +24 -18
  19. package/src/agent/tools/shared/ripgrep.js +33 -6
  20. package/src/agent/tools/shared/runtime-context.js +60 -50
  21. package/src/agent/tools/shared/tool-context.js +157 -0
  22. package/src/agent/tools/web-fetch.js +21 -10
  23. package/src/agent/tools/web-search.js +11 -4
  24. package/src/agent/tools/write.js +7 -3
  25. package/src/agent/transcript.js +16 -1
  26. package/src/ai/cost.js +128 -3
  27. package/src/ai/failure.js +96 -4
  28. package/src/ai/live-input-prompt.js +11 -1
  29. package/src/ai/providers/claude-cli.js +2 -2
  30. package/src/ai/providers/claude-sdk.js +55 -12
  31. package/src/ai/providers/codex-app.js +36 -23
  32. package/src/ai/providers/opencode-app.js +2 -2
  33. package/src/ai/providers/opencode-discovery.js +2 -2
  34. package/src/ai/providers/pi-events.js +5 -0
  35. package/src/ai/providers/pi-models.js +21 -4
  36. package/src/ai/providers/pi-native/compaction-driver.js +394 -0
  37. package/src/ai/providers/pi-native/result-builder.js +312 -0
  38. package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
  39. package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
  40. package/src/ai/providers/pi-native/structured-output.js +130 -0
  41. package/src/ai/providers/pi-native/turn-runner.js +278 -0
  42. package/src/ai/providers/pi-native.js +415 -1106
  43. package/src/ai/runtime/model-refs.js +30 -0
  44. package/src/ai/runtime/registry.js +29 -0
  45. package/src/ai/runtime/router.js +96 -12
  46. package/src/ai/runtime/session-liveness.js +110 -0
  47. package/src/ai/runtime/sessions.js +16 -0
  48. package/src/ai/types.js +252 -19
  49. package/src/index.js +1 -1
  50. package/src/pi-auth.js +147 -16
  51. package/src/runtime-brand.js +21 -1
  52. package/src/runtime.js +72 -8
  53. package/types/agent/allowlists.d.ts +25 -0
  54. package/types/agent/approval.d.ts +30 -0
  55. package/types/agent/compaction.d.ts +97 -0
  56. package/types/agent/index.d.ts +5 -0
  57. package/types/agent/prompt/skill-index.d.ts +19 -0
  58. package/types/agent/sandbox-seam.d.ts +148 -0
  59. package/types/agent/tool-bloat.d.ts +22 -0
  60. package/types/agent/tools/bash.d.ts +16 -0
  61. package/types/agent/tools/edit.d.ts +14 -0
  62. package/types/agent/tools/glob.d.ts +16 -0
  63. package/types/agent/tools/grep.d.ts +22 -0
  64. package/types/agent/tools/index.d.ts +10 -0
  65. package/types/agent/tools/pi-bridge.d.ts +144 -0
  66. package/types/agent/tools/read.d.ts +19 -0
  67. package/types/agent/tools/shared/constants.d.ts +13 -0
  68. package/types/agent/tools/shared/dedup.d.ts +8 -0
  69. package/types/agent/tools/shared/output-truncation.d.ts +22 -0
  70. package/types/agent/tools/shared/path-resolver.d.ts +6 -0
  71. package/types/agent/tools/shared/ripgrep.d.ts +38 -0
  72. package/types/agent/tools/shared/runtime-context.d.ts +27 -0
  73. package/types/agent/tools/shared/tool-context.d.ts +48 -0
  74. package/types/agent/tools/web-fetch.d.ts +13 -0
  75. package/types/agent/tools/web-search.d.ts +11 -0
  76. package/types/agent/tools/write.d.ts +12 -0
  77. package/types/agent/transcript.d.ts +45 -0
  78. package/types/ai/backend.d.ts +41 -0
  79. package/types/ai/cost.d.ts +96 -0
  80. package/types/ai/failure.d.ts +117 -0
  81. package/types/ai/file-change-stats.d.ts +75 -0
  82. package/types/ai/index.d.ts +7 -0
  83. package/types/ai/live-input-prompt.d.ts +6 -0
  84. package/types/ai/observer.d.ts +68 -0
  85. package/types/ai/providers/claude-cli.d.ts +211 -0
  86. package/types/ai/providers/claude-sdk.d.ts +66 -0
  87. package/types/ai/providers/claude-subagents.d.ts +2 -0
  88. package/types/ai/providers/codex-app.d.ts +151 -0
  89. package/types/ai/providers/opencode-app.d.ts +95 -0
  90. package/types/ai/providers/opencode-discovery.d.ts +4 -0
  91. package/types/ai/providers/pi-errors.d.ts +3 -0
  92. package/types/ai/providers/pi-events.d.ts +24 -0
  93. package/types/ai/providers/pi-messages.d.ts +5 -0
  94. package/types/ai/providers/pi-models.d.ts +57 -0
  95. package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
  96. package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
  97. package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
  98. package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
  99. package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
  100. package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
  101. package/types/ai/providers/pi-native.d.ts +18 -0
  102. package/types/ai/registry.d.ts +1 -0
  103. package/types/ai/runtime/capabilities-used.d.ts +21 -0
  104. package/types/ai/runtime/capabilities.d.ts +33 -0
  105. package/types/ai/runtime/context-windows.d.ts +8 -0
  106. package/types/ai/runtime/fast-mode.d.ts +2 -0
  107. package/types/ai/runtime/model-refs.d.ts +35 -0
  108. package/types/ai/runtime/registry.d.ts +38 -0
  109. package/types/ai/runtime/router.d.ts +56 -0
  110. package/types/ai/runtime/session-liveness.d.ts +55 -0
  111. package/types/ai/runtime/sessions.d.ts +38 -0
  112. package/types/ai/streaming/codex-events.d.ts +30 -0
  113. package/types/ai/streaming/opencode-events.d.ts +41 -0
  114. package/types/ai/types.d.ts +702 -0
  115. package/types/index.d.ts +7 -0
  116. package/types/pi-auth.d.ts +28 -0
  117. package/types/runtime-brand.d.ts +30 -0
  118. package/types/runtime.d.ts +10 -0
  119. package/src/ai/providers/pi-sdk.js +0 -35
@@ -8,6 +8,7 @@ import { codexModelSupportsFastMode, normalizeFastMode } from "../runtime/fast-m
8
8
  import { readRuntimeBrand } from "../../agent/tools/shared/runtime-context.js";
9
9
  import { buildCapabilitiesUsed } from "../runtime/capabilities-used.js";
10
10
  import { createSessionRegistry } from "../runtime/sessions.js";
11
+ import { createSessionLiveness } from "../runtime/session-liveness.js";
11
12
 
12
13
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
13
14
  const DEFAULT_THREAD_START_ATTEMPTS = 2;
@@ -223,6 +224,9 @@ function codexCollaborationModePayload(nativeSubagents, { model, effort, systemP
223
224
  };
224
225
  }
225
226
 
227
+ /**
228
+ * @param {{command?: string, args?: string[], cwd?: any, env?: any, onNotification?: (msg: any) => void}} [options]
229
+ */
226
230
  export function createCodexAppServerClient({
227
231
  command = "codex",
228
232
  // project_doc_max_bytes=0 keeps codex from injecting its own project docs;
@@ -440,6 +444,11 @@ const codexSessions = createSessionRegistry({
440
444
  try { entry.client.close(); } catch {}
441
445
  },
442
446
  });
447
+ // Synchronous liveness primitives over the registry. Codex only needs the
448
+ // await-free busy claim (its resume handling is simpler than pi's — no durable
449
+ // reopen / create-on-miss reservation), so it consumes claim(); its keep-alive
450
+ // register + deletes stay direct registry ops.
451
+ const codexLiveness = createSessionLiveness(codexSessions);
443
452
 
444
453
  export async function generateCodexAppResponse(systemPrompt, options = {}) {
445
454
  const start = Date.now();
@@ -579,7 +588,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
579
588
  }
580
589
 
581
590
  async function initializeClient(nextClient) {
582
- const brand = readRuntimeBrand();
591
+ const brand = options.toolContext?.runtimeBrand ?? readRuntimeBrand();
583
592
  await nextClient.request("initialize", {
584
593
  clientInfo: { name: brand.clientInfoName, title: brand.clientInfoTitle, version: "0" },
585
594
  capabilities: { experimentalApi: true },
@@ -654,7 +663,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
654
663
  ]);
655
664
  if (turnCompleted || !turnReadyResolved) break;
656
665
  }
657
- const input = userTextInput(formatLiveInputGuidance(message.body));
666
+ const input = userTextInput(formatLiveInputGuidance(message.body, options.prompts));
658
667
  try {
659
668
  const response = await client.request("turn/steer", {
660
669
  threadId,
@@ -730,25 +739,27 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
730
739
  }
731
740
 
732
741
  if (resumeSessionId) {
733
- const entry = codexSessions.get(resumeSessionId);
734
- if (!entry) {
735
- // The host sent no conversation history for a resume, so silently
736
- // starting a fresh thread would lose context. Fail fast instead.
737
- return sessionUnavailableResult(
738
- "session_not_found",
739
- `Codex session ${resumeSessionId} is not live; cannot resume`,
740
- "codex_session_not_found",
741
- );
742
+ // Await-free busy claim (get -> busy check -> set-busy in one span). A miss
743
+ // fails fast: the host sent no conversation history for a resume, so silently
744
+ // starting a fresh thread would lose context. A busy entry is executing a
745
+ // turn already.
746
+ const claimed = codexLiveness.claim(resumeSessionId);
747
+ if (!claimed.ok) {
748
+ // @ts-check does not narrow the ClaimResult union on `!claimed.ok`,
749
+ // though the loser branch always carries `reason`.
750
+ return /** @type {{reason: string}} */ (claimed).reason === "missing"
751
+ ? sessionUnavailableResult(
752
+ "session_not_found",
753
+ `Codex session ${resumeSessionId} is not live; cannot resume`,
754
+ "codex_session_not_found",
755
+ )
756
+ : sessionUnavailableResult(
757
+ "session_busy",
758
+ `Codex session ${resumeSessionId} is already executing a turn`,
759
+ "codex_session_busy",
760
+ );
742
761
  }
743
- if (entry.busy) {
744
- return sessionUnavailableResult(
745
- "session_busy",
746
- `Codex session ${resumeSessionId} is already executing a turn`,
747
- "codex_session_busy",
748
- );
749
- }
750
- entry.busy = true;
751
- resumeEntry = entry;
762
+ resumeEntry = claimed.entry;
752
763
  }
753
764
 
754
765
  try {
@@ -786,11 +797,13 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
786
797
  const fastMode = codexModelSupportsFastMode(resolved.model) && normalizeFastMode(options.fastMode, true);
787
798
  if (!resumeEntry) {
788
799
  const mcpServers = codexMcpConfig(options.mcpServers);
789
- const config = {
800
+ // Incrementally assembled config handed across the codex app-server
801
+ // boundary; the reasoning fields below are attached conditionally.
802
+ const config = /** @type {any} */ ({
790
803
  ...(fastMode ? { service_tier: "fast" } : {}),
791
804
  features: { fast_mode: fastMode },
792
805
  ...(Object.keys(mcpServers).length ? { mcp_servers: mcpServers } : {}),
793
- };
806
+ });
794
807
  if (normalizedEffort) {
795
808
  config.model_reasoning_effort = normalizedEffort;
796
809
  if (normalizedEffort !== "none") config.model_reasoning_summary = "auto";
@@ -807,7 +820,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
807
820
  approvalPolicy: approvalPolicyForPermissionMode(options.permissionMode),
808
821
  sandbox: sandboxForPermissionMode(options.permissionMode),
809
822
  config,
810
- serviceName: readRuntimeBrand().serviceName,
823
+ serviceName: (options.toolContext?.runtimeBrand ?? readRuntimeBrand()).serviceName,
811
824
  developerInstructions: systemPrompt,
812
825
  ephemeral: true,
813
826
  sessionStartSource: "startup",
@@ -164,10 +164,10 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
164
164
  };
165
165
 
166
166
  try {
167
- const opencode = await createOpencode({
167
+ const opencode = await createOpencode(/** @type {any} */ ({
168
168
  ...(Object.keys(mcp).length ? { config: { mcp } } : { config: {} }),
169
169
  ...(options.abortSignal ? { signal: options.abortSignal } : {}),
170
- });
170
+ }));
171
171
  client = opencode.client;
172
172
  server = opencode.server;
173
173
  options.abortSignal?.addEventListener?.("abort", abortHandler, { once: true });
@@ -15,10 +15,10 @@ export async function discoverOpencodeProviders({ createServer = createOpencode
15
15
  if (result?.error) {
16
16
  const message = typeof result.error === "string"
17
17
  ? result.error
18
- : (result.error?.message || JSON.stringify(result.error));
18
+ : (/** @type {any} */ (result.error)?.message || JSON.stringify(result.error));
19
19
  throw new Error(message);
20
20
  }
21
- const providers = result?.data?.providers || result?.providers || [];
21
+ const providers = result?.data?.providers || /** @type {any} */ (result)?.providers || [];
22
22
  return providers.map((provider) => ({
23
23
  providerID: provider.id,
24
24
  name: provider.name || provider.id,
@@ -51,6 +51,11 @@ export function compactToolRawResult(result, resultContent) {
51
51
  };
52
52
  }
53
53
 
54
+ /**
55
+ * @param {any} toolName
56
+ * @param {any} args
57
+ * @param {{cwd?: any, toolLimits?: any}} [options]
58
+ */
54
59
  export function eventToolArgs(toolName, args, { cwd, toolLimits } = {}) {
55
60
  return normalizePiBuiltinToolParams(toolName, args || {}, { cwd, toolLimits });
56
61
  }
@@ -1,4 +1,8 @@
1
- import { getModel as getPiModel } from "@earendil-works/pi-ai";
1
+ // pi 0.80 moved the static catalog reads off the pi-ai root: `getModel` is now
2
+ // deprecated/compat-only. `getBuiltinModel(provider, id)` from `providers/all`
3
+ // is the non-deprecated replacement — same 2-arg signature, and it returns
4
+ // `undefined` on an unknown provider/model exactly like the old `getModel`.
5
+ import { getBuiltinModel as getPiModel } from "@earendil-works/pi-ai/providers/all";
2
6
  import { readRuntimeBrand } from "../../agent/tools/shared/runtime-context.js";
3
7
 
4
8
  export const EMPTY_USAGE = {
@@ -20,8 +24,8 @@ function openAiCompatBaseUrl(provider) {
20
24
  return /\/v\d+$/.test(baseUrl) ? baseUrl : `${baseUrl}/v1`;
21
25
  }
22
26
 
23
- function customProviderName(provider) {
24
- return `${readRuntimeBrand().providerModelPrefix}-${provider.id}`;
27
+ function customProviderName(provider, brand) {
28
+ return `${(brand ?? readRuntimeBrand()).providerModelPrefix}-${provider.id}`;
25
29
  }
26
30
 
27
31
  function customProviderKey(provider, isPrivate) {
@@ -64,7 +68,7 @@ function resolveCustomPiModel(resolved, options) {
64
68
  const isPrivate = typeof options.isPrivateProvider === "boolean"
65
69
  ? options.isPrivateProvider
66
70
  : false;
67
- const providerName = customProviderName(provider);
71
+ const providerName = customProviderName(provider, options.toolContext?.runtimeBrand);
68
72
  const pricing = modelRow?.pricing || {};
69
73
  return {
70
74
  model: {
@@ -90,11 +94,24 @@ function resolveCustomPiModel(resolved, options) {
90
94
  };
91
95
  }
92
96
 
97
+ // `options.customProvider`, when present, takes UNCONDITIONAL precedence for
98
+ // ANY pi model ref resolved in this run — it is not scoped to the specific
99
+ // model reference passed in. A fallback-router chain that mixes a custom-pi
100
+ // entry with a builtin-pi entry therefore routes ALL pi entries in that chain
101
+ // through the same custom provider once options.customProvider is set for the
102
+ // run (the router does not re-derive customProvider per chain entry).
93
103
  export function resolvePiRuntimeModel(resolved, options) {
94
104
  if (options.customProvider) return resolveCustomPiModel(resolved, options);
95
105
  if (resolved.sdk !== "pi") throw new Error(`unsupported pi sdk: ${resolved.sdk}`);
96
106
  const provider = resolved.provider;
97
107
  const model = getPiModel(provider, resolved.model);
108
+ if (!model) {
109
+ // Phrasing matters: this must match ai/failure.js's NON_RETRYABLE_PROVIDER_RE
110
+ // `model[_ ]not[_ ]found` alternation so the router classifies a catalog miss
111
+ // as non-retryable and bails cleanly instead of retrying/misclassifying it as
112
+ // a transient provider_unavailable failure.
113
+ throw new Error(`pi model not found: ${provider}:${resolved.model}`);
114
+ }
98
115
  return {
99
116
  model,
100
117
  capabilities: {
@@ -0,0 +1,394 @@
1
+ // @ts-check
2
+ // Context auto-compaction for the pi-native bridge.
3
+ //
4
+ // AUTO-COMPACTION. pi-agent-core performs NO automatic in-loop compaction
5
+ // (shouldCompact/compact are exported helpers its loop never calls), so this
6
+ // bridge DRIVES it: proactively before a turn when the running model's context
7
+ // is near the window, and reactively (compact + single re-prompt) if a turn
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.
10
+ //
11
+ // DELEGATED to pi where pi provides the primitive: the proactive trigger
12
+ // DECISION runs through pi's shouldCompact() (via piCompactionSettings) and the
13
+ // context-size ESTIMATE runs through pi's estimateContextTokens(). Only the
14
+ // pieces pi does not model stay hand-rolled here: the DRIVING (pi never invokes
15
+ // compaction itself), the discovered-window ceiling learning, and the fixed
16
+ // per-request overhead (system prompt + tool schemas) that estimateContextTokens
17
+ // omits.
18
+ //
19
+ // Pure moves out of pi-native.js: the discovered-window cache (kept at MODULE
20
+ // scope, matching its bridge-level scope before the split), context estimation,
21
+ // the guarded tryCompact, the reactive candidate test, the live compaction
22
+ // policy resolution, and the proactive+reactive hooks. Per-run compaction state
23
+ // (applied / reactiveAttempted / compactedThisRun / policy / diagnostics) lives
24
+ // on the caller-owned runState.compaction.
25
+
26
+ import {
27
+ calculateContextTokens,
28
+ estimateContextTokens,
29
+ getLastAssistantUsage,
30
+ shouldCompact,
31
+ } from "@earendil-works/pi-agent-core";
32
+ import {
33
+ estimateFixedOverheadTokens,
34
+ isLikelyContextTermination,
35
+ resolveAgentCompactionPolicy,
36
+ } from "../../../agent/compaction.js";
37
+ import {
38
+ isContextLimitError,
39
+ normalizePiErrorMessage,
40
+ parseContextLimitFromError,
41
+ } from "../pi-errors.js";
42
+ import { appendStructuredOutputInstruction } from "./structured-output.js";
43
+ import { runHarnessPrompt } from "./turn-runner.js";
44
+
45
+ // Per-process cache of real context-window ceilings discovered from overflow
46
+ // errors, keyed by model reference/id. The long-running host re-learns quickly
47
+ // after a restart; this just spares repeated first-overflow round-trips.
48
+ const discoveredContextWindows = new Map();
49
+
50
+ function modelWindowKey(harness, runtime, resolved) {
51
+ const live = typeof harness?.getModel === "function" ? harness.getModel() : null;
52
+ return resolved?.reference || runtime?.model?.id || live?.id || "unknown";
53
+ }
54
+
55
+ // The window of the model that ACTUALLY serves this request: prefer the harness's
56
+ // live model (authoritative for native pi models), fall back to the resolved
57
+ // runtime model. Returns 0 when unknown so callers can skip the proactive trigger.
58
+ function liveModelContextWindow(harness, runtime) {
59
+ const live = typeof harness?.getModel === "function" ? harness.getModel() : null;
60
+ const win = Number(live?.contextWindow) || Number(runtime?.model?.contextWindow) || 0;
61
+ return win > 0 ? win : 0;
62
+ }
63
+
64
+ function effectiveContextWindow(harness, runtime, resolved) {
65
+ const declared = liveModelContextWindow(harness, runtime);
66
+ const discovered = discoveredContextWindows.get(modelWindowKey(harness, runtime, resolved));
67
+ if (Number.isFinite(discovered) && discovered > 0) {
68
+ return declared > 0 ? Math.min(declared, discovered) : discovered;
69
+ }
70
+ return declared;
71
+ }
72
+
73
+ function recordDiscoveredContextWindow(harness, runtime, resolved, limit) {
74
+ const n = Number(limit);
75
+ if (!Number.isFinite(n) || n <= 0) return;
76
+ const key = modelWindowKey(harness, runtime, resolved);
77
+ const existing = discoveredContextWindows.get(key);
78
+ discoveredContextWindows.set(key, Number.isFinite(existing) && existing > 0 ? Math.min(existing, n) : n);
79
+ }
80
+
81
+ // Best-effort estimate of the current session's context size. The last assistant
82
+ // usage is authoritative (it reflects what the provider actually counted,
83
+ // including cache reads), but it can be stale/zero (e.g. seeded history), so we
84
+ // take the MAX of the usage-based count and pi's estimateContextTokens() over the
85
+ // transcript. Either being large is a reason to compact; overcounting only
86
+ // compacts slightly early.
87
+ //
88
+ // The transcript branch is DELEGATED to pi's estimateContextTokens(): it sums
89
+ // pi's own per-message estimateTokens across session.buildContext().messages,
90
+ // and when a message carries a VALID last-assistant usage it uses usage +
91
+ // trailing-message estimate instead of re-summing the whole transcript. Seeded /
92
+ // faux histories carry no valid usage (faux usage totals 0 tokens, which pi
93
+ // rejects), so it reduces to the pure per-message sum the bridge summed by hand
94
+ // before — a behaviour-neutral swap there, and a more provider-accurate estimate
95
+ // once real usage is present.
96
+ //
97
+ // `fixedOverheadTokens` is the system-prompt + tool-schema + per-turn user
98
+ // message overhead the provider meters but the transcript estimate (which covers
99
+ // 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) {
105
+ let usageTokens = 0;
106
+ let rawTokens = 0;
107
+ try {
108
+ const usage = getLastAssistantUsage(await session.getEntries());
109
+ if (usage) usageTokens = Number(calculateContextTokens(usage)) || 0;
110
+ } catch { /* ignore — fall back to the transcript estimate */ }
111
+ try {
112
+ const context = await session.buildContext();
113
+ rawTokens = Number(estimateContextTokens(context?.messages || []).tokens) || 0;
114
+ } catch { /* ignore — usage-based estimate stands */ }
115
+ // Apply the fixed overhead to the transcript estimate only (see note above).
116
+ rawTokens += Number(fixedOverheadTokens) || 0;
117
+ if (usageTokens === 0 && rawTokens === 0) return { tokens: 0, source: "unavailable" };
118
+ return usageTokens >= rawTokens
119
+ ? { tokens: usageTokens, source: "usage" }
120
+ : { tokens: rawTokens, source: "estimate" };
121
+ }
122
+
123
+ // Run a single guarded compaction. Requires the harness idle (callers
124
+ // waitForIdle first). Never throws — classifies AgentHarnessError into a warning
125
+ // and reports back whether anything was compacted. Fires onCompactionRecorded on
126
+ // success so a host can persist the compaction row.
127
+ export async function tryCompact(harness, { trigger, onEvent, runtimeWarnings, onCompactionRecorded, runId, model }) {
128
+ try {
129
+ const result = await harness.compact();
130
+ const tokensBefore = Number(result?.tokensBefore) || null;
131
+ onEvent?.({
132
+ type: "runtime_warning",
133
+ warning_kind: "context_compaction_applied",
134
+ source: "pi",
135
+ trigger,
136
+ tokens_before: tokensBefore,
137
+ });
138
+ if (typeof onCompactionRecorded === "function") {
139
+ try {
140
+ onCompactionRecorded({
141
+ task_run_id: runId || null,
142
+ trigger,
143
+ provider_kind: "pi",
144
+ model: model || null,
145
+ tokens_before: tokensBefore,
146
+ summary: result?.summary || "",
147
+ first_kept_entry_id: result?.firstKeptEntryId || null,
148
+ status: "succeeded",
149
+ created_at: Date.now(),
150
+ });
151
+ } catch (err) {
152
+ runtimeWarnings.push({
153
+ warning_kind: "context_compaction_record_failed",
154
+ source: "pi",
155
+ message: err?.message || String(err),
156
+ });
157
+ }
158
+ }
159
+ return { applied: true, tokensBefore, nothingToCompact: false };
160
+ } catch (err) {
161
+ const message = err?.message || String(err);
162
+ const code = err?.code;
163
+ const nothingToCompact = code === "compaction" && /nothing to compact/i.test(message);
164
+ const warningKind = nothingToCompact
165
+ ? "context_compaction_nothing_to_compact"
166
+ : code === "auth"
167
+ ? "context_compaction_auth_failed"
168
+ : code === "busy"
169
+ ? "context_compaction_busy"
170
+ : "context_compaction_failed";
171
+ runtimeWarnings.push({ warning_kind: warningKind, source: "pi", trigger, message });
172
+ return { applied: false, tokensBefore: null, nothingToCompact };
173
+ }
174
+ }
175
+
176
+ function isReactiveCompactionCandidate(errorMessage, diagnostics) {
177
+ if (!errorMessage) return false;
178
+ return isContextLimitError(errorMessage) || isLikelyContextTermination(errorMessage, diagnostics);
179
+ }
180
+
181
+ /**
182
+ * Express the kernel's compaction policy as a pi `CompactionSettings` so the
183
+ * proactive trigger runs through pi's own `shouldCompact()`.
184
+ *
185
+ * EQUIVALENCE (exact, proven by pi-native-compaction-parity.test.js): pi fires
186
+ * when `contextTokens > contextWindow - reserveTokens` (STRICT `>`), while the
187
+ * kernel policy fires when `estimate >= triggerTokens` (`>=`). Both `estimate`
188
+ * and `triggerTokens` are non-negative INTEGERS — pi's `estimateTokens` /
189
+ * `calculateContextTokens` return `Math.ceil(...)`/summed provider counts, and
190
+ * `resolveAgentCompactionPolicy` builds `triggerTokens` with `Math.floor` — so
191
+ * for integers `x >= t` iff `x > t - 1`. Setting
192
+ * `reserveTokens = contextWindow - triggerTokens + 1`
193
+ * gives `contextWindow - reserveTokens = triggerTokens - 1`, hence
194
+ * `shouldCompact(x, window, s)` == `x > triggerTokens - 1` == `x >= triggerTokens`.
195
+ * `triggerTokens < contextWindow` always (it is `min(floor(window*ratio),
196
+ * window - reserve)` with `ratio <= 0.95`), so `reserveTokens >= 2` — never
197
+ * degenerate. `keepRecentTokens` is carried through for a faithful settings
198
+ * object even though `shouldCompact` ignores it.
199
+ * @param {{enabled: boolean, contextWindow: number, triggerTokens: number, keepRecentTokens: number}} policy
200
+ * @returns {{enabled: boolean, reserveTokens: number, keepRecentTokens: number}}
201
+ */
202
+ export function piCompactionSettings(policy) {
203
+ return {
204
+ enabled: !!policy.enabled,
205
+ reserveTokens: policy.contextWindow - policy.triggerTokens + 1,
206
+ keepRecentTokens: policy.keepRecentTokens,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Resolve the compaction policy against the LIVE model's context window
212
+ * (auto-recognized from the model actually serving the request, lowered by any
213
+ * 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.
218
+ * @param {{harness: any, runtime: any, resolved: any, settings: any, contextWindowOverride?: number}} params
219
+ */
220
+ 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);
225
+ return resolveAgentCompactionPolicy(settings || {}, { contextWindow });
226
+ }
227
+
228
+ /**
229
+ * Proactive compaction: if the session is already near the window, compact
230
+ * BEFORE issuing the request so a long-lived session never overflows. Mutates
231
+ * runState.compaction (applied / compactedThisRun / diagnostics) and re-anchors
232
+ * runState.sessionBaselineCount when it fires.
233
+ * @param {any} runState
234
+ * @param {any} params
235
+ */
236
+ export async function runProactiveCompaction(runState, {
237
+ harness,
238
+ systemPrompt,
239
+ options,
240
+ tools,
241
+ promptText,
242
+ promptImages,
243
+ reference,
244
+ onEvent,
245
+ runtimeWarnings,
246
+ }) {
247
+ const policy = runState.compaction.policy;
248
+ if (!(policy.enabled && policy.contextWindow > 0 && !options.abortSignal?.aborted)) return;
249
+ // Fixed per-request overhead the provider meters but the raw transcript
250
+ // estimate excludes (system prompt + tool/MCP schemas + per-turn user
251
+ // message + memory). Computed ONCE here from the same inputs the harness
252
+ // sends to the provider, then folded into the raw estimate so the trigger
253
+ // reflects the real request size. ON by default (this corrects a real
254
+ // 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
258
+ // policy.fixedOverheadEnabled, so read it there rather than re-sniffing the
259
+ // raw settings bag. See estimateFixedOverheadTokens.
260
+ //
261
+ // Only the TRAILING per-turn user message is passed here, NOT
262
+ // options.messages. The prior transcript is already summed by the raw
263
+ // branch via session.buildContext().messages (priorMessages were seeded
264
+ // into the session above), so passing the whole history would double-count
265
+ // it. promptText/promptImages (from splitPromptMessages at the run head)
266
+ // ARE the per-turn turn, so reconstruct that single message for the
267
+ // estimate — matching estimateFixedOverheadTokens' "per-turn user
268
+ // message(s)" contract.
269
+ const perTurnContent = Array.isArray(promptImages) && promptImages.length > 0
270
+ ? [{ type: "text", text: promptText }, ...promptImages]
271
+ : promptText;
272
+ const fixedOverhead = policy.fixedOverheadEnabled !== false
273
+ ? estimateFixedOverheadTokens({
274
+ systemPrompt: appendStructuredOutputInstruction(systemPrompt, options.outputSchema, options.prompts),
275
+ tools,
276
+ messages: [{ role: "user", content: perTurnContent }],
277
+ })
278
+ : { systemPromptTokens: 0, toolSchemaTokens: 0, userMessageTokens: 0, fixedOverheadTokens: 0 };
279
+ const est = await estimateCurrentContextTokens(runState.session, fixedOverhead.fixedOverheadTokens);
280
+ // DELEGATED trigger decision: pi's shouldCompact() with the policy mapped to
281
+ // pi CompactionSettings (see piCompactionSettings — exact `>=`-preserving
282
+ // mapping). Equivalent to the prior `est.tokens >= policy.triggerTokens`.
283
+ if (shouldCompact(est.tokens, policy.contextWindow, piCompactionSettings(policy))) {
284
+ await harness.waitForIdle();
285
+ if (!options.abortSignal?.aborted) {
286
+ const res = await tryCompact(harness, {
287
+ trigger: "proactive",
288
+ onEvent,
289
+ runtimeWarnings,
290
+ onCompactionRecorded: options.onCompactionRecorded,
291
+ runId: options.runId,
292
+ model: reference,
293
+ });
294
+ if (res.applied) {
295
+ runState.compaction.applied = true;
296
+ runState.compaction.compactedThisRun = true;
297
+ Object.assign(runState.compaction.diagnostics, {
298
+ 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
+ });
312
+ // Compaction collapses the transcript prefix, so the pre-run baseline
313
+ // no longer aligns. Re-anchor it to the compacted length so the run's
314
+ // own turns (issued next) slice out correctly in captureState.
315
+ runState.sessionBaselineCount = (await runState.session.buildContext()).messages.length;
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Reactive recovery: if the turn ended in a context overflow and we have not
323
+ * already compacted-and-retried this run, compact once and re-prompt once.
324
+ * Learns the real ceiling from the overflow error. Returns the (possibly
325
+ * re-captured) state + runError.
326
+ * @param {any} runState
327
+ * @param {any} params
328
+ * @returns {Promise<{state: any, runError: any}>}
329
+ */
330
+ export async function runReactiveCompaction(runState, {
331
+ harness,
332
+ runtime,
333
+ resolved,
334
+ options,
335
+ promptText,
336
+ promptImages,
337
+ reference,
338
+ onEvent,
339
+ runtimeWarnings,
340
+ state,
341
+ runError,
342
+ captureState,
343
+ }) {
344
+ const c = runState.compaction;
345
+ if (!(
346
+ c.policy?.enabled
347
+ && !c.reactiveAttempted
348
+ && !runState.externalAbort
349
+ && !runState.maxTurnsHit
350
+ && !options.abortSignal?.aborted
351
+ )) {
352
+ return { state, runError };
353
+ }
354
+ const provisionalRaw = state.stopReason === "error" || state.stopReason === "aborted"
355
+ ? state.lastAssistant?.errorMessage || runError?.message || null
356
+ : (runError ? runError.message || String(runError) : null);
357
+ const provisionalError = normalizePiErrorMessage(provisionalRaw);
358
+ if (provisionalError && isReactiveCompactionCandidate(provisionalError, c.diagnostics)) {
359
+ 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));
363
+ // A second compaction immediately after a fresh proactive one is almost
364
+ // always "nothing to compact"; skip it and surface the original error.
365
+ if (!c.compactedThisRun) {
366
+ await harness.waitForIdle();
367
+ const res = await tryCompact(harness, {
368
+ trigger: "reactive_overflow",
369
+ onEvent,
370
+ runtimeWarnings,
371
+ onCompactionRecorded: options.onCompactionRecorded,
372
+ runId: options.runId,
373
+ model: reference,
374
+ });
375
+ if (res.applied) {
376
+ c.applied = true;
377
+ c.compactedThisRun = true;
378
+ Object.assign(c.diagnostics, {
379
+ context_compaction_reactive: true,
380
+ context_compaction_tokens_before: res.tokensBefore,
381
+ });
382
+ // Re-anchor the transcript baseline to the compacted length so the
383
+ // re-prompt's turn (and its stopReason/usage) slices out correctly.
384
+ 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();
390
+ }
391
+ }
392
+ }
393
+ return { state, runError };
394
+ }