@mono-agent/agent-runtime 0.4.0 → 0.5.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.
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
@@ -19,28 +19,19 @@
19
19
  // The result/event contract is the package's unified runtime-result shape, so
20
20
  // callers and the test suite see the same artifact the retired bridge produced.
21
21
 
22
- import {
23
- AgentHarness,
24
- InMemorySessionRepo,
25
- JsonlSessionRepo,
26
- calculateContextTokens,
27
- estimateTokens,
28
- getLastAssistantUsage,
29
- } from "@earendil-works/pi-agent-core";
30
- import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
22
+ import { createModels, createProvider, envApiKeyAuth } from "@earendil-works/pi-ai";
23
+ import { builtinModels } from "@earendil-works/pi-ai/providers/all";
24
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
31
25
  import { randomUUID } from "node:crypto";
32
26
  import { estimateCost } from "../cost.js";
33
27
  import { retryableProviderFailureInfo } from "../failure.js";
34
28
  import { runtimeCapabilities } from "../runtime/capabilities.js";
35
- import { createSessionRegistry } from "../runtime/sessions.js";
36
- import { formatLiveInputGuidance } from "../live-input-prompt.js";
37
- import { isLikelyContextTermination, resolveAgentCompactionPolicy } from "../../agent/compaction.js";
38
29
  import {
39
- closePiMcpClients,
40
- createStructuredOutputTool,
41
- getPiBuiltinTools,
42
- initPiMcpTools,
43
- } from "../../agent/tools/pi-bridge.js";
30
+ deprecatedSettingsWarning,
31
+ resolveAgentCompactionPolicy,
32
+ resolveRuntimePolicyInputs,
33
+ } from "../../agent/compaction.js";
34
+ import { closePiMcpClients } from "../../agent/tools/pi-bridge.js";
44
35
  import { createApprovalManager } from "../../agent/approval.js";
45
36
  import { buildCapabilitiesUsed, toolCompactionAppliedFromWarnings } from "../runtime/capabilities-used.js";
46
37
  import { resolvePiRuntimeModel } from "./pi-models.js";
@@ -48,224 +39,43 @@ import {
48
39
  textFromContent,
49
40
  thinkingFromContent,
50
41
  toAgentMessages,
51
- toolResultContent,
52
42
  } from "./pi-messages.js";
43
+ import { emitCaptured } from "./pi-events.js";
44
+ import { normalizePiErrorMessage } from "./pi-errors.js";
53
45
  import {
54
- compactToolRawResult,
55
- emitCaptured,
56
- eventToolArgs,
57
- jsonSerializable,
58
- streamContentKey,
59
- } from "./pi-events.js";
46
+ runStructuredOutputFinalizationRetry,
47
+ shouldRetryStructuredOutputFinalization,
48
+ } from "./pi-native/structured-output.js";
60
49
  import {
61
- isContextLimitError,
62
- normalizePiErrorMessage,
63
- parseContextLimitFromError,
64
- } from "./pi-errors.js";
65
-
66
- function usageFromMessages(messages = []) {
67
- const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
68
- for (const message of messages) {
69
- if (message?.role !== "assistant") continue;
70
- const next = message.usage || {};
71
- usage.input += Number(next.input) || 0;
72
- usage.output += Number(next.output) || 0;
73
- usage.cacheRead += Number(next.cacheRead) || 0;
74
- usage.cacheWrite += Number(next.cacheWrite) || 0;
75
- usage.cost += Number(next.cost?.total) || 0;
76
- }
77
- return usage;
78
- }
79
-
80
- function thinkingLevelForEffort(effort, capabilities) {
81
- if (!capabilities?.reasoning || capabilities.reasoning_mode === "none") return "off";
82
- if (effort === "none") return "off";
83
- if (effort === "max") return "xhigh";
84
- if (effort === "xhigh") return "xhigh";
85
- if (effort === "high") return "high";
86
- if (effort === "medium") return "medium";
87
- return "low";
88
- }
89
-
90
- function appendStructuredOutputInstruction(systemPrompt, outputSchema) {
91
- if (!outputSchema) return systemPrompt;
92
- return [
93
- systemPrompt,
94
- "",
95
- "Structured output is available through the `StructuredOutput` tool.",
96
- "When the final result is ready, call `StructuredOutput` with the complete JSON object matching the requested schema.",
97
- "Do not also print the same JSON as prose unless tool calling is unavailable.",
98
- ].join("\n");
99
- }
100
-
101
- function toolStartProgressText(toolName) {
102
- if (typeof toolName !== "string" || toolName.trim().length === 0) return null;
103
- return `Running ${toolName}...`;
104
- }
105
-
106
- function structuredOutputFinalizationPrompt() {
107
- return [
108
- "The previous assistant turn ended without submitting the required structured result.",
109
- "Do not run tools, inspect files, or redo work.",
110
- "Call only `StructuredOutput` once with the final object matching the requested schema, based on the completed transcript above.",
111
- "Do not print prose before or after the tool call.",
112
- ].join("\n");
113
- }
114
-
115
- function shouldRetryStructuredOutputFinalization({
116
- outputSchema,
117
- structuredResult,
118
- finalText,
119
- stopReason,
120
- externalAbort,
121
- maxTurnsHit,
122
- }) {
123
- if (!outputSchema) return false;
124
- if (structuredResult !== null && structuredResult !== undefined) return false;
125
- if (String(finalText || "").trim()) return false;
126
- if (externalAbort || maxTurnsHit) return false;
127
- return stopReason !== "error" && stopReason !== "aborted";
128
- }
129
-
130
- function structuredOutputRetryDiagnostics(attempts, reason, failed) {
131
- if (!attempts) return {};
132
- return {
133
- structured_output_finalization_retry_attempts: attempts,
134
- structured_output_finalization_retry_reason: reason,
135
- structured_output_finalization_retry_failed: !!failed,
136
- };
137
- }
138
-
139
- function failureKindForPiError(message, diagnostics, { maxTurnsHit = false } = {}) {
140
- if (!message) return null;
141
- if (maxTurnsHit || isContextLimitError(message) || isLikelyContextTermination(message, diagnostics)) {
142
- return "usage_limit";
143
- }
144
- return "provider_unavailable";
145
- }
146
-
147
- // AUTO-COMPACTION. pi-agent-core performs NO automatic in-loop compaction
148
- // (shouldCompact/compact are exported helpers its loop never calls), so this
149
- // bridge drives it: proactively before a turn when the running model's context
150
- // is near the window, and reactively (compact + single re-prompt) if a turn
151
- // still overflows. The window auto-tracks the model actually serving the request
152
- // and self-corrects from any real ceiling stated in an overflow error.
153
-
154
- // Per-process cache of real context-window ceilings discovered from overflow
155
- // errors, keyed by model reference/id. The long-running host re-learns quickly
156
- // after a restart; this just spares repeated first-overflow round-trips.
157
- const discoveredContextWindows = new Map();
158
-
159
- function modelWindowKey(harness, runtime, resolved) {
160
- const live = typeof harness?.getModel === "function" ? harness.getModel() : null;
161
- return resolved?.reference || runtime?.model?.id || live?.id || "unknown";
162
- }
163
-
164
- // The window of the model that ACTUALLY serves this request: prefer the harness's
165
- // live model (authoritative for native pi models), fall back to the resolved
166
- // runtime model. Returns 0 when unknown so callers can skip the proactive trigger.
167
- function liveModelContextWindow(harness, runtime) {
168
- const live = typeof harness?.getModel === "function" ? harness.getModel() : null;
169
- const win = Number(live?.contextWindow) || Number(runtime?.model?.contextWindow) || 0;
170
- return win > 0 ? win : 0;
171
- }
172
-
173
- function effectiveContextWindow(harness, runtime, resolved) {
174
- const declared = liveModelContextWindow(harness, runtime);
175
- const discovered = discoveredContextWindows.get(modelWindowKey(harness, runtime, resolved));
176
- if (Number.isFinite(discovered) && discovered > 0) {
177
- return declared > 0 ? Math.min(declared, discovered) : discovered;
178
- }
179
- return declared;
180
- }
181
-
182
- function recordDiscoveredContextWindow(harness, runtime, resolved, limit) {
183
- const n = Number(limit);
184
- if (!Number.isFinite(n) || n <= 0) return;
185
- const key = modelWindowKey(harness, runtime, resolved);
186
- const existing = discoveredContextWindows.get(key);
187
- discoveredContextWindows.set(key, Number.isFinite(existing) && existing > 0 ? Math.min(existing, n) : n);
188
- }
189
-
190
- // Best-effort estimate of the current session's context size. The last assistant
191
- // usage is authoritative (it reflects what the provider actually counted,
192
- // including cache reads), but it can be stale/zero (e.g. seeded history), so we
193
- // take the MAX of the usage-based count and a raw per-message estimate. Either
194
- // being large is a reason to compact; overcounting only compacts slightly early.
195
- async function estimateCurrentContextTokens(session) {
196
- let usageTokens = 0;
197
- let rawTokens = 0;
198
- try {
199
- const usage = getLastAssistantUsage(await session.getEntries());
200
- if (usage) usageTokens = Number(calculateContextTokens(usage)) || 0;
201
- } catch { /* ignore — fall back to the raw estimate */ }
202
- try {
203
- const context = await session.buildContext();
204
- for (const message of context?.messages || []) rawTokens += Number(estimateTokens(message)) || 0;
205
- } catch { /* ignore — usage-based estimate stands */ }
206
- if (usageTokens === 0 && rawTokens === 0) return { tokens: 0, source: "unavailable" };
207
- return usageTokens >= rawTokens
208
- ? { tokens: usageTokens, source: "usage" }
209
- : { tokens: rawTokens, source: "estimate" };
210
- }
211
-
212
- // Run a single guarded compaction. Requires the harness idle (callers
213
- // waitForIdle first). Never throws — classifies AgentHarnessError into a warning
214
- // and reports back whether anything was compacted. Fires onCompactionRecorded on
215
- // success so a host can persist the compaction row.
216
- async function tryCompact(harness, { trigger, onEvent, runtimeWarnings, onCompactionRecorded, runId, model }) {
217
- try {
218
- const result = await harness.compact();
219
- const tokensBefore = Number(result?.tokensBefore) || null;
220
- onEvent?.({
221
- type: "runtime_warning",
222
- warning_kind: "context_compaction_applied",
223
- source: "pi",
224
- trigger,
225
- tokens_before: tokensBefore,
226
- });
227
- if (typeof onCompactionRecorded === "function") {
228
- try {
229
- onCompactionRecorded({
230
- task_run_id: runId || null,
231
- trigger,
232
- provider_kind: "pi",
233
- model: model || null,
234
- tokens_before: tokensBefore,
235
- summary: result?.summary || "",
236
- first_kept_entry_id: result?.firstKeptEntryId || null,
237
- status: "succeeded",
238
- created_at: Date.now(),
239
- });
240
- } catch (err) {
241
- runtimeWarnings.push({
242
- warning_kind: "context_compaction_record_failed",
243
- source: "pi",
244
- message: err?.message || String(err),
245
- });
246
- }
247
- }
248
- return { applied: true, tokensBefore, nothingToCompact: false };
249
- } catch (err) {
250
- const message = err?.message || String(err);
251
- const code = err?.code;
252
- const nothingToCompact = code === "compaction" && /nothing to compact/i.test(message);
253
- const warningKind = nothingToCompact
254
- ? "context_compaction_nothing_to_compact"
255
- : code === "auth"
256
- ? "context_compaction_auth_failed"
257
- : code === "busy"
258
- ? "context_compaction_busy"
259
- : "context_compaction_failed";
260
- runtimeWarnings.push({ warning_kind: warningKind, source: "pi", trigger, message });
261
- return { applied: false, tokensBefore: null, nothingToCompact };
262
- }
263
- }
264
-
265
- function isReactiveCompactionCandidate(errorMessage, diagnostics) {
266
- if (!errorMessage) return false;
267
- return isContextLimitError(errorMessage) || isLikelyContextTermination(errorMessage, diagnostics);
268
- }
50
+ abortedResult,
51
+ buildDiagnostics,
52
+ buildErrorDetails,
53
+ buildErrorResult,
54
+ buildSuccessResult,
55
+ emitCapabilitiesResolved,
56
+ emitUsageCostEvents,
57
+ usageFromMessages,
58
+ } from "./pi-native/result-builder.js";
59
+ import {
60
+ cleanupSessionOnThrow,
61
+ commitSession,
62
+ discardUncommittedSession,
63
+ resolveDurableNativeSessionRepo,
64
+ resolveSession,
65
+ rollbackAbortedTurn,
66
+ } from "./pi-native/session-lifecycle.js";
67
+ import {
68
+ resolveLiveCompactionPolicy,
69
+ runProactiveCompaction,
70
+ runReactiveCompaction,
71
+ } from "./pi-native/compaction-driver.js";
72
+ import {
73
+ buildTurnHarness,
74
+ buildTurnTools,
75
+ runHarnessPrompt,
76
+ startLiveInput,
77
+ thinkingLevelForEffort,
78
+ } from "./pi-native/turn-runner.js";
269
79
 
270
80
  async function resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnings }) {
271
81
  if (apiKeys?.has(provider)) return apiKeys.get(provider);
@@ -282,6 +92,85 @@ async function resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnin
282
92
  }
283
93
  }
284
94
 
95
+ async function readCredential(provider, { apiKeys, resolvePiApiKey, runtimeWarnings }) {
96
+ if (apiKeys?.has(provider)) {
97
+ const key = apiKeys.get(provider);
98
+ return typeof key === "string" && key.length > 0 ? { type: "api_key", key } : undefined;
99
+ }
100
+ if (typeof resolvePiApiKey?.readCredential === "function") {
101
+ const credential = await resolvePiApiKey.readCredential(provider);
102
+ return isCredential(credential) ? credential : undefined;
103
+ }
104
+ const key = await resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnings });
105
+ return typeof key === "string" && key.length > 0 ? { type: "api_key", key } : undefined;
106
+ }
107
+
108
+ function isCredential(credential) {
109
+ return credential
110
+ && typeof credential === "object"
111
+ && (credential.type === "api_key" || credential.type === "oauth");
112
+ }
113
+
114
+ // pi 0.80 removed the harness `getApiKeyAndHeaders` hook: request auth now
115
+ // resolves through a `Models` collection's `CredentialStore`. This store keeps
116
+ // the bridge's per-run key-resolution contract intact — an `apiKeys` map entry
117
+ // wins, else an enhanced host resolver's credential-store methods are used, else
118
+ // the legacy `resolvePiApiKey(provider)` callback is consulted. Legacy callback
119
+ // failures remain soft (`pi_auth_failed` warning + keyless env fallback);
120
+ // credential-store read/modify failures reject so Pi can surface them as auth
121
+ // storage failures instead of silently treating a corrupt store as no credential.
122
+ export function createDynamicCredentialStore(apiKeys, resolvePiApiKey, runtimeWarnings) {
123
+ const read = async (providerId) => readCredential(providerId, { apiKeys, resolvePiApiKey, runtimeWarnings });
124
+ return /** @type {any} */ ({
125
+ read,
126
+ async modify(providerId, fn) {
127
+ if (!apiKeys?.has(providerId) && typeof resolvePiApiKey?.modifyCredential === "function") {
128
+ return resolvePiApiKey.modifyCredential(providerId, fn);
129
+ }
130
+ const current = await read(providerId);
131
+ const next = await fn(current);
132
+ if (apiKeys?.has(providerId) && next?.type === "api_key" && typeof next.key === "string") {
133
+ apiKeys.set(providerId, next.key);
134
+ }
135
+ return next ?? current;
136
+ },
137
+ async delete(providerId) {
138
+ if (apiKeys?.has(providerId)) {
139
+ apiKeys.delete(providerId);
140
+ return;
141
+ }
142
+ if (typeof resolvePiApiKey?.deleteCredential === "function") {
143
+ await resolvePiApiKey.deleteCredential(providerId);
144
+ }
145
+ },
146
+ });
147
+ }
148
+
149
+ // Assemble the pi 0.80 `Models` collection serving this run. Builtin models
150
+ // reuse pi's own provider factories (correct per-provider baseUrl/headers and
151
+ // env-var fallback); a custom OpenAI-completions provider is registered from the
152
+ // resolved model. `piResolvedModels` is an advanced/test seam mirroring
153
+ // `piResolvedModel`: when supplied it is used verbatim (the model dispatched via
154
+ // `piResolvedModel` may live outside pi's builtin catalog, e.g. a faux model).
155
+ function buildRunModels(runtime, options, runtimeWarnings) {
156
+ if (options.piResolvedModels) return options.piResolvedModels;
157
+ const credentials = createDynamicCredentialStore(runtime.apiKeys, options.resolvePiApiKey, runtimeWarnings);
158
+ if (options.customProvider) {
159
+ const model = runtime.model;
160
+ const models = createModels({ credentials });
161
+ models.setProvider(createProvider({
162
+ id: model.provider,
163
+ name: model.name || model.provider,
164
+ baseUrl: model.baseUrl,
165
+ auth: { apiKey: envApiKeyAuth(model.name || model.provider, []) },
166
+ models: [model],
167
+ api: openAICompletionsApi(),
168
+ }));
169
+ return models;
170
+ }
171
+ return builtinModels({ credentials });
172
+ }
173
+
285
174
  // Normalize the incoming runtime messages into AgentMessages the harness can
286
175
  // seed/prompt. Returns the prior messages (appended to the session before the
287
176
  // run) and the final user text used to drive `harness.prompt`.
@@ -327,149 +216,71 @@ function splitUserContent(content) {
327
216
  return { text: texts.join("\n"), images };
328
217
  }
329
218
 
330
- // Live pi-native sessions, keyed by provider session id. Entries are
331
- // { session, metadata, repo, durable, busy } — identical shape and lifecycle
332
- // policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
333
- // when the registry evicts them; durable (jsonl) transcripts survive eviction
334
- // so a later resume can reopen them from disk. Registering here gives
335
- // runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
336
- // reach over native pi sessions that the legacy bridge had.
337
- const nativeSessionRepo = new InMemorySessionRepo();
338
- const nativeSessions = createSessionRegistry({
339
- isBusy: (entry) => entry.busy === true,
340
- onEvict: async (entry) => {
341
- if (entry.durable) return;
342
- try {
343
- await entry.repo.delete(entry.metadata);
344
- } catch { /* best-effort */ }
345
- },
346
- });
347
-
348
- const durableNativeSessionRepos = new Map();
349
-
350
- function resolveDurableNativeSessionRepo(piSessionsRoot) {
351
- if (typeof piSessionsRoot !== "string" || !piSessionsRoot.trim()) return null;
352
- const root = piSessionsRoot.trim();
353
- let repo = durableNativeSessionRepos.get(root);
354
- if (!repo) {
355
- repo = new JsonlSessionRepo({
356
- fs: new NodeExecutionEnv({ cwd: process.cwd() }),
357
- sessionsRoot: root,
358
- });
359
- durableNativeSessionRepos.set(root, repo);
360
- }
361
- return repo;
362
- }
363
-
364
- // Defense in depth (R4): create-on-miss passes the caller-controlled session id
365
- // straight to durableRepo.create({ id }), and JsonlSessionRepo writes
366
- // `${createdAt}_${id}.jsonl` — so an id like "../../../../tmp/pwn" would escape
367
- // piSessionsRoot and name a file anywhere on disk. The harness-derived id is a
368
- // sha256 hex (always safe), but the public runtime API is caller-controlled.
369
- // Only an id that is a single safe filename component may CREATE a session;
370
- // anything else falls through to the existing session_not_found fast-fail, so a
371
- // malicious id can never name a file. (A genuinely on-disk session reopened by
372
- // reopenDurableNativeSession is matched by `.id`, never used to build a path, so
373
- // this gate is confined to the create path.)
374
- const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
375
-
376
- function isSafeSessionId(id) {
377
- return typeof id === "string"
378
- && SAFE_SESSION_ID.test(id)
379
- && !id.includes("..")
380
- && !id.includes("/")
381
- && !id.includes("\\");
382
- }
383
-
384
- async function reopenDurableNativeSession(repo, sessionId) {
385
- try {
386
- const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
387
- if (!metadata) return null;
388
- const session = await repo.open(metadata);
389
- return { session, metadata, repo, durable: true, busy: false };
390
- } catch {
391
- return null;
392
- }
393
- }
394
-
395
- function sessionUnavailableResult({
396
- resolved,
397
- options,
398
- events,
399
- runtimeWarnings,
400
- start,
401
- sessionId,
402
- errorMessage,
403
- failureKind,
404
- piErrorCode,
405
- }) {
406
- return {
407
- text: null,
408
- events,
409
- usage: {},
410
- durationMs: Date.now() - start,
411
- numTurns: 0,
412
- model: resolved?.reference || resolved?.model || null,
413
- effort: options.effort || null,
414
- sdk: resolved?.sdk || "pi",
415
- cancelled: false,
416
- error: errorMessage,
417
- failureKind,
418
- providerSessionId: sessionId,
419
- runtimeWarnings,
420
- diagnostics: {
421
- provider_session_id: sessionId,
422
- pi_error_code: piErrorCode,
423
- pi_engine: "native",
424
- },
425
- };
426
- }
427
-
428
- function abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId }) {
429
- return {
430
- text: null,
431
- thinking: "",
432
- events,
433
- usage: {},
434
- durationMs: Date.now() - start,
435
- numTurns: 0,
436
- model: resolved?.reference || resolved?.model || null,
437
- effort: options.effort || null,
438
- sdk: resolved?.sdk || "pi",
439
- cancelled: true,
440
- error: null,
441
- failureKind: null,
442
- providerSessionId,
443
- runtimeWarnings,
444
- diagnostics: {
445
- provider_session_id: providerSessionId,
446
- pi_stop_reason: "aborted",
447
- pi_engine: "native",
448
- external_abort: true,
449
- },
450
- };
451
- }
452
-
453
219
  export async function generatePiNativeResponse(systemPrompt, options = {}) {
454
220
  const resolved = options.model;
455
221
  const start = Date.now();
456
222
  const events = [];
457
223
  const runtimeWarnings = [];
458
- const assistantTexts = [];
459
- const assistantThinking = [];
460
- const textDeltaIndexes = new Set();
461
- const thinkingDeltaIndexes = new Set();
462
- let structuredResult = null;
463
224
  let mcpClients = [];
464
- let externalAbort = false;
465
- let maxTurnsHit = false;
466
- let turnCount = 0;
467
- let toolResultsSeen = 0;
468
- let lastToolName = null;
469
- // toolCallId -> start timestamp, so we can emit per-tool execution latency.
470
- const toolStartTimes = new Map();
471
225
  let harness = null;
472
- let removeAbortHandler = null;
226
+ // The ONE explicit runState the extracted modules (stream subscriber, session
227
+ // lifecycle, compaction driver, turn runner, result builder) read/write.
228
+ // Reassignable scalars/refs live here so a module can rebind them (an
229
+ // orchestrator local cannot be reassigned from a module). `events` /
230
+ // `runtimeWarnings` stay as consts shared by reference. `toolStartTimes` maps
231
+ // toolCallId -> start timestamp so per-tool execution latency can be emitted.
232
+ const runState = {
233
+ assistantTexts: [],
234
+ assistantThinking: [],
235
+ textDeltaIndexes: new Set(),
236
+ thinkingDeltaIndexes: new Set(),
237
+ toolStartTimes: new Map(),
238
+ turnCount: 0,
239
+ toolResultsSeen: 0,
240
+ lastToolName: null,
241
+ maxTurnsHit: false,
242
+ // Populated by the StructuredOutput tool callback (built in the turn runner);
243
+ // read by the finalization retry predicate and the result assembly.
244
+ structuredResult: null,
245
+ // Set by the turn-runner abort handler; the OUTER catch and lifecycle
246
+ // decisions re-check it (`externalAbort ||= aborted`). removeAbortHandler is
247
+ // installed by the turn runner and cleared in finally; harness is set there
248
+ // too.
249
+ externalAbort: false,
250
+ removeAbortHandler: null,
251
+ harness: null,
252
+ // Auto-compaction sub-state. The policy is (re)computed at the decision
253
+ // point against the model actually serving the request; these flags track
254
+ // whether a compaction fired so the run reports context_compaction_applied
255
+ // honestly and never double-compacts.
256
+ compaction: {
257
+ applied: false,
258
+ reactiveAttempted: false,
259
+ compactedThisRun: false,
260
+ policy: null,
261
+ diagnostics: {},
262
+ },
263
+ session: null,
264
+ sessionEntry: null,
265
+ // True when a requestedSessionId had no live entry AND no durable transcript,
266
+ // so a fresh durable session was created under that id (cross-restart resume,
267
+ // first turn for the conversation). Distinct from a true resume (sessionEntry
268
+ // set): the on-disk transcript is empty, so prior messages must still be
269
+ // seeded — unlike a resume, where the session already holds them.
270
+ createdOnMiss: false,
271
+ // The create-on-miss BUSY reservation handle (R8 concurrent-first-turn
272
+ // reservation) when the create path inserted its placeholder; null
273
+ // otherwise. Its release()/commit() clean the placeholder on the
274
+ // drop/error/abort/keep-alive paths, since createdOnMiss leaves sessionEntry
275
+ // null so the finally's busy-clear does not cover it.
276
+ reservation: null,
277
+ sessionBaselineCount: 0,
278
+ // Leaf captured before a resumed turn runs, so a failed resume can be rolled
279
+ // back to the last good transcript via moveTo. On runState so the OUTER catch
280
+ // (host/runtime-side throws after the session mutated) can roll back too, not
281
+ // just the success path.
282
+ baselineLeafId: null,
283
+ };
473
284
 
474
285
  const providerSessionId = options.sessionId
475
286
  || options.providerSessionId
@@ -509,142 +320,29 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
509
320
  }
510
321
 
511
322
  const durableRepo = resolveDurableNativeSessionRepo(options.piSessionsRoot);
512
- let session = null;
513
- let sessionEntry = null;
514
- // True when a requestedSessionId had no live entry AND no durable transcript,
515
- // so a fresh durable session was created under that id (cross-restart resume,
516
- // first turn for the conversation). Distinct from a true resume (sessionEntry
517
- // set): the on-disk transcript is empty, so prior messages must still be
518
- // seeded — unlike a resume, where the session already holds them.
519
- let createdOnMiss = false;
520
- // True once the create-on-miss path inserted its BUSY placeholder into the
521
- // registry (R8 concurrent-first-turn reservation). Used to clean the
522
- // placeholder up on the drop/error/abort paths, since createdOnMiss leaves
523
- // sessionEntry null so the finally's busy-clear does not cover it.
524
- let createdOnMissPlaceholder = false;
525
- let sessionBaselineCount = 0;
526
- // Leaf captured before a resumed turn runs, so a failed resume can be rolled
527
- // back to the last good transcript via moveTo. Hoisted to function scope so
528
- // the OUTER catch (host/runtime-side throws after the session mutated) can
529
- // roll back too, not just the success path.
530
- let baselineLeafId = null;
531
323
 
532
324
  try {
533
- // Resume check first: a session miss must stay cheap (no tool/MCP/harness
534
- // init). This mirrors the legacy bridge's fail-fast contract.
535
- if (requestedSessionId) {
536
- let entry = nativeSessions.get(requestedSessionId);
537
- if (!entry && durableRepo) {
538
- entry = await reopenDurableNativeSession(durableRepo, requestedSessionId);
539
- if (entry) {
540
- // TOCTOU guard: the reopen above is an AWAIT, so a second concurrent
541
- // cold resume could have reopened+inserted its own entry in this
542
- // window. Re-read the registry and adopt any entry already present so
543
- // the busy-claim below collapses back to the warm path's synchronous
544
- // semantics (the loser sees the winner's shared entry with busy===true
545
- // and returns session_busy). The discarded reopen is just an in-memory
546
- // jsonl handle (no subprocess/socket), so dropping it is safe.
547
- const concurrent = nativeSessions.get(requestedSessionId);
548
- if (concurrent) {
549
- entry = concurrent;
550
- } else {
551
- nativeSessions.set(requestedSessionId, entry, { idleTimeoutMs: sessionTtlMs });
552
- }
553
- }
554
- }
555
- if (!entry) {
556
- if (durableRepo && isSafeSessionId(providerSessionId)) {
557
- // Create-on-miss (durable resume only): the requested id has no live
558
- // registry entry AND no JSONL on disk under piSessionsRoot. This is
559
- // the cross-restart resume case — the harness derives a stable id from
560
- // the conversationId and passes it before any session exists on a
561
- // fresh process. Rather than fail with session_not_found (which would
562
- // make the harness re-send full history into yet another fresh,
563
- // randomly-named session and orphan future resumes), create a durable
564
- // session UNDER the requested id so this and every later turn for the
565
- // conversation resolve to the same on-disk transcript. sessionEntry
566
- // stays null so this proceeds exactly like a fresh run (prior messages
567
- // are seeded, the keep-alive success path registers + persists it).
568
- // The IN-MEMORY resume miss (no durableRepo) — and a create-on-miss
569
- // with an UNSAFE id (R4) — keep fast-failing below, preserving the
570
- // existing per-process session_not_found contract.
571
- //
572
- // Concurrent-first-turn race (R8): two concurrent first turns for the
573
- // same durable id would BOTH miss here and BOTH create, producing two
574
- // transcripts for one logical id (JsonlSessionRepo names files by
575
- // `${createdAt}_${id}`, so there is no fs-level dedup). Mirror the
576
- // cold-reopen-race defense: synchronously (NO await) re-check the
577
- // registry, then reserve the id with a BUSY placeholder before the
578
- // create await. The get→check→set span MUST stay await-free, so the
579
- // loser observes the busy placeholder and returns session_busy via the
580
- // same busy-claim path below — exactly one create per durable id.
581
- const concurrent = nativeSessions.get(requestedSessionId);
582
- if (concurrent) {
583
- // A concurrent caller already reserved/created this id in the window
584
- // since the miss above. Adopt its entry and fall into the busy-claim
585
- // logic (session_busy if its turn is in flight, else resume).
586
- entry = concurrent;
587
- } else {
588
- // Reserve the id with a busy placeholder BEFORE the create await so a
589
- // second concurrent first turn observes busy and returns session_busy.
590
- // The keep-alive success path (set(providerSessionId, ...) with
591
- // busy:false) overwrites this placeholder on success; the drop/abort/
592
- // catch paths delete it by requestedSessionId/providerSessionId (they
593
- // are equal here). Keyed by requestedSessionId === providerSessionId.
594
- nativeSessions.set(requestedSessionId, {
595
- session: null,
596
- metadata: null,
597
- repo: durableRepo,
598
- durable: true,
599
- busy: true,
600
- }, { idleTimeoutMs: sessionTtlMs });
601
- createdOnMissPlaceholder = true;
602
- session = await durableRepo.create({ id: providerSessionId, cwd: options.cwd || process.cwd() });
603
- createdOnMiss = true;
604
- }
605
- } else {
606
- return sessionUnavailableResult({
607
- resolved,
608
- options,
609
- events,
610
- runtimeWarnings,
611
- start,
612
- sessionId: requestedSessionId,
613
- errorMessage: `Pi session ${requestedSessionId} is not live`,
614
- failureKind: "session_not_found",
615
- piErrorCode: "pi_session_not_found",
616
- });
617
- }
618
- }
619
- if (entry && !createdOnMiss) {
620
- // The busy claim MUST stay await-free between the registry adoption
621
- // above and `entry.busy = true` below: get/set + this check/claim are
622
- // all synchronous, which is what makes the cold-resume race (F4) safe.
623
- // Do not introduce any await in this span or the TOCTOU window reopens.
624
- if (entry.busy) {
625
- return sessionUnavailableResult({
626
- resolved,
627
- options,
628
- events,
629
- runtimeWarnings,
630
- start,
631
- sessionId: requestedSessionId,
632
- errorMessage: `Pi session ${requestedSessionId} is busy with another turn`,
633
- failureKind: "session_busy",
634
- piErrorCode: "pi_session_busy",
635
- });
636
- }
637
- entry.busy = true;
638
- sessionEntry = entry;
639
- session = entry.session;
640
- }
641
- } else {
642
- // Fresh runs persist into the durable jsonl repo when piSessionsRoot is
643
- // set, so a kept-alive session can be reopened from disk after the live
644
- // entry is evicted; otherwise the in-memory repo is used.
645
- session = await (durableRepo || nativeSessionRepo)
646
- .create({ id: providerSessionId, cwd: options.cwd || process.cwd() });
647
- }
325
+ // Resume the session (warm registry hit, durable cold reopen, or
326
+ // create-on-miss for a durable cross-restart first turn) or create a fresh
327
+ // one. resolveSession mutates runState.session / sessionEntry / createdOnMiss
328
+ // / reservation, and returns an early fast-fail result (session_not_found /
329
+ // session_busy) to return verbatim. Its liveness claims (I1 busy-claim, R8
330
+ // reservation, F4 cold-reopen re-read) are await-free by construction. A
331
+ // session miss stays cheap: no tool/MCP/harness init runs before this
332
+ // fast-fail.
333
+ const resolvedSession = await resolveSession(runState, {
334
+ requestedSessionId,
335
+ providerSessionId,
336
+ durableRepo,
337
+ sessionTtlMs,
338
+ cwd: options.cwd,
339
+ resolved,
340
+ options,
341
+ events,
342
+ runtimeWarnings,
343
+ start,
344
+ });
345
+ if (resolvedSession.done) return resolvedSession.result;
648
346
 
649
347
  // `piResolvedModel` is an advanced/test seam: when supplied it provides a
650
348
  // ready pi-ai Model (e.g. a registered faux provider model) plus optional
@@ -667,84 +365,45 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
667
365
  const reference = resolved.reference
668
366
  || (resolved.sdk === "pi" ? `pi:${resolved.provider}:${resolved.model}` : `${resolved.sdk}:${resolved.model}`);
669
367
 
670
- // Tool-output limits (settings-driven clamps for tool/MCP payloads). The
671
- // legacy pi-sdk bridge wired these via the compaction manager's `.policy`;
672
- // resolveAgentCompactionPolicy is pure (no manager/Agent), so we compute the
673
- // same policy directly and pass it into the tool builders + display
674
- // normalization. Restores configurable clamping (agent_tool_text_limit_chars,
675
- // agent_search_result_limit, ...) on top of the 256KB hard ceiling.
676
- const toolLimits = resolveAgentCompactionPolicy(options.settings || {}, runtime.model);
677
-
678
- // Auto-compaction state. The compaction policy is (re)computed at the
679
- // decision point (Hook B) against the model actually serving the request, so
680
- // it is declared there; these flags track whether a compaction fired so the
681
- // run reports context_compaction_applied honestly and never double-compacts.
682
- let contextCompactionApplied = false;
683
- let contextCompactionReactiveAttempted = false;
684
- let contextCompactedThisRun = false;
685
- let compactionPolicy = null;
686
- const contextCompactionDiagnostics = {};
687
-
688
- const onTruncate = (info) => {
689
- try {
690
- onEvent({
691
- type: "runtime_warning",
692
- warning_kind: "tool_payload_truncated",
693
- source: "tool_bloat_guard",
694
- ...info,
695
- });
696
- } catch { /* best-effort */ }
697
- };
698
- const persistArtifact = options.persistArtifact || null;
699
- const qaOutputDir = options.qaOutputDir || options.runArtifactDir || null;
700
-
701
- // REUSED custom pieces: built-in tool sandboxing + allowlist/bloat filter +
702
- // approval gates. These are identical to the legacy bridge.
703
- const builtIns = capabilities.tool_use === false
704
- ? []
705
- : getPiBuiltinTools(options.allowedTools, {
706
- skillNames: (options.skills || []).map((skill) => skill.name),
707
- dataDir: options.dataDir,
708
- cwd: options.cwd,
709
- onEvent,
710
- persistArtifact,
711
- onTruncate,
712
- toolLimits,
713
- toolPayloadMaxBytes: toolLimits.toolPayloadMaxBytes,
714
- toolPolicy: options.toolPolicy,
715
- sandboxPolicy: options.sandboxPolicy,
716
- sandboxEngine: options.sandboxEngine,
717
- approvalManager,
718
- approvalModel: runtime.model?.id || runtime.model?.name || resolved.model,
719
- });
720
-
721
- const structuredTool = createStructuredOutputTool(options.outputSchema, (value) => {
722
- structuredResult = value;
368
+ // Resolve the typed `toolLimits` / `compaction` policy objects against the
369
+ // deprecated `settings` fallback (per-group precedence: a present typed
370
+ // object wins and its group's legacy keys are ignored; an absent object
371
+ // falls back to `settings`). Consuming any legacy key emits exactly one
372
+ // deprecation warning per run. mono-agent never passes `settings`, so this
373
+ // is a no-op there; the shim exists for worklab's day-one port.
374
+ const { settingsLike, consumedSettingsKeys } = resolveRuntimePolicyInputs({
375
+ toolLimits: options.toolLimits,
376
+ compaction: options.compaction,
377
+ settings: options.settings,
723
378
  });
724
- const reservedNames = new Set(builtIns.map((toolDef) => toolDef.name));
725
- if (structuredTool) reservedNames.add(structuredTool.name);
726
-
727
- // REUSED MCP tool bridge: same initPiMcpTools sandboxing path.
728
- const mcpInit = capabilities.tool_use === false
729
- ? { clients: [], tools: [], warnings: [] }
730
- : await initPiMcpTools(options.mcpServers || {}, reservedNames, {
731
- cwd: options.cwd,
732
- persistArtifact,
733
- qaOutputDir,
734
- onTruncate,
735
- limits: toolLimits,
736
- toolPayloadMaxBytes: toolLimits.toolPayloadMaxBytes,
737
- sandboxPolicy: options.sandboxPolicy,
738
- sandboxEngine: options.sandboxEngine,
739
- });
740
- mcpClients = mcpInit.clients;
741
- for (const warning of mcpInit.warnings || []) onEvent(warning);
379
+ if (consumedSettingsKeys.length > 0) {
380
+ const warning = deprecatedSettingsWarning(consumedSettingsKeys);
381
+ runtimeWarnings.push(warning);
382
+ onEvent({ type: "runtime_warning", ...warning });
383
+ }
742
384
 
743
- const tools = [
744
- ...builtIns,
745
- ...mcpInit.tools,
746
- ...(structuredTool ? [structuredTool] : []),
747
- ];
385
+ // Tool-output limits (clamps for tool/MCP payloads). The legacy pi-sdk bridge
386
+ // wired these via the compaction manager's `.policy`; resolveAgentCompactionPolicy
387
+ // is pure (no manager/Agent), so we compute the same policy directly from the
388
+ // resolved settings-like inputs and pass it into the tool builders + display
389
+ // normalization. Restores configurable clamping (toolTextLimitChars,
390
+ // searchResultLimit, ...) on top of the 256KB hard ceiling.
391
+ const toolLimits = resolveAgentCompactionPolicy(settingsLike, runtime.model);
392
+
393
+ // Build the turn's tools (builtins + MCP bridge + StructuredOutput). The
394
+ // StructuredOutput callback writes runState.structuredResult; the MCP clients
395
+ // are closed in the finally.
396
+ const { tools, structuredTool, mcpClients: builtMcpClients } = await buildTurnTools(runState, {
397
+ options,
398
+ capabilities,
399
+ toolLimits,
400
+ approvalManager,
401
+ runtime,
402
+ resolved,
403
+ onEvent,
404
+ runtimeWarnings,
405
+ });
406
+ mcpClients = builtMcpClients;
748
407
 
749
408
  // Provider retry/backoff is delegated to pi-ai via streamOptions, replacing
750
409
  // the legacy hand-rolled stream-retry loop.
@@ -759,129 +418,39 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
759
418
  // (QueueMode). Only enable when tools in a step are independent.
760
419
  const toolSteeringMode = options.piToolParallelismMode === "all" ? "all" : "one-at-a-time";
761
420
 
762
- harness = new AgentHarness({
763
- env: new NodeExecutionEnv({ cwd: options.cwd || process.cwd() }),
764
- session,
421
+ const piModels = buildRunModels(runtime, options, runtimeWarnings);
422
+
423
+ // Construct the harness, subscribe the stream normalizer, and wire the
424
+ // external abort handler (which sets runState.externalAbort and aborts the
425
+ // harness). Sets runState.harness + runState.removeAbortHandler.
426
+ harness = buildTurnHarness(runState, {
427
+ cwd: options.cwd,
428
+ session: runState.session,
429
+ piModels,
765
430
  model: runtime.model,
766
431
  thinkingLevel: effectiveThinkingLevel,
767
- systemPrompt: appendStructuredOutputInstruction(systemPrompt, options.outputSchema),
432
+ systemPrompt,
433
+ outputSchema: options.outputSchema,
768
434
  tools,
769
- streamOptions: { maxRetries, maxRetryDelayMs },
770
- getApiKeyAndHeaders: async (model) => {
771
- const apiKey = await resolveApiKey(model.provider, {
772
- apiKeys: runtime.apiKeys,
773
- resolvePiApiKey: options.resolvePiApiKey,
774
- runtimeWarnings,
775
- });
776
- return apiKey ? { apiKey } : undefined;
777
- },
435
+ maxRetries,
436
+ maxRetryDelayMs,
778
437
  steeringMode: toolSteeringMode,
779
- followUpMode: toolSteeringMode,
780
- });
781
-
782
- harness.subscribe((event) => {
783
- if (event.type === "message_update") {
784
- const streamEvent = event.assistantMessageEvent;
785
- if (streamEvent?.type === "text_delta" && streamEvent.delta) {
786
- textDeltaIndexes.add(streamContentKey(streamEvent, "text"));
787
- assistantTexts.push(streamEvent.delta);
788
- onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.delta }] } });
789
- } else if (streamEvent?.type === "text_end" && streamEvent.content) {
790
- const key = streamContentKey(streamEvent, "text");
791
- if (!textDeltaIndexes.has(key)) {
792
- assistantTexts.push(streamEvent.content);
793
- onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.content }] } });
794
- }
795
- } else if (streamEvent?.type === "thinking_delta" && streamEvent.delta) {
796
- thinkingDeltaIndexes.add(streamContentKey(streamEvent, "thinking"));
797
- assistantThinking.push(streamEvent.delta);
798
- onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.delta }] } });
799
- } else if (streamEvent?.type === "thinking_end" && streamEvent.content) {
800
- const key = streamContentKey(streamEvent, "thinking");
801
- if (!thinkingDeltaIndexes.has(key)) {
802
- assistantThinking.push(streamEvent.content);
803
- onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.content }] } });
804
- }
805
- }
806
- } else if (event.type === "tool_execution_start") {
807
- if (event.toolName) lastToolName = event.toolName;
808
- if (event.toolCallId) toolStartTimes.set(event.toolCallId, Date.now());
809
- const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
810
- const progressText = toolStartProgressText(event.toolName);
811
- if (progressText) {
812
- onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: progressText }] } });
813
- }
814
- onEvent({
815
- type: "assistant",
816
- message: { content: [{ type: "tool_use", id: event.toolCallId, name: event.toolName, input }] },
817
- });
818
- } else if (event.type === "tool_execution_update") {
819
- const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
820
- onEvent({
821
- type: "tool_update",
822
- tool_use_id: event.toolCallId,
823
- name: event.toolName,
824
- input,
825
- partial_result: jsonSerializable(event.partialResult, String(event.partialResult ?? "")),
826
- });
827
- } else if (event.type === "tool_execution_end") {
828
- const resultContent = toolResultContent(event.result);
829
- if (!event.isError) toolResultsSeen += 1;
830
- const startedAt = toolStartTimes.get(event.toolCallId);
831
- if (startedAt !== undefined) {
832
- toolStartTimes.delete(event.toolCallId);
833
- onEvent({
834
- type: "tool_timing",
835
- tool_use_id: event.toolCallId,
836
- name: event.toolName,
837
- execution_ms: Date.now() - startedAt,
838
- is_error: !!event.isError,
839
- });
840
- }
841
- onEvent({
842
- type: "user",
843
- message: {
844
- content: [{
845
- type: "tool_result",
846
- tool_use_id: event.toolCallId,
847
- content: resultContent,
848
- raw_result: compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent),
849
- is_error: !!event.isError,
850
- }],
851
- },
852
- });
853
- } else if (event.type === "turn_end") {
854
- turnCount += 1;
855
- if (Number.isFinite(Number(options.maxTurns))
856
- && Number(options.maxTurns) > 0
857
- && turnCount >= Number(options.maxTurns)
858
- && event.message?.stopReason === "toolUse") {
859
- maxTurnsHit = true;
860
- harness.abort();
861
- }
862
- }
438
+ onEvent,
439
+ options,
440
+ toolLimits,
863
441
  });
864
442
 
865
- const abortHandler = () => {
866
- externalAbort = true;
867
- harness.abort();
868
- };
869
- if (options.abortSignal) {
870
- options.abortSignal.addEventListener("abort", abortHandler, { once: true });
871
- removeAbortHandler = () => options.abortSignal.removeEventListener?.("abort", abortHandler);
872
- }
873
-
874
443
  // Seed prior transcript (everything before the trailing user turn) into the
875
444
  // harness-owned session. On a true resume the session already holds the
876
445
  // transcript, so prior messages are skipped; a fresh run AND a create-on-miss
877
446
  // (requestedSessionId set but the durable session was just created empty)
878
447
  // both seed, since their on-disk transcript is empty.
879
448
  const { priorMessages, promptText, promptImages } = splitPromptMessages(options.messages, runtime.model);
880
- sessionBaselineCount = (await session.buildContext()).messages.length;
881
- if (!requestedSessionId || createdOnMiss) {
449
+ runState.sessionBaselineCount = (await runState.session.buildContext()).messages.length;
450
+ if (!requestedSessionId || runState.createdOnMiss) {
882
451
  for (const message of priorMessages) {
883
452
  await harness.appendMessage(message);
884
- sessionBaselineCount += 1;
453
+ runState.sessionBaselineCount += 1;
885
454
  }
886
455
  }
887
456
  // The harness persists each turn INLINE into the live session. To preserve
@@ -890,36 +459,14 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
890
459
  // the last good transcript via the session tree's moveTo primitive. Only a
891
460
  // TRUE resume needs this: a create-on-miss session is fresh, so a failure
892
461
  // drops it entirely via the fresh-run path (no leaf to roll back to).
893
- if (requestedSessionId && !createdOnMiss) {
894
- try { baselineLeafId = await session.getLeafId(); } catch { /* best-effort */ }
462
+ if (requestedSessionId && !runState.createdOnMiss) {
463
+ try { runState.baselineLeafId = await runState.session.getLeafId(); } catch { /* best-effort */ }
895
464
  }
896
465
 
897
466
  // Live steering: consume follow-up messages and steer the harness mid-run.
898
- // The consumer is tied to run completion (runComplete) so it stops steering
899
- // once the run finishes and does not swallow messages meant for a later turn.
900
- let liveInputIterator = null;
901
- let liveInputTask = null;
902
- let runComplete = false;
903
- if (options.liveInput) {
904
- liveInputIterator = typeof options.liveInput[Symbol.asyncIterator] === "function"
905
- ? options.liveInput[Symbol.asyncIterator]()
906
- : options.liveInput;
907
- liveInputTask = (async () => {
908
- try {
909
- while (!runComplete && !options.abortSignal?.aborted) {
910
- const next = await liveInputIterator.next();
911
- if (next.done || runComplete || options.abortSignal?.aborted) break;
912
- await harness.steer(formatLiveInputGuidance(next.value.body));
913
- }
914
- } catch (err) {
915
- onEvent({
916
- type: "runtime_warning",
917
- warning_kind: "live_input_failed",
918
- message: err?.message || String(err),
919
- });
920
- }
921
- })();
922
- }
467
+ // The consumer is tied to run completion so it stops steering once the run
468
+ // finishes and does not swallow messages meant for a later turn.
469
+ const liveInput = startLiveInput({ harness, options, onEvent });
923
470
 
924
471
  // Re-check abort right before issuing the provider request. The abort
925
472
  // handler is only installed at ~:639, AFTER a long stretch of awaited setup
@@ -929,65 +476,38 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
929
476
  // target. Without this re-check a full provider/LLM request would be issued
930
477
  // for a run the caller already aborted (mirrors the entry pre-check at ~:356).
931
478
  if (options.abortSignal?.aborted) {
932
- // Drop a freshly-created non-keep-alive session so an aborted-before-run
933
- // turn does not leave an orphan jsonl on disk. Guarded `session &&
934
- // !sessionEntry` so a resumed (user-owned) session is NEVER deleted —
935
- // identical to the outer catch guard. The finally block clears
936
- // sessionEntry.busy, removes the abort handler, and closes MCP clients.
937
- // For a resume no transcript was appended yet (prompt never ran), so the
938
- // live session is already at its pre-turn leaf and needs no rollback.
939
- if (session && !sessionEntry) {
940
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
941
- }
942
- // Drop the create-on-miss BUSY reservation too. The finally only clears
943
- // sessionEntry.busy (null here), so without this the busy placeholder leaks
944
- // and every future resume of this conversation's stable id returns
945
- // session_busy forever (busy entries are never idle-evicted). Mirrors the
946
- // lifecycle drop branch + outer catch cleanup.
947
- if (createdOnMissPlaceholder) nativeSessions.delete(providerSessionId);
479
+ // Drop a freshly-created non-keep-alive session (and any create-on-miss
480
+ // reservation) so an aborted-before-run turn does not leave an orphan
481
+ // jsonl / leaked busy placeholder. discardUncommittedSession guards
482
+ // `session && !sessionEntry` so a resumed (user-owned) session is NEVER
483
+ // deleted; the finally clears sessionEntry.busy, removes the abort handler,
484
+ // and closes MCP clients. For a resume no transcript was appended yet
485
+ // (prompt never ran), so the live session needs no rollback.
486
+ await discardUncommittedSession(runState, { durableRepo });
948
487
  return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
949
488
  }
950
489
 
951
- // Compaction policy against the LIVE model's context window (auto-recognized
952
- // from the model actually serving the request, lowered by any ceiling learned
953
- // from a prior overflow). Drives the proactive trigger + reactive recovery.
954
- compactionPolicy = resolveAgentCompactionPolicy(
955
- options.settings || {},
956
- { contextWindow: effectiveContextWindow(harness, runtime, resolved) },
957
- );
958
-
959
- // Proactive compaction: if the session is already near the window, compact
960
- // BEFORE issuing the request so a long-lived session never overflows.
961
- if (compactionPolicy.enabled && compactionPolicy.contextWindow > 0 && !options.abortSignal?.aborted) {
962
- const est = await estimateCurrentContextTokens(session);
963
- if (est.tokens >= compactionPolicy.triggerTokens) {
964
- await harness.waitForIdle();
965
- if (!options.abortSignal?.aborted) {
966
- const res = await tryCompact(harness, {
967
- trigger: "proactive",
968
- onEvent,
969
- runtimeWarnings,
970
- onCompactionRecorded: options.onCompactionRecorded,
971
- runId: options.runId,
972
- model: reference,
973
- });
974
- if (res.applied) {
975
- contextCompactionApplied = true;
976
- contextCompactedThisRun = true;
977
- Object.assign(contextCompactionDiagnostics, {
978
- context_compaction_proactive: true,
979
- context_compaction_tokens_before: res.tokensBefore,
980
- context_compaction_estimate_source: est.source,
981
- context_window: compactionPolicy.contextWindow,
982
- });
983
- // Compaction collapses the transcript prefix, so the pre-run baseline
984
- // no longer aligns. Re-anchor it to the compacted length so the run's
985
- // own turns (issued next) slice out correctly in captureState.
986
- sessionBaselineCount = (await session.buildContext()).messages.length;
987
- }
988
- }
989
- }
990
- }
490
+ // Compaction policy against the LIVE model's context window, then proactive
491
+ // compaction: if the session is already near the window, compact BEFORE
492
+ // issuing the request so a long-lived session never overflows.
493
+ runState.compaction.policy = resolveLiveCompactionPolicy({
494
+ harness,
495
+ runtime,
496
+ resolved,
497
+ settings: settingsLike,
498
+ contextWindowOverride: options.compaction?.contextWindowOverride,
499
+ });
500
+ await runProactiveCompaction(runState, {
501
+ harness,
502
+ systemPrompt,
503
+ options,
504
+ tools,
505
+ promptText,
506
+ promptImages,
507
+ reference,
508
+ onEvent,
509
+ runtimeWarnings,
510
+ });
991
511
 
992
512
  onEvent({
993
513
  type: "provider_request_started",
@@ -997,39 +517,18 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
997
517
  timestamp: Date.now(),
998
518
  });
999
519
 
1000
- let runError = null;
1001
- try {
1002
- // Pass structured images (when present) so multimodal input reaches the
1003
- // model as image blocks rather than stringified text. AgentHarness.prompt
1004
- // takes them under an options object (`{ images }`); a bare array would be
1005
- // read as `options` and silently dropped (options?.images === undefined).
1006
- if (Array.isArray(promptImages) && promptImages.length > 0) {
1007
- await harness.prompt(promptText, { images: promptImages });
1008
- } else {
1009
- await harness.prompt(promptText);
1010
- }
1011
- } catch (err) {
1012
- runError = err;
1013
- }
1014
- await harness.waitForIdle();
520
+ let { runError } = await runHarnessPrompt(harness, promptText, promptImages);
1015
521
 
1016
522
  // The run is done: stop the live-steering consumer so it cannot steer a
1017
- // finished harness or swallow a follow-up meant for the next turn. We signal
1018
- // completion, then best-effort return() the iterator to unblock a pending
1019
- // next(). We do NOT await the task (it could block on next() if the source
1020
- // has no return()), but the runComplete guard prevents any further steering.
1021
- runComplete = true;
1022
- if (liveInputIterator && typeof liveInputIterator.return === "function") {
1023
- try { await liveInputIterator.return(); } catch { /* best-effort */ }
1024
- }
1025
- void liveInputTask;
523
+ // finished harness or swallow a follow-up meant for the next turn.
524
+ await liveInput.stop();
1026
525
 
1027
- externalAbort ||= !!options.abortSignal?.aborted;
526
+ runState.externalAbort ||= !!options.abortSignal?.aborted;
1028
527
 
1029
528
  const captureState = async () => {
1030
- const context = await session.buildContext();
529
+ const context = await runState.session.buildContext();
1031
530
  const transcript = context.messages || [];
1032
- const runTranscript = transcript.slice(sessionBaselineCount);
531
+ const runTranscript = transcript.slice(runState.sessionBaselineCount);
1033
532
  const assistantMessages = runTranscript.filter((message) => message?.role === "assistant");
1034
533
  const lastAssistant = assistantMessages[assistantMessages.length - 1] || null;
1035
534
  return {
@@ -1038,8 +537,8 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
1038
537
  assistantMessages,
1039
538
  lastAssistant,
1040
539
  stopReason: lastAssistant?.stopReason || null,
1041
- finalText: textFromContent(lastAssistant?.content) || assistantTexts.join(""),
1042
- finalThinking: thinkingFromContent(lastAssistant?.content) || assistantThinking.join(""),
540
+ finalText: textFromContent(lastAssistant?.content) || runState.assistantTexts.join(""),
541
+ finalThinking: thinkingFromContent(lastAssistant?.content) || runState.assistantThinking.join(""),
1043
542
  };
1044
543
  };
1045
544
 
@@ -1052,100 +551,36 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
1052
551
  // followUp + setActiveTools instead of the low-level agent.continue() loop.
1053
552
  if (!runError && shouldRetryStructuredOutputFinalization({
1054
553
  outputSchema: options.outputSchema,
1055
- structuredResult,
554
+ structuredResult: runState.structuredResult,
1056
555
  finalText: state.finalText,
1057
556
  stopReason: state.stopReason,
1058
- externalAbort,
1059
- maxTurnsHit,
557
+ externalAbort: runState.externalAbort,
558
+ maxTurnsHit: runState.maxTurnsHit,
1060
559
  })) {
1061
- structuredOutputFinalizationRetryAttempts = 1;
1062
- structuredOutputFinalizationRetryReason = "empty_final_output";
1063
- runtimeWarnings.push({
1064
- warning_kind: "structured_output_finalization_retry",
1065
- source: "pi",
1066
- reason: structuredOutputFinalizationRetryReason,
1067
- message: "Pi stopped without text or structured output; retrying once in the same session with only StructuredOutput enabled.",
1068
- });
1069
- const previousActive = harness.getActiveTools().map((toolDef) => toolDef.name);
1070
- try {
1071
- // The harness is idle after waitForIdle(), so re-prompt (not followUp,
1072
- // which only queues onto an active run) with only StructuredOutput
1073
- // active. This re-prompts ONCE in the same session, matching the legacy
1074
- // single agent.continue() finalization re-prompt.
1075
- await harness.setActiveTools(structuredTool ? [structuredTool.name] : []);
1076
- await harness.prompt(structuredOutputFinalizationPrompt());
1077
- await harness.waitForIdle();
1078
- } catch (err) {
1079
- runtimeWarnings.push({
1080
- warning_kind: "structured_output_finalization_retry_failed",
1081
- source: "pi",
1082
- message: err?.message || String(err),
1083
- });
1084
- } finally {
1085
- try { await harness.setActiveTools(previousActive); } catch { /* best-effort */ }
1086
- }
1087
- structuredOutputFinalizationRetryFailed = structuredResult === null || structuredResult === undefined;
560
+ const retry = await runStructuredOutputFinalizationRetry({ harness, structuredTool, runtimeWarnings, prompts: options.prompts });
561
+ structuredOutputFinalizationRetryAttempts = retry.attempts;
562
+ structuredOutputFinalizationRetryReason = retry.reason;
563
+ structuredOutputFinalizationRetryFailed = runState.structuredResult === null || runState.structuredResult === undefined;
1088
564
  state = await captureState();
1089
565
  }
1090
566
 
1091
567
  // Reactive recovery: if the turn ended in a context overflow and we have not
1092
- // already compacted-and-retried this run, compact once and re-prompt once.
1093
- if (
1094
- compactionPolicy?.enabled
1095
- && !contextCompactionReactiveAttempted
1096
- && !externalAbort
1097
- && !maxTurnsHit
1098
- && !options.abortSignal?.aborted
1099
- ) {
1100
- const provisionalRaw = state.stopReason === "error" || state.stopReason === "aborted"
1101
- ? state.lastAssistant?.errorMessage || runError?.message || null
1102
- : (runError ? runError.message || String(runError) : null);
1103
- const provisionalError = normalizePiErrorMessage(provisionalRaw);
1104
- if (provisionalError && isReactiveCompactionCandidate(provisionalError, contextCompactionDiagnostics)) {
1105
- contextCompactionReactiveAttempted = true;
1106
- // Learn the real ceiling from the error so future runs trigger
1107
- // proactively at it even when the configured contextWindow was wrong.
1108
- recordDiscoveredContextWindow(harness, runtime, resolved, parseContextLimitFromError(provisionalError));
1109
- // A second compaction immediately after a fresh proactive one is almost
1110
- // always "nothing to compact"; skip it and surface the original error.
1111
- if (!contextCompactedThisRun) {
1112
- await harness.waitForIdle();
1113
- const res = await tryCompact(harness, {
1114
- trigger: "reactive_overflow",
1115
- onEvent,
1116
- runtimeWarnings,
1117
- onCompactionRecorded: options.onCompactionRecorded,
1118
- runId: options.runId,
1119
- model: reference,
1120
- });
1121
- if (res.applied) {
1122
- contextCompactionApplied = true;
1123
- contextCompactedThisRun = true;
1124
- Object.assign(contextCompactionDiagnostics, {
1125
- context_compaction_reactive: true,
1126
- context_compaction_tokens_before: res.tokensBefore,
1127
- });
1128
- // Re-anchor the transcript baseline to the compacted length so the
1129
- // re-prompt's turn (and its stopReason/usage) slices out correctly.
1130
- sessionBaselineCount = (await session.buildContext()).messages.length;
1131
- // Re-prompt ONCE in the now-compacted session. The trailing user turn
1132
- // is already persisted, so a fresh prompt continues against it.
1133
- runError = null;
1134
- try {
1135
- if (Array.isArray(promptImages) && promptImages.length > 0) {
1136
- await harness.prompt(promptText, { images: promptImages });
1137
- } else {
1138
- await harness.prompt(promptText);
1139
- }
1140
- } catch (err) {
1141
- runError = err;
1142
- }
1143
- await harness.waitForIdle();
1144
- state = await captureState();
1145
- }
1146
- }
1147
- }
1148
- }
568
+ // already compacted-and-retried this run, compact once and re-prompt once
569
+ // (and re-capture state). Learns the real window ceiling from the error.
570
+ ({ state, runError } = await runReactiveCompaction(runState, {
571
+ harness,
572
+ runtime,
573
+ resolved,
574
+ options,
575
+ promptText,
576
+ promptImages,
577
+ reference,
578
+ onEvent,
579
+ runtimeWarnings,
580
+ state,
581
+ runError,
582
+ captureState,
583
+ }));
1149
584
 
1150
585
  const { runTranscript, lastAssistant, stopReason, finalText, finalThinking } = state;
1151
586
  const runAssistantCount = state.assistantMessages.length;
@@ -1159,75 +594,47 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
1159
594
  cachedTokens: usage.cacheRead,
1160
595
  cacheWriteTokens: usage.cacheWrite,
1161
596
  });
1162
- if (usage.cacheRead > 0) {
1163
- onEvent({ type: "cache_hit", sdk: resolved.sdk, model: reference, tokens: usage.cacheRead, source: "prompt_cache" });
1164
- }
1165
- if (usage.cacheWrite > 0) {
1166
- onEvent({ type: "cache_miss", sdk: resolved.sdk, model: reference, tokens: usage.cacheWrite, source: "prompt_cache" });
1167
- }
1168
- onEvent({
1169
- type: "cost_accumulated",
1170
- sdk: resolved.sdk,
1171
- model: reference,
1172
- cumulativeUsd: Number(usage.cost) || Number(estimatedCost) || 0,
1173
- tokens: {
1174
- input: Number(usage.input) || 0,
1175
- output: Number(usage.output) || 0,
1176
- cacheReadTokens: Number(usage.cacheRead) || 0,
1177
- cacheCreationTokens: Number(usage.cacheWrite) || 0,
1178
- },
1179
- });
1180
- onEvent({
1181
- type: "provider_request_completed",
1182
- sdk: resolved.sdk,
1183
- model: reference,
1184
- runtime: "pi",
1185
- timestamp: Date.now(),
1186
- durationMs: Date.now() - start,
1187
- cancelled: externalAbort,
1188
- });
597
+ emitUsageCostEvents({ onEvent, resolved, reference, usage, estimatedCost, start, externalAbort: runState.externalAbort });
1189
598
 
1190
- const rawErrorMessage = externalAbort
599
+ const rawErrorMessage = runState.externalAbort
1191
600
  ? null
1192
- : maxTurnsHit
601
+ : runState.maxTurnsHit
1193
602
  ? "Pi agent stopped before final output: max turns reached"
1194
603
  : (stopReason === "error" || stopReason === "aborted"
1195
604
  ? lastAssistant?.errorMessage || runError?.message || "Pi agent aborted before final output"
1196
605
  : (runError ? runError.message || String(runError) : null));
1197
606
  const errorMessage = normalizePiErrorMessage(rawErrorMessage);
1198
607
 
1199
- const diagnostics = {
1200
- provider_session_id: providerSessionId,
1201
- pi_stop_reason: stopReason,
1202
- pi_engine: "native",
1203
- max_turns_hit: maxTurnsHit,
1204
- max_turns: Number.isFinite(Number(options.maxTurns)) ? Number(options.maxTurns) : null,
1205
- turn_count: turnCount || runAssistantCount,
1206
- external_abort: externalAbort,
1207
- pi_max_retries: maxRetries,
1208
- ...(lastToolName ? { last_tool_name: lastToolName } : {}),
1209
- ...structuredOutputRetryDiagnostics(
1210
- structuredOutputFinalizationRetryAttempts,
1211
- structuredOutputFinalizationRetryReason,
1212
- structuredOutputFinalizationRetryFailed,
1213
- ),
1214
- ...contextCompactionDiagnostics,
608
+ const structuredRetry = {
609
+ attempts: structuredOutputFinalizationRetryAttempts,
610
+ reason: structuredOutputFinalizationRetryReason,
611
+ failed: structuredOutputFinalizationRetryFailed,
1215
612
  };
1216
- const errorDetails = errorMessage ? {
1217
- pi_stop_reason: stopReason || "error",
1218
- last_tool_name: lastToolName,
1219
- tool_results_seen: toolResultsSeen,
1220
- turn_count: turnCount || runAssistantCount,
1221
- max_turns_hit: maxTurnsHit,
1222
- provider_session_id: providerSessionId,
1223
- pi_engine: "native",
1224
- ...structuredOutputRetryDiagnostics(
1225
- structuredOutputFinalizationRetryAttempts,
1226
- structuredOutputFinalizationRetryReason,
1227
- structuredOutputFinalizationRetryFailed,
1228
- ),
1229
- ...contextCompactionDiagnostics,
1230
- } : null;
613
+ const diagnostics = buildDiagnostics({
614
+ providerSessionId,
615
+ stopReason,
616
+ maxTurnsHit: runState.maxTurnsHit,
617
+ maxTurns: options.maxTurns,
618
+ turnCount: runState.turnCount,
619
+ runAssistantCount,
620
+ externalAbort: runState.externalAbort,
621
+ maxRetries,
622
+ lastToolName: runState.lastToolName,
623
+ structuredRetry,
624
+ contextCompactionDiagnostics: runState.compaction.diagnostics,
625
+ });
626
+ const errorDetails = buildErrorDetails({
627
+ errorMessage,
628
+ stopReason,
629
+ lastToolName: runState.lastToolName,
630
+ toolResultsSeen: runState.toolResultsSeen,
631
+ turnCount: runState.turnCount,
632
+ runAssistantCount,
633
+ maxTurnsHit: runState.maxTurnsHit,
634
+ providerSessionId,
635
+ structuredRetry,
636
+ contextCompactionDiagnostics: runState.compaction.diagnostics,
637
+ });
1231
638
 
1232
639
  const capabilitiesUsed = buildCapabilitiesUsed({
1233
640
  promptCacheActive: usage.cacheRead > 0 || usage.cacheWrite > 0,
@@ -1239,199 +646,101 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
1239
646
  toolCompactionApplied: toolCompactionAppliedFromWarnings(runtimeWarnings),
1240
647
  // Tristate: true = a compaction fired this run (proactive or reactive),
1241
648
  // false = the path is enabled but did not need to fire, null = disabled via
1242
- // agent_compaction_enabled. See docs/feature-registry.md runtime.context-compaction.
1243
- contextCompactionApplied: compactionPolicy?.enabled ? contextCompactionApplied : null,
649
+ // agent_compaction_enabled. See docs/reference/feature-registry.md runtime.context-compaction.
650
+ contextCompactionApplied: runState.compaction.policy?.enabled ? runState.compaction.applied : null,
1244
651
  });
1245
- onEvent({ type: "capabilities_resolved", sdk: resolved.sdk, model: reference, capabilitiesUsed });
652
+ emitCapabilitiesResolved(onEvent, { sdk: resolved.sdk, model: reference, capabilitiesUsed });
1246
653
 
1247
654
  // Re-check the abort signal: a cancel can land during the post-run work above
1248
655
  // (live-input teardown, structured-output finalization retry) after the line
1249
656
  // ~780 check. Pick it up here so the lifecycle decision below rolls back the
1250
657
  // cancelled turn instead of committing it into a durable transcript a later
1251
658
  // resume would replay.
1252
- externalAbort ||= !!options.abortSignal?.aborted;
659
+ runState.externalAbort ||= !!options.abortSignal?.aborted;
1253
660
 
1254
661
  // Session lifecycle parity with the legacy bridge. The harness already
1255
662
  // durably persisted the transcript into its session object (in-memory for
1256
- // the default repo, jsonl on disk when piSessionsRoot is set); the registry
663
+ // the default repo, jsonl on disk when piSessionsRoot is set); commitSession
1257
664
  // tracks LIVENESS so disposeProviderSession / idle-TTL eviction can reach
1258
- // native sessions, and a resume miss reports session_not_found.
1259
- if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
1260
- try {
1261
- if (sessionEntry) {
1262
- // Resumed run: the harness appended this run's turns onto the live
1263
- // session; just re-arm the idle window.
1264
- nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
1265
- // Surface a write failure the harness swallowed: a session that can
1266
- // no longer persist must not pretend to be resumable.
1267
- await session.buildContext();
1268
- } else {
1269
- const metadata = await session.getMetadata();
1270
- nativeSessions.set(providerSessionId, {
1271
- session,
1272
- metadata,
1273
- repo: durableRepo || nativeSessionRepo,
1274
- durable: !!durableRepo,
1275
- busy: false,
1276
- }, { idleTimeoutMs: sessionTtlMs });
1277
- }
1278
- } catch (err) {
1279
- // Session persistence must never fail the run; drop the (now
1280
- // inconsistent) session instead of resuming from a broken transcript.
1281
- onEvent({
1282
- type: "runtime_warning",
1283
- warning_kind: "pi_session_persist_failed",
1284
- message: err?.message || String(err),
1285
- });
1286
- nativeSessions.delete(providerSessionId);
1287
- if (requestedSessionId) nativeSessions.delete(requestedSessionId);
1288
- const broken = sessionEntry;
1289
- if (broken) {
1290
- try { await broken.repo.delete(broken.metadata); } catch { /* best-effort */ }
1291
- }
1292
- }
1293
- } else if (sessionEntry) {
1294
- // Resumed run that errored (or was aborted): roll the live session back to
1295
- // the leaf captured before this turn so the failed turn never leaks into a
1296
- // later resume. The next resume then sees the last good transcript. The
1297
- // entry stays live (busy is cleared in finally) and its idle TTL re-arms.
1298
- if (baselineLeafId && (errorMessage || externalAbort)) {
1299
- try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
1300
- }
1301
- nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
1302
- } else {
1303
- // Fresh, non-keep-alive (or failed first) run: never leave a live session
1304
- // behind. A durable jsonl transcript on disk is dropped too, matching the
1305
- // legacy default contract that a non-keep-alive run is not resumable.
1306
- // A create-on-miss BUSY placeholder (R8) is keyed under
1307
- // requestedSessionId === providerSessionId; drop it here too so a
1308
- // non-keep-alive / errored / aborted first turn never leaks a busy entry
1309
- // (the success keep-alive path overwrites it with the finalized entry, so
1310
- // it is only this drop branch that must clean it up).
1311
- if (createdOnMissPlaceholder) nativeSessions.delete(providerSessionId);
1312
- try {
1313
- await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
1314
- } catch { /* best-effort */ }
1315
- }
665
+ // native sessions, keep-alive registers the session, and a failed/aborted
666
+ // resumed turn rolls back to its pre-turn leaf.
667
+ await commitSession(runState, {
668
+ options,
669
+ requestedSessionId,
670
+ providerSessionId,
671
+ durableRepo,
672
+ sessionTtlMs,
673
+ externalAbort: runState.externalAbort,
674
+ errorMessage,
675
+ onEvent,
676
+ });
1316
677
 
1317
678
  // Final abort guard (durable cancel TOCTOU): if a cancel raced the lifecycle
1318
679
  // commit above — landing AFTER the keep-alive/!externalAbort decision but
1319
680
  // before this return — the cancelled turn is still in the durable transcript
1320
681
  // and (for keep-alive) the live registry. Roll it back so the next resume sees
1321
- // the pre-turn state: a resumed session moves to its baseline leaf and drops
1322
- // its live entry; a fresh durable session deletes its jsonl. There is no await
1323
- // between here and the return, so an external cancel cannot newly fire past it.
1324
- if (!externalAbort && options.abortSignal?.aborted) {
1325
- externalAbort = true;
1326
- if (sessionEntry) {
1327
- if (baselineLeafId) {
1328
- try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
1329
- }
1330
- nativeSessions.delete(requestedSessionId);
1331
- } else {
1332
- nativeSessions.delete(providerSessionId);
1333
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
1334
- }
682
+ // the pre-turn state (rollbackAbortedTurn: a resumed session moves to its
683
+ // baseline leaf and drops its live entry; a fresh durable session deletes its
684
+ // jsonl). The abort re-check + return stay inline with NO await between the
685
+ // false-branch check and the return, so an external cancel cannot newly fire
686
+ // past it (I10).
687
+ if (!runState.externalAbort && options.abortSignal?.aborted) {
688
+ runState.externalAbort = true;
689
+ await rollbackAbortedTurn(runState, { requestedSessionId, providerSessionId, durableRepo });
1335
690
  }
1336
691
 
1337
- return {
1338
- text: finalText,
1339
- thinking: finalThinking,
692
+ return buildSuccessResult({
693
+ finalText,
694
+ finalThinking,
1340
695
  events,
1341
- usage: {
1342
- input_tokens: usage.input || null,
1343
- output_tokens: usage.output || null,
1344
- cache_read_tokens: usage.cacheRead || null,
1345
- cache_creation_tokens: usage.cacheWrite || null,
1346
- cache_write_tokens: usage.cacheWrite || null,
1347
- cost_usd: usage.cost || estimatedCost,
1348
- },
1349
- durationMs: Date.now() - start,
1350
- numTurns: turnCount || runAssistantCount,
1351
- model: resolved.reference || `pi:${resolved.provider}:${resolved.model}`,
1352
- effort: options.effort || null,
1353
- sdk: resolved.sdk,
1354
- cancelled: externalAbort,
1355
- error: errorMessage,
696
+ usage,
697
+ estimatedCost,
698
+ start,
699
+ turnCount: runState.turnCount,
700
+ runAssistantCount,
701
+ resolved,
702
+ options,
703
+ externalAbort: runState.externalAbort,
704
+ errorMessage,
1356
705
  errorDetails,
1357
- failureKind: errorMessage
1358
- ? failureKindForPiError(errorMessage, diagnostics, { maxTurnsHit })
1359
- : null,
706
+ diagnostics,
707
+ maxTurnsHit: runState.maxTurnsHit,
1360
708
  providerSessionId,
1361
709
  runtimeWarnings,
1362
- diagnostics,
1363
710
  capabilitiesUsed,
1364
- ...(structuredResult !== null && structuredResult !== undefined
1365
- ? { structuredResult, structuredResultSource: "StructuredOutput" }
1366
- : { structuredResult: undefined, structuredResultSource: null }),
1367
- };
711
+ structuredResult: runState.structuredResult,
712
+ });
1368
713
  } catch (err) {
1369
- externalAbort ||= !!options.abortSignal?.aborted;
1370
- // Drop a just-created FRESH durable session so a setup/run failure does not
1371
- // leave a resumable orphan jsonl on disk (the success path drops it at ~905;
1372
- // the catch must mirror that). Guarded: `session && !sessionEntry` fires only
1373
- // for fresh runs that actually created a session NEVER for resumes
1374
- // (sessionEntry is non-null only on resume; deleting a resumed user session
1375
- // here would be data loss) and never when the throw preceded session create.
1376
- if (session && !sessionEntry) {
1377
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
1378
- }
1379
- // Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
1380
- // during/after the reservation — including a throw inside the create await
1381
- // itself, where `session` is still null so the jsonl-delete above is skipped.
1382
- // Keyed under requestedSessionId === providerSessionId; never set on a resume
1383
- // (sessionEntry would be non-null), so this never deletes a live user session.
1384
- if (createdOnMissPlaceholder && !sessionEntry) nativeSessions.delete(providerSessionId);
1385
- // Resumed-session rollback for host/runtime-side throws (e.g. a throwing
1386
- // custom pricing resolver / bridge event callback) that land here AFTER the
1387
- // harness already mutated the live session. Mirrors the success-path
1388
- // rollback at ~:925-932: move the live session back to the pre-turn leaf so
1389
- // the failed turn never leaks into a later resume. Gated on `sessionEntry &&
1390
- // baselineLeafId` so it only fires for resumes that captured a baseline.
1391
- if (sessionEntry && baselineLeafId) {
1392
- try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
1393
- }
714
+ runState.externalAbort ||= !!options.abortSignal?.aborted;
715
+ // Drop a just-created fresh durable session, release a create-on-miss
716
+ // reservation placeholder, and roll a resumed session back to its pre-turn
717
+ // leaf for host/runtime-side throws that landed after the harness already
718
+ // mutated the live session (guards preserved in cleanupSessionOnThrow).
719
+ await cleanupSessionOnThrow(runState, { durableRepo });
1394
720
  const errorMessage = normalizePiErrorMessage(err?.message || String(err));
1395
721
  const isRetryable = retryableProviderFailureInfo({
1396
722
  errorText: errorMessage,
1397
723
  failureKind: "provider_unavailable",
1398
724
  }).retryable;
1399
- return {
1400
- text: assistantTexts.join("") || null,
725
+ return buildErrorResult({
726
+ assistantTexts: runState.assistantTexts,
1401
727
  events,
1402
- usage: {},
1403
- durationMs: Date.now() - start,
1404
- numTurns: turnCount,
1405
- model: resolved?.reference || resolved?.model || null,
1406
- effort: options.effort || null,
1407
- sdk: resolved?.sdk || "pi",
1408
- cancelled: externalAbort,
1409
- error: externalAbort ? null : errorMessage,
1410
- errorDetails: externalAbort ? null : {
1411
- pi_stop_reason: "error",
1412
- last_tool_name: lastToolName,
1413
- tool_results_seen: toolResultsSeen,
1414
- turn_count: turnCount,
1415
- max_turns_hit: maxTurnsHit,
1416
- provider_session_id: providerSessionId,
1417
- pi_engine: "native",
1418
- pi_error_retryable: isRetryable,
1419
- },
1420
- failureKind: externalAbort ? null : failureKindForPiError(errorMessage, {}, { maxTurnsHit }),
728
+ start,
729
+ turnCount: runState.turnCount,
730
+ resolved,
731
+ options,
732
+ externalAbort: runState.externalAbort,
733
+ errorMessage,
734
+ lastToolName: runState.lastToolName,
735
+ toolResultsSeen: runState.toolResultsSeen,
736
+ maxTurnsHit: runState.maxTurnsHit,
1421
737
  providerSessionId,
1422
738
  runtimeWarnings,
1423
- diagnostics: {
1424
- provider_session_id: providerSessionId,
1425
- pi_stop_reason: externalAbort ? "aborted" : "error",
1426
- pi_engine: "native",
1427
- max_turns_hit: maxTurnsHit,
1428
- turn_count: turnCount,
1429
- external_abort: externalAbort,
1430
- },
1431
- };
739
+ isRetryable,
740
+ });
1432
741
  } finally {
1433
- if (sessionEntry) sessionEntry.busy = false;
1434
- removeAbortHandler?.();
742
+ if (runState.sessionEntry) runState.sessionEntry.busy = false;
743
+ runState.removeAbortHandler?.();
1435
744
  await closePiMcpClients(mcpClients);
1436
745
  }
1437
746
  }