@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.
Files changed (123) hide show
  1. package/ARCHITECTURE.md +75 -9
  2. package/MIGRATION.md +293 -0
  3. package/README.md +46 -20
  4. package/package.json +104 -10
  5. package/src/agent/allowlists.js +3 -0
  6. package/src/agent/approval.js +3 -0
  7. package/src/agent/compaction.js +231 -654
  8. package/src/agent/index.js +1 -1
  9. package/src/agent/prompt/skill-index.js +6 -0
  10. package/src/agent/sandbox-seam.js +227 -0
  11. package/src/agent/tool-bloat.js +8 -2
  12. package/src/agent/tools/bash.js +16 -8
  13. package/src/agent/tools/edit.js +7 -3
  14. package/src/agent/tools/glob.js +11 -5
  15. package/src/agent/tools/grep.js +11 -5
  16. package/src/agent/tools/pi-bridge.js +272 -54
  17. package/src/agent/tools/read.js +28 -3
  18. package/src/agent/tools/shared/output-truncation.js +8 -3
  19. package/src/agent/tools/shared/path-resolver.js +24 -18
  20. package/src/agent/tools/shared/ripgrep.js +33 -6
  21. package/src/agent/tools/shared/runtime-context.js +60 -50
  22. package/src/agent/tools/shared/tool-context.js +157 -0
  23. package/src/agent/tools/web-fetch.js +65 -18
  24. package/src/agent/tools/web-search.js +11 -4
  25. package/src/agent/tools/write.js +7 -3
  26. package/src/agent/transcript.js +16 -1
  27. package/src/ai/cost.js +128 -3
  28. package/src/ai/failure.js +96 -4
  29. package/src/ai/index.js +1 -0
  30. package/src/ai/live-input-prompt.js +11 -1
  31. package/src/ai/providers/claude-cli.js +6 -2
  32. package/src/ai/providers/claude-sdk.js +55 -12
  33. package/src/ai/providers/codex-app.js +36 -23
  34. package/src/ai/providers/opencode-app.js +2 -2
  35. package/src/ai/providers/opencode-discovery.js +2 -2
  36. package/src/ai/providers/pi-errors.js +65 -0
  37. package/src/ai/providers/pi-events.js +5 -0
  38. package/src/ai/providers/pi-models.js +21 -4
  39. package/src/ai/providers/pi-native/compaction-driver.js +394 -0
  40. package/src/ai/providers/pi-native/result-builder.js +312 -0
  41. package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
  42. package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
  43. package/src/ai/providers/pi-native/structured-output.js +130 -0
  44. package/src/ai/providers/pi-native/turn-runner.js +278 -0
  45. package/src/ai/providers/pi-native.js +754 -0
  46. package/src/ai/runtime/capabilities.js +3 -0
  47. package/src/ai/runtime/model-refs.js +30 -0
  48. package/src/ai/runtime/registry.js +33 -2
  49. package/src/ai/runtime/router.js +119 -19
  50. package/src/ai/runtime/session-liveness.js +110 -0
  51. package/src/ai/runtime/sessions.js +19 -2
  52. package/src/ai/types.js +252 -20
  53. package/src/index.js +1 -1
  54. package/src/pi-auth.js +147 -16
  55. package/src/runtime-brand.js +21 -1
  56. package/src/runtime.js +75 -10
  57. package/types/agent/allowlists.d.ts +25 -0
  58. package/types/agent/approval.d.ts +30 -0
  59. package/types/agent/compaction.d.ts +97 -0
  60. package/types/agent/index.d.ts +5 -0
  61. package/types/agent/prompt/skill-index.d.ts +19 -0
  62. package/types/agent/sandbox-seam.d.ts +148 -0
  63. package/types/agent/tool-bloat.d.ts +22 -0
  64. package/types/agent/tools/bash.d.ts +16 -0
  65. package/types/agent/tools/edit.d.ts +14 -0
  66. package/types/agent/tools/glob.d.ts +16 -0
  67. package/types/agent/tools/grep.d.ts +22 -0
  68. package/types/agent/tools/index.d.ts +10 -0
  69. package/types/agent/tools/pi-bridge.d.ts +144 -0
  70. package/types/agent/tools/read.d.ts +19 -0
  71. package/types/agent/tools/shared/constants.d.ts +13 -0
  72. package/types/agent/tools/shared/dedup.d.ts +8 -0
  73. package/types/agent/tools/shared/output-truncation.d.ts +22 -0
  74. package/types/agent/tools/shared/path-resolver.d.ts +6 -0
  75. package/types/agent/tools/shared/ripgrep.d.ts +38 -0
  76. package/types/agent/tools/shared/runtime-context.d.ts +27 -0
  77. package/types/agent/tools/shared/tool-context.d.ts +48 -0
  78. package/types/agent/tools/web-fetch.d.ts +13 -0
  79. package/types/agent/tools/web-search.d.ts +11 -0
  80. package/types/agent/tools/write.d.ts +12 -0
  81. package/types/agent/transcript.d.ts +45 -0
  82. package/types/ai/backend.d.ts +41 -0
  83. package/types/ai/cost.d.ts +96 -0
  84. package/types/ai/failure.d.ts +117 -0
  85. package/types/ai/file-change-stats.d.ts +75 -0
  86. package/types/ai/index.d.ts +7 -0
  87. package/types/ai/live-input-prompt.d.ts +6 -0
  88. package/types/ai/observer.d.ts +68 -0
  89. package/types/ai/providers/claude-cli.d.ts +211 -0
  90. package/types/ai/providers/claude-sdk.d.ts +66 -0
  91. package/types/ai/providers/claude-subagents.d.ts +2 -0
  92. package/types/ai/providers/codex-app.d.ts +151 -0
  93. package/types/ai/providers/opencode-app.d.ts +95 -0
  94. package/types/ai/providers/opencode-discovery.d.ts +4 -0
  95. package/types/ai/providers/pi-errors.d.ts +3 -0
  96. package/types/ai/providers/pi-events.d.ts +24 -0
  97. package/types/ai/providers/pi-messages.d.ts +5 -0
  98. package/types/ai/providers/pi-models.d.ts +57 -0
  99. package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
  100. package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
  101. package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
  102. package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
  103. package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
  104. package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
  105. package/types/ai/providers/pi-native.d.ts +18 -0
  106. package/types/ai/registry.d.ts +1 -0
  107. package/types/ai/runtime/capabilities-used.d.ts +21 -0
  108. package/types/ai/runtime/capabilities.d.ts +33 -0
  109. package/types/ai/runtime/context-windows.d.ts +8 -0
  110. package/types/ai/runtime/fast-mode.d.ts +2 -0
  111. package/types/ai/runtime/model-refs.d.ts +35 -0
  112. package/types/ai/runtime/registry.d.ts +38 -0
  113. package/types/ai/runtime/router.d.ts +56 -0
  114. package/types/ai/runtime/session-liveness.d.ts +55 -0
  115. package/types/ai/runtime/sessions.d.ts +38 -0
  116. package/types/ai/streaming/codex-events.d.ts +30 -0
  117. package/types/ai/streaming/opencode-events.d.ts +41 -0
  118. package/types/ai/types.d.ts +702 -0
  119. package/types/index.d.ts +7 -0
  120. package/types/pi-auth.d.ts +28 -0
  121. package/types/runtime-brand.d.ts +30 -0
  122. package/types/runtime.d.ts +10 -0
  123. package/src/ai/providers/pi-sdk.js +0 -1310
@@ -0,0 +1,754 @@
1
+ // Pi-NATIVE runtime bridge.
2
+ //
3
+ // This is the SOLE pi runtime path: the hand-rolled pi bridge (formerly
4
+ // pi-sdk.js, driving the low-level `Agent` with manual MCP init, transcript
5
+ // handling, compaction, a hand-rolled stream-retry loop, and session
6
+ // bookkeeping) was removed once this bridge reached parity. This bridge builds
7
+ // on pi-agent-core's high-level AgentHarness plus native primitives:
8
+ //
9
+ // * AgentHarness OWNS a session and performs durable writes itself, so resume
10
+ // is "open the session from a repo and hand it to a new harness". There is
11
+ // no separate live-session registry here.
12
+ // * The provider transport (pi-ai streamSimple) is invoked by the harness;
13
+ // retry/backoff is delegated to pi-ai via streamOptions.maxRetries instead
14
+ // of the legacy manual loop.
15
+ // * Tool sandboxing, approval gates, allowlist/bloat filtering, and the MCP
16
+ // tool bridge are reused shared pieces — they are wired into the harness
17
+ // via its `tools` option, never reimplemented.
18
+ //
19
+ // The result/event contract is the package's unified runtime-result shape, so
20
+ // callers and the test suite see the same artifact the retired bridge produced.
21
+
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";
25
+ import { randomUUID } from "node:crypto";
26
+ import { estimateCost } from "../cost.js";
27
+ import { retryableProviderFailureInfo } from "../failure.js";
28
+ import { runtimeCapabilities } from "../runtime/capabilities.js";
29
+ import {
30
+ deprecatedSettingsWarning,
31
+ resolveAgentCompactionPolicy,
32
+ resolveRuntimePolicyInputs,
33
+ } from "../../agent/compaction.js";
34
+ import { closePiMcpClients } from "../../agent/tools/pi-bridge.js";
35
+ import { createApprovalManager } from "../../agent/approval.js";
36
+ import { buildCapabilitiesUsed, toolCompactionAppliedFromWarnings } from "../runtime/capabilities-used.js";
37
+ import { resolvePiRuntimeModel } from "./pi-models.js";
38
+ import {
39
+ textFromContent,
40
+ thinkingFromContent,
41
+ toAgentMessages,
42
+ } from "./pi-messages.js";
43
+ import { emitCaptured } from "./pi-events.js";
44
+ import { normalizePiErrorMessage } from "./pi-errors.js";
45
+ import {
46
+ runStructuredOutputFinalizationRetry,
47
+ shouldRetryStructuredOutputFinalization,
48
+ } from "./pi-native/structured-output.js";
49
+ import {
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";
79
+
80
+ async function resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnings }) {
81
+ if (apiKeys?.has(provider)) return apiKeys.get(provider);
82
+ if (typeof resolvePiApiKey !== "function") return undefined;
83
+ try {
84
+ return await resolvePiApiKey(provider);
85
+ } catch (err) {
86
+ runtimeWarnings.push({
87
+ warning_kind: "pi_auth_failed",
88
+ provider,
89
+ message: err?.message || String(err),
90
+ });
91
+ return undefined;
92
+ }
93
+ }
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
+
174
+ // Normalize the incoming runtime messages into AgentMessages the harness can
175
+ // seed/prompt. Returns the prior messages (appended to the session before the
176
+ // run) and the final user text used to drive `harness.prompt`.
177
+ export function splitPromptMessages(messages, model) {
178
+ const source = Array.isArray(messages) && messages.length
179
+ ? messages
180
+ : [{ role: "user", content: "" }];
181
+ // The harness `prompt` takes the trailing user turn; everything before it is
182
+ // seeded as transcript context.
183
+ let lastUserIndex = -1;
184
+ for (let i = source.length - 1; i >= 0; i -= 1) {
185
+ if (source[i]?.role === "user") { lastUserIndex = i; break; }
186
+ }
187
+ if (lastUserIndex === -1) {
188
+ // No user turn: seed everything (structure preserved), nothing to prompt.
189
+ return { priorMessages: toAgentMessages(source, model), promptText: "", promptImages: [] };
190
+ }
191
+ // Prior turns: preserve structure (incl. image blocks) via toAgentMessages
192
+ // instead of stringifying — this is the format the harness seeds from.
193
+ const priorMessages = lastUserIndex > 0 ? toAgentMessages(source.slice(0, lastUserIndex), model) : [];
194
+ // Final user turn: split into plain text + structured images so harness.prompt
195
+ // can receive them as ImageContent[] rather than a JSON-stringified blob.
196
+ const { text, images } = splitUserContent(source[lastUserIndex].content);
197
+ return { priorMessages, promptText: text, promptImages: images };
198
+ }
199
+
200
+ // Split a user message's content into joined text and ImageContent[] image
201
+ // parts ({ type, data, mimeType }), preserving multimodal input for the runtime.
202
+ function splitUserContent(content) {
203
+ if (typeof content === "string") return { text: content, images: [] };
204
+ if (!Array.isArray(content)) return { text: String(content ?? ""), images: [] };
205
+ const texts = [];
206
+ const images = [];
207
+ for (const part of content) {
208
+ if (typeof part === "string") { texts.push(part); continue; }
209
+ if (part?.type === "text" && typeof part.text === "string") { texts.push(part.text); continue; }
210
+ if (part?.type === "image" && part.data) {
211
+ images.push({ type: "image", data: part.data, mimeType: part.mimeType || part.mime_type || "image/png" });
212
+ continue;
213
+ }
214
+ texts.push(JSON.stringify(part ?? ""));
215
+ }
216
+ return { text: texts.join("\n"), images };
217
+ }
218
+
219
+ export async function generatePiNativeResponse(systemPrompt, options = {}) {
220
+ const resolved = options.model;
221
+ const start = Date.now();
222
+ const events = [];
223
+ const runtimeWarnings = [];
224
+ let mcpClients = [];
225
+ let harness = 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
+ };
284
+
285
+ const providerSessionId = options.sessionId
286
+ || options.providerSessionId
287
+ || options.runId
288
+ || randomUUID();
289
+ // Prefer the explicit sessionId, but fall back to providerSessionId so a caller
290
+ // that only supplies providerSessionId still resumes the prior session instead
291
+ // of being treated as a fresh run (which would drop prior context).
292
+ const requestedSessionId = typeof options.sessionId === "string" && options.sessionId.trim()
293
+ ? options.sessionId
294
+ : (typeof options.providerSessionId === "string" && options.providerSessionId.trim()
295
+ ? options.providerSessionId
296
+ : null);
297
+ // Bridge TTL is a backstop behind the host's session policy; the grace
298
+ // keeps host-side lazy expiry firing first.
299
+ const sessionTtlMs = Number.isFinite(Number(options.sessionIdleTimeoutMs))
300
+ ? Number(options.sessionIdleTimeoutMs) + 60_000
301
+ : undefined;
302
+ let structuredOutputFinalizationRetryAttempts = 0;
303
+ let structuredOutputFinalizationRetryReason = null;
304
+ let structuredOutputFinalizationRetryFailed = false;
305
+
306
+ const onEvent = (event) => emitCaptured(events, options.onEvent, event);
307
+ const approvalManager = options.onToolApprovalRequest
308
+ ? createApprovalManager({
309
+ onToolApprovalRequest: options.onToolApprovalRequest,
310
+ defaultRiskTier: options.approvalDefaultRiskTier,
311
+ timeoutMs: options.approvalTimeoutMs,
312
+ onEvent,
313
+ riskTiersByTool: options.toolRiskTiers,
314
+ alwaysAllowTools: options.approvalAlwaysAllowTools,
315
+ })
316
+ : null;
317
+
318
+ if (options.abortSignal?.aborted) {
319
+ return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
320
+ }
321
+
322
+ const durableRepo = resolveDurableNativeSessionRepo(options.piSessionsRoot);
323
+
324
+ try {
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;
346
+
347
+ // `piResolvedModel` is an advanced/test seam: when supplied it provides a
348
+ // ready pi-ai Model (e.g. a registered faux provider model) plus optional
349
+ // capabilities, bypassing the static model-registry lookup. Production
350
+ // callers leave it undefined and resolve through pi-ai's registry.
351
+ const runtime = options.piResolvedModel
352
+ ? {
353
+ model: options.piResolvedModel,
354
+ capabilities: options.piResolvedCapabilities || {
355
+ tool_use: true,
356
+ reasoning: !!options.piResolvedModel.reasoning,
357
+ reasoning_mode: options.piResolvedModel.reasoning ? "effort" : "none",
358
+ json_mode: true,
359
+ },
360
+ apiKeys: new Map(),
361
+ }
362
+ : resolvePiRuntimeModel(resolved, options);
363
+ const capabilities = runtime.capabilities || {};
364
+ const effectiveThinkingLevel = thinkingLevelForEffort(options.effort || "medium", capabilities);
365
+ const reference = resolved.reference
366
+ || (resolved.sdk === "pi" ? `pi:${resolved.provider}:${resolved.model}` : `${resolved.sdk}:${resolved.model}`);
367
+
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,
378
+ });
379
+ if (consumedSettingsKeys.length > 0) {
380
+ const warning = deprecatedSettingsWarning(consumedSettingsKeys);
381
+ runtimeWarnings.push(warning);
382
+ onEvent({ type: "runtime_warning", ...warning });
383
+ }
384
+
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;
407
+
408
+ // Provider retry/backoff is delegated to pi-ai via streamOptions, replacing
409
+ // the legacy hand-rolled stream-retry loop.
410
+ const maxRetries = Number.isFinite(Number(options.piMaxRetries))
411
+ ? Math.max(0, Math.min(8, Number(options.piMaxRetries)))
412
+ : 2;
413
+ const maxRetryDelayMs = Number.isFinite(Number(options.maxRetryDelayMs))
414
+ ? Number(options.maxRetryDelayMs)
415
+ : 60_000;
416
+ // Tool steering: default "one-at-a-time" (safe, deterministic ordering).
417
+ // Opt-in "all" lets pi-agent-core run a model step's tool calls concurrently
418
+ // (QueueMode). Only enable when tools in a step are independent.
419
+ const toolSteeringMode = options.piToolParallelismMode === "all" ? "all" : "one-at-a-time";
420
+
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,
430
+ model: runtime.model,
431
+ thinkingLevel: effectiveThinkingLevel,
432
+ systemPrompt,
433
+ outputSchema: options.outputSchema,
434
+ tools,
435
+ maxRetries,
436
+ maxRetryDelayMs,
437
+ steeringMode: toolSteeringMode,
438
+ onEvent,
439
+ options,
440
+ toolLimits,
441
+ });
442
+
443
+ // Seed prior transcript (everything before the trailing user turn) into the
444
+ // harness-owned session. On a true resume the session already holds the
445
+ // transcript, so prior messages are skipped; a fresh run AND a create-on-miss
446
+ // (requestedSessionId set but the durable session was just created empty)
447
+ // both seed, since their on-disk transcript is empty.
448
+ const { priorMessages, promptText, promptImages } = splitPromptMessages(options.messages, runtime.model);
449
+ runState.sessionBaselineCount = (await runState.session.buildContext()).messages.length;
450
+ if (!requestedSessionId || runState.createdOnMiss) {
451
+ for (const message of priorMessages) {
452
+ await harness.appendMessage(message);
453
+ runState.sessionBaselineCount += 1;
454
+ }
455
+ }
456
+ // The harness persists each turn INLINE into the live session. To preserve
457
+ // the legacy "a failed resumed turn does not corrupt the session" contract,
458
+ // remember the leaf before the run so a failed resume can be rolled back to
459
+ // the last good transcript via the session tree's moveTo primitive. Only a
460
+ // TRUE resume needs this: a create-on-miss session is fresh, so a failure
461
+ // drops it entirely via the fresh-run path (no leaf to roll back to).
462
+ if (requestedSessionId && !runState.createdOnMiss) {
463
+ try { runState.baselineLeafId = await runState.session.getLeafId(); } catch { /* best-effort */ }
464
+ }
465
+
466
+ // Live steering: consume follow-up messages and steer the harness mid-run.
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 });
470
+
471
+ // Re-check abort right before issuing the provider request. The abort
472
+ // handler is only installed at ~:639, AFTER a long stretch of awaited setup
473
+ // (reopen, create, MCP init, buildContext, appendMessage, getLeafId). If
474
+ // abort fired DURING any of those awaits the listener was not yet attached,
475
+ // so the event was dropped and no run is active for harness.abort() to
476
+ // target. Without this re-check a full provider/LLM request would be issued
477
+ // for a run the caller already aborted (mirrors the entry pre-check at ~:356).
478
+ if (options.abortSignal?.aborted) {
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 });
487
+ return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
488
+ }
489
+
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
+ });
511
+
512
+ onEvent({
513
+ type: "provider_request_started",
514
+ sdk: resolved.sdk,
515
+ model: reference,
516
+ runtime: "pi",
517
+ timestamp: Date.now(),
518
+ });
519
+
520
+ let { runError } = await runHarnessPrompt(harness, promptText, promptImages);
521
+
522
+ // The run is done: stop the live-steering consumer so it cannot steer a
523
+ // finished harness or swallow a follow-up meant for the next turn.
524
+ await liveInput.stop();
525
+
526
+ runState.externalAbort ||= !!options.abortSignal?.aborted;
527
+
528
+ const captureState = async () => {
529
+ const context = await runState.session.buildContext();
530
+ const transcript = context.messages || [];
531
+ const runTranscript = transcript.slice(runState.sessionBaselineCount);
532
+ const assistantMessages = runTranscript.filter((message) => message?.role === "assistant");
533
+ const lastAssistant = assistantMessages[assistantMessages.length - 1] || null;
534
+ return {
535
+ transcript,
536
+ runTranscript,
537
+ assistantMessages,
538
+ lastAssistant,
539
+ stopReason: lastAssistant?.stopReason || null,
540
+ finalText: textFromContent(lastAssistant?.content) || runState.assistantTexts.join(""),
541
+ finalThinking: thinkingFromContent(lastAssistant?.content) || runState.assistantThinking.join(""),
542
+ };
543
+ };
544
+
545
+ let state = await captureState();
546
+
547
+ // Structured-output finalization retry: if the turn ended with neither
548
+ // text nor a StructuredOutput call, re-prompt ONCE in the same session with
549
+ // only StructuredOutput active so the model can submit the required result.
550
+ // This replicates the legacy bridge's single re-prompt via the harness's
551
+ // followUp + setActiveTools instead of the low-level agent.continue() loop.
552
+ if (!runError && shouldRetryStructuredOutputFinalization({
553
+ outputSchema: options.outputSchema,
554
+ structuredResult: runState.structuredResult,
555
+ finalText: state.finalText,
556
+ stopReason: state.stopReason,
557
+ externalAbort: runState.externalAbort,
558
+ maxTurnsHit: runState.maxTurnsHit,
559
+ })) {
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;
564
+ state = await captureState();
565
+ }
566
+
567
+ // Reactive recovery: if the turn ended in a context overflow and we have not
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
+ }));
584
+
585
+ const { runTranscript, lastAssistant, stopReason, finalText, finalThinking } = state;
586
+ const runAssistantCount = state.assistantMessages.length;
587
+
588
+ const usage = usageFromMessages(runTranscript);
589
+ const estimatedCost = estimateCost({
590
+ resolveCustomPricing: options.resolveCustomPricing,
591
+ model: reference,
592
+ inputTokens: usage.input,
593
+ outputTokens: usage.output,
594
+ cachedTokens: usage.cacheRead,
595
+ cacheWriteTokens: usage.cacheWrite,
596
+ });
597
+ emitUsageCostEvents({ onEvent, resolved, reference, usage, estimatedCost, start, externalAbort: runState.externalAbort });
598
+
599
+ const rawErrorMessage = runState.externalAbort
600
+ ? null
601
+ : runState.maxTurnsHit
602
+ ? "Pi agent stopped before final output: max turns reached"
603
+ : (stopReason === "error" || stopReason === "aborted"
604
+ ? lastAssistant?.errorMessage || runError?.message || "Pi agent aborted before final output"
605
+ : (runError ? runError.message || String(runError) : null));
606
+ const errorMessage = normalizePiErrorMessage(rawErrorMessage);
607
+
608
+ const structuredRetry = {
609
+ attempts: structuredOutputFinalizationRetryAttempts,
610
+ reason: structuredOutputFinalizationRetryReason,
611
+ failed: structuredOutputFinalizationRetryFailed,
612
+ };
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
+ });
638
+
639
+ const capabilitiesUsed = buildCapabilitiesUsed({
640
+ promptCacheActive: usage.cacheRead > 0 || usage.cacheWrite > 0,
641
+ thinkingEnabled: effectiveThinkingLevel !== "off" && effectiveThinkingLevel !== "low",
642
+ structuredOutputEnforced: !!options.outputSchema,
643
+ subagentInvoked: false,
644
+ mcpServersUsed: mcpClients.map((entry) => entry?.name).filter(Boolean),
645
+ nativeSubagentsUsed: [],
646
+ toolCompactionApplied: toolCompactionAppliedFromWarnings(runtimeWarnings),
647
+ // Tristate: true = a compaction fired this run (proactive or reactive),
648
+ // false = the path is enabled but did not need to fire, null = disabled via
649
+ // agent_compaction_enabled. See docs/reference/feature-registry.md runtime.context-compaction.
650
+ contextCompactionApplied: runState.compaction.policy?.enabled ? runState.compaction.applied : null,
651
+ });
652
+ emitCapabilitiesResolved(onEvent, { sdk: resolved.sdk, model: reference, capabilitiesUsed });
653
+
654
+ // Re-check the abort signal: a cancel can land during the post-run work above
655
+ // (live-input teardown, structured-output finalization retry) after the line
656
+ // ~780 check. Pick it up here so the lifecycle decision below rolls back the
657
+ // cancelled turn instead of committing it into a durable transcript a later
658
+ // resume would replay.
659
+ runState.externalAbort ||= !!options.abortSignal?.aborted;
660
+
661
+ // Session lifecycle parity with the legacy bridge. The harness already
662
+ // durably persisted the transcript into its session object (in-memory for
663
+ // the default repo, jsonl on disk when piSessionsRoot is set); commitSession
664
+ // tracks LIVENESS so disposeProviderSession / idle-TTL eviction can reach
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
+ });
677
+
678
+ // Final abort guard (durable cancel TOCTOU): if a cancel raced the lifecycle
679
+ // commit above — landing AFTER the keep-alive/!externalAbort decision but
680
+ // before this return — the cancelled turn is still in the durable transcript
681
+ // and (for keep-alive) the live registry. Roll it back so the next resume sees
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 });
690
+ }
691
+
692
+ return buildSuccessResult({
693
+ finalText,
694
+ finalThinking,
695
+ events,
696
+ usage,
697
+ estimatedCost,
698
+ start,
699
+ turnCount: runState.turnCount,
700
+ runAssistantCount,
701
+ resolved,
702
+ options,
703
+ externalAbort: runState.externalAbort,
704
+ errorMessage,
705
+ errorDetails,
706
+ diagnostics,
707
+ maxTurnsHit: runState.maxTurnsHit,
708
+ providerSessionId,
709
+ runtimeWarnings,
710
+ capabilitiesUsed,
711
+ structuredResult: runState.structuredResult,
712
+ });
713
+ } catch (err) {
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 });
720
+ const errorMessage = normalizePiErrorMessage(err?.message || String(err));
721
+ const isRetryable = retryableProviderFailureInfo({
722
+ errorText: errorMessage,
723
+ failureKind: "provider_unavailable",
724
+ }).retryable;
725
+ return buildErrorResult({
726
+ assistantTexts: runState.assistantTexts,
727
+ events,
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,
737
+ providerSessionId,
738
+ runtimeWarnings,
739
+ isRetryable,
740
+ });
741
+ } finally {
742
+ if (runState.sessionEntry) runState.sessionEntry.busy = false;
743
+ runState.removeAbortHandler?.();
744
+ await closePiMcpClients(mcpClients);
745
+ }
746
+ }
747
+
748
+ export const piNativeRuntimeBridge = {
749
+ id: "pi",
750
+ kind: "pi",
751
+ capabilities: runtimeCapabilities("pi"),
752
+ supports: (ref) => ref?.sdk === "pi",
753
+ execute: generatePiNativeResponse,
754
+ };