@mono-agent/agent-runtime 0.3.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.
- package/ARCHITECTURE.md +75 -9
- package/MIGRATION.md +293 -0
- package/README.md +46 -20
- package/package.json +104 -10
- package/src/agent/allowlists.js +3 -0
- package/src/agent/approval.js +3 -0
- package/src/agent/compaction.js +231 -654
- package/src/agent/index.js +1 -1
- package/src/agent/prompt/skill-index.js +6 -0
- package/src/agent/sandbox-seam.js +227 -0
- package/src/agent/tool-bloat.js +8 -2
- package/src/agent/tools/bash.js +16 -8
- package/src/agent/tools/edit.js +7 -3
- package/src/agent/tools/glob.js +11 -5
- package/src/agent/tools/grep.js +11 -5
- package/src/agent/tools/pi-bridge.js +272 -54
- package/src/agent/tools/read.js +28 -3
- package/src/agent/tools/shared/output-truncation.js +8 -3
- package/src/agent/tools/shared/path-resolver.js +24 -18
- package/src/agent/tools/shared/ripgrep.js +33 -6
- package/src/agent/tools/shared/runtime-context.js +60 -50
- package/src/agent/tools/shared/tool-context.js +157 -0
- package/src/agent/tools/web-fetch.js +65 -18
- package/src/agent/tools/web-search.js +11 -4
- package/src/agent/tools/write.js +7 -3
- package/src/agent/transcript.js +16 -1
- package/src/ai/cost.js +128 -3
- package/src/ai/failure.js +96 -4
- package/src/ai/index.js +1 -0
- package/src/ai/live-input-prompt.js +11 -1
- package/src/ai/providers/claude-cli.js +6 -2
- package/src/ai/providers/claude-sdk.js +55 -12
- package/src/ai/providers/codex-app.js +36 -23
- package/src/ai/providers/opencode-app.js +2 -2
- package/src/ai/providers/opencode-discovery.js +2 -2
- package/src/ai/providers/pi-errors.js +65 -0
- package/src/ai/providers/pi-events.js +5 -0
- package/src/ai/providers/pi-models.js +21 -4
- package/src/ai/providers/pi-native/compaction-driver.js +394 -0
- package/src/ai/providers/pi-native/result-builder.js +312 -0
- package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
- package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
- package/src/ai/providers/pi-native/structured-output.js +130 -0
- package/src/ai/providers/pi-native/turn-runner.js +278 -0
- package/src/ai/providers/pi-native.js +754 -0
- package/src/ai/runtime/capabilities.js +3 -0
- package/src/ai/runtime/model-refs.js +30 -0
- package/src/ai/runtime/registry.js +33 -2
- package/src/ai/runtime/router.js +119 -19
- package/src/ai/runtime/session-liveness.js +110 -0
- package/src/ai/runtime/sessions.js +19 -2
- package/src/ai/types.js +252 -20
- package/src/index.js +1 -1
- package/src/pi-auth.js +147 -16
- package/src/runtime-brand.js +21 -1
- package/src/runtime.js +75 -10
- package/types/agent/allowlists.d.ts +25 -0
- package/types/agent/approval.d.ts +30 -0
- package/types/agent/compaction.d.ts +97 -0
- package/types/agent/index.d.ts +5 -0
- package/types/agent/prompt/skill-index.d.ts +19 -0
- package/types/agent/sandbox-seam.d.ts +148 -0
- package/types/agent/tool-bloat.d.ts +22 -0
- package/types/agent/tools/bash.d.ts +16 -0
- package/types/agent/tools/edit.d.ts +14 -0
- package/types/agent/tools/glob.d.ts +16 -0
- package/types/agent/tools/grep.d.ts +22 -0
- package/types/agent/tools/index.d.ts +10 -0
- package/types/agent/tools/pi-bridge.d.ts +144 -0
- package/types/agent/tools/read.d.ts +19 -0
- package/types/agent/tools/shared/constants.d.ts +13 -0
- package/types/agent/tools/shared/dedup.d.ts +8 -0
- package/types/agent/tools/shared/output-truncation.d.ts +22 -0
- package/types/agent/tools/shared/path-resolver.d.ts +6 -0
- package/types/agent/tools/shared/ripgrep.d.ts +38 -0
- package/types/agent/tools/shared/runtime-context.d.ts +27 -0
- package/types/agent/tools/shared/tool-context.d.ts +48 -0
- package/types/agent/tools/web-fetch.d.ts +13 -0
- package/types/agent/tools/web-search.d.ts +11 -0
- package/types/agent/tools/write.d.ts +12 -0
- package/types/agent/transcript.d.ts +45 -0
- package/types/ai/backend.d.ts +41 -0
- package/types/ai/cost.d.ts +96 -0
- package/types/ai/failure.d.ts +117 -0
- package/types/ai/file-change-stats.d.ts +75 -0
- package/types/ai/index.d.ts +7 -0
- package/types/ai/live-input-prompt.d.ts +6 -0
- package/types/ai/observer.d.ts +68 -0
- package/types/ai/providers/claude-cli.d.ts +211 -0
- package/types/ai/providers/claude-sdk.d.ts +66 -0
- package/types/ai/providers/claude-subagents.d.ts +2 -0
- package/types/ai/providers/codex-app.d.ts +151 -0
- package/types/ai/providers/opencode-app.d.ts +95 -0
- package/types/ai/providers/opencode-discovery.d.ts +4 -0
- package/types/ai/providers/pi-errors.d.ts +3 -0
- package/types/ai/providers/pi-events.d.ts +24 -0
- package/types/ai/providers/pi-messages.d.ts +5 -0
- package/types/ai/providers/pi-models.d.ts +57 -0
- package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
- package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
- package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
- package/types/ai/providers/pi-native.d.ts +18 -0
- package/types/ai/registry.d.ts +1 -0
- package/types/ai/runtime/capabilities-used.d.ts +21 -0
- package/types/ai/runtime/capabilities.d.ts +33 -0
- package/types/ai/runtime/context-windows.d.ts +8 -0
- package/types/ai/runtime/fast-mode.d.ts +2 -0
- package/types/ai/runtime/model-refs.d.ts +35 -0
- package/types/ai/runtime/registry.d.ts +38 -0
- package/types/ai/runtime/router.d.ts +56 -0
- package/types/ai/runtime/session-liveness.d.ts +55 -0
- package/types/ai/runtime/sessions.d.ts +38 -0
- package/types/ai/streaming/codex-events.d.ts +30 -0
- package/types/ai/streaming/opencode-events.d.ts +41 -0
- package/types/ai/types.d.ts +702 -0
- package/types/index.d.ts +7 -0
- package/types/pi-auth.d.ts +28 -0
- package/types/runtime-brand.d.ts +30 -0
- package/types/runtime.d.ts +10 -0
- package/src/ai/providers/pi-sdk.js +0 -1310
|
@@ -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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Shared pi error-normalization helpers.
|
|
2
|
+
//
|
|
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.
|
|
7
|
+
|
|
8
|
+
function tryParseJson(text) {
|
|
9
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizePiErrorMessage(message) {
|
|
13
|
+
const text = String(message || "").trim();
|
|
14
|
+
if (!text) return null;
|
|
15
|
+
const codexMatch = /^Codex error:\s*(\{[\s\S]*\})$/i.exec(text);
|
|
16
|
+
const parsed = tryParseJson(codexMatch ? codexMatch[1] : text);
|
|
17
|
+
const nested = parsed?.error || parsed;
|
|
18
|
+
if (typeof nested?.message === "string" && nested.message.trim()) return nested.message.trim();
|
|
19
|
+
if (typeof nested?.error?.message === "string" && nested.error.message.trim()) return nested.error.message.trim();
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
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);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Best-effort extraction of the model's real context-window ceiling from an
|
|
35
|
+
// overflow error. Providers usually state the limit ("maximum context length is
|
|
36
|
+
// 200000 tokens", "context window of 128000", "this model supports at most
|
|
37
|
+
// 32768 tokens"). We use the discovered value to lower the proactive compaction
|
|
38
|
+
// trigger on the running model so a wrong/default contextWindow self-corrects.
|
|
39
|
+
// Returns the smallest plausible token count found, or null.
|
|
40
|
+
export function parseContextLimitFromError(message) {
|
|
41
|
+
const text = String(message || "");
|
|
42
|
+
if (!text) return null;
|
|
43
|
+
// Capture a number that sits next to context/window/token wording, tolerating
|
|
44
|
+
// separators in large numbers (e.g. "128,000" or "128 000"). Phrases like
|
|
45
|
+
// "however you requested 210000 tokens" would over-count the limit, so we
|
|
46
|
+
// anchor on max/limit/context/window wording and take the smallest match.
|
|
47
|
+
const patterns = [
|
|
48
|
+
/(?:maximum|max(?:imum)?)\s+context\s+(?:length|window)\s*(?:is|of|=|:)?\s*([\d][\d,_ ]*)/ig,
|
|
49
|
+
/context\s+(?:length|window|budget)\s*(?:is|of|=|:)?\s*([\d][\d,_ ]*)/ig,
|
|
50
|
+
/(?:maximum|max(?:imum)?|at most|supports?(?:\s+up\s+to)?)\s+([\d][\d,_ ]*)\s*(?:input\s+)?tokens?/ig,
|
|
51
|
+
/(?:token|context)\s+limit\s*(?:is|of|=|:)?\s*([\d][\d,_ ]*)/ig,
|
|
52
|
+
];
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const re of patterns) {
|
|
55
|
+
let m;
|
|
56
|
+
while ((m = re.exec(text)) !== null) {
|
|
57
|
+
const n = Number(String(m[1]).replace(/[,_ ]/g, ""));
|
|
58
|
+
// Guard against matching small token counts (e.g. "8 tokens") that are not
|
|
59
|
+
// a real window; a context window is at least a few thousand tokens.
|
|
60
|
+
if (Number.isFinite(n) && n >= 1000) found.push(n);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (found.length === 0) return null;
|
|
64
|
+
return Math.min(...found);
|
|
65
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|