@mono-agent/agent-runtime 0.2.2 → 0.4.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.
@@ -0,0 +1,1445 @@
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 {
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";
31
+ import { randomUUID } from "node:crypto";
32
+ import { estimateCost } from "../cost.js";
33
+ import { retryableProviderFailureInfo } from "../failure.js";
34
+ 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
+ import {
39
+ closePiMcpClients,
40
+ createStructuredOutputTool,
41
+ getPiBuiltinTools,
42
+ initPiMcpTools,
43
+ } from "../../agent/tools/pi-bridge.js";
44
+ import { createApprovalManager } from "../../agent/approval.js";
45
+ import { buildCapabilitiesUsed, toolCompactionAppliedFromWarnings } from "../runtime/capabilities-used.js";
46
+ import { resolvePiRuntimeModel } from "./pi-models.js";
47
+ import {
48
+ textFromContent,
49
+ thinkingFromContent,
50
+ toAgentMessages,
51
+ toolResultContent,
52
+ } from "./pi-messages.js";
53
+ import {
54
+ compactToolRawResult,
55
+ emitCaptured,
56
+ eventToolArgs,
57
+ jsonSerializable,
58
+ streamContentKey,
59
+ } from "./pi-events.js";
60
+ 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
+ }
269
+
270
+ async function resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnings }) {
271
+ if (apiKeys?.has(provider)) return apiKeys.get(provider);
272
+ if (typeof resolvePiApiKey !== "function") return undefined;
273
+ try {
274
+ return await resolvePiApiKey(provider);
275
+ } catch (err) {
276
+ runtimeWarnings.push({
277
+ warning_kind: "pi_auth_failed",
278
+ provider,
279
+ message: err?.message || String(err),
280
+ });
281
+ return undefined;
282
+ }
283
+ }
284
+
285
+ // Normalize the incoming runtime messages into AgentMessages the harness can
286
+ // seed/prompt. Returns the prior messages (appended to the session before the
287
+ // run) and the final user text used to drive `harness.prompt`.
288
+ export function splitPromptMessages(messages, model) {
289
+ const source = Array.isArray(messages) && messages.length
290
+ ? messages
291
+ : [{ role: "user", content: "" }];
292
+ // The harness `prompt` takes the trailing user turn; everything before it is
293
+ // seeded as transcript context.
294
+ let lastUserIndex = -1;
295
+ for (let i = source.length - 1; i >= 0; i -= 1) {
296
+ if (source[i]?.role === "user") { lastUserIndex = i; break; }
297
+ }
298
+ if (lastUserIndex === -1) {
299
+ // No user turn: seed everything (structure preserved), nothing to prompt.
300
+ return { priorMessages: toAgentMessages(source, model), promptText: "", promptImages: [] };
301
+ }
302
+ // Prior turns: preserve structure (incl. image blocks) via toAgentMessages
303
+ // instead of stringifying — this is the format the harness seeds from.
304
+ const priorMessages = lastUserIndex > 0 ? toAgentMessages(source.slice(0, lastUserIndex), model) : [];
305
+ // Final user turn: split into plain text + structured images so harness.prompt
306
+ // can receive them as ImageContent[] rather than a JSON-stringified blob.
307
+ const { text, images } = splitUserContent(source[lastUserIndex].content);
308
+ return { priorMessages, promptText: text, promptImages: images };
309
+ }
310
+
311
+ // Split a user message's content into joined text and ImageContent[] image
312
+ // parts ({ type, data, mimeType }), preserving multimodal input for the runtime.
313
+ function splitUserContent(content) {
314
+ if (typeof content === "string") return { text: content, images: [] };
315
+ if (!Array.isArray(content)) return { text: String(content ?? ""), images: [] };
316
+ const texts = [];
317
+ const images = [];
318
+ for (const part of content) {
319
+ if (typeof part === "string") { texts.push(part); continue; }
320
+ if (part?.type === "text" && typeof part.text === "string") { texts.push(part.text); continue; }
321
+ if (part?.type === "image" && part.data) {
322
+ images.push({ type: "image", data: part.data, mimeType: part.mimeType || part.mime_type || "image/png" });
323
+ continue;
324
+ }
325
+ texts.push(JSON.stringify(part ?? ""));
326
+ }
327
+ return { text: texts.join("\n"), images };
328
+ }
329
+
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
+ export async function generatePiNativeResponse(systemPrompt, options = {}) {
454
+ const resolved = options.model;
455
+ const start = Date.now();
456
+ const events = [];
457
+ const runtimeWarnings = [];
458
+ const assistantTexts = [];
459
+ const assistantThinking = [];
460
+ const textDeltaIndexes = new Set();
461
+ const thinkingDeltaIndexes = new Set();
462
+ let structuredResult = null;
463
+ 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
+ let harness = null;
472
+ let removeAbortHandler = null;
473
+
474
+ const providerSessionId = options.sessionId
475
+ || options.providerSessionId
476
+ || options.runId
477
+ || randomUUID();
478
+ // Prefer the explicit sessionId, but fall back to providerSessionId so a caller
479
+ // that only supplies providerSessionId still resumes the prior session instead
480
+ // of being treated as a fresh run (which would drop prior context).
481
+ const requestedSessionId = typeof options.sessionId === "string" && options.sessionId.trim()
482
+ ? options.sessionId
483
+ : (typeof options.providerSessionId === "string" && options.providerSessionId.trim()
484
+ ? options.providerSessionId
485
+ : null);
486
+ // Bridge TTL is a backstop behind the host's session policy; the grace
487
+ // keeps host-side lazy expiry firing first.
488
+ const sessionTtlMs = Number.isFinite(Number(options.sessionIdleTimeoutMs))
489
+ ? Number(options.sessionIdleTimeoutMs) + 60_000
490
+ : undefined;
491
+ let structuredOutputFinalizationRetryAttempts = 0;
492
+ let structuredOutputFinalizationRetryReason = null;
493
+ let structuredOutputFinalizationRetryFailed = false;
494
+
495
+ const onEvent = (event) => emitCaptured(events, options.onEvent, event);
496
+ const approvalManager = options.onToolApprovalRequest
497
+ ? createApprovalManager({
498
+ onToolApprovalRequest: options.onToolApprovalRequest,
499
+ defaultRiskTier: options.approvalDefaultRiskTier,
500
+ timeoutMs: options.approvalTimeoutMs,
501
+ onEvent,
502
+ riskTiersByTool: options.toolRiskTiers,
503
+ alwaysAllowTools: options.approvalAlwaysAllowTools,
504
+ })
505
+ : null;
506
+
507
+ if (options.abortSignal?.aborted) {
508
+ return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
509
+ }
510
+
511
+ 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
+
532
+ 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
+ }
648
+
649
+ // `piResolvedModel` is an advanced/test seam: when supplied it provides a
650
+ // ready pi-ai Model (e.g. a registered faux provider model) plus optional
651
+ // capabilities, bypassing the static model-registry lookup. Production
652
+ // callers leave it undefined and resolve through pi-ai's registry.
653
+ const runtime = options.piResolvedModel
654
+ ? {
655
+ model: options.piResolvedModel,
656
+ capabilities: options.piResolvedCapabilities || {
657
+ tool_use: true,
658
+ reasoning: !!options.piResolvedModel.reasoning,
659
+ reasoning_mode: options.piResolvedModel.reasoning ? "effort" : "none",
660
+ json_mode: true,
661
+ },
662
+ apiKeys: new Map(),
663
+ }
664
+ : resolvePiRuntimeModel(resolved, options);
665
+ const capabilities = runtime.capabilities || {};
666
+ const effectiveThinkingLevel = thinkingLevelForEffort(options.effort || "medium", capabilities);
667
+ const reference = resolved.reference
668
+ || (resolved.sdk === "pi" ? `pi:${resolved.provider}:${resolved.model}` : `${resolved.sdk}:${resolved.model}`);
669
+
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;
723
+ });
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);
742
+
743
+ const tools = [
744
+ ...builtIns,
745
+ ...mcpInit.tools,
746
+ ...(structuredTool ? [structuredTool] : []),
747
+ ];
748
+
749
+ // Provider retry/backoff is delegated to pi-ai via streamOptions, replacing
750
+ // the legacy hand-rolled stream-retry loop.
751
+ const maxRetries = Number.isFinite(Number(options.piMaxRetries))
752
+ ? Math.max(0, Math.min(8, Number(options.piMaxRetries)))
753
+ : 2;
754
+ const maxRetryDelayMs = Number.isFinite(Number(options.maxRetryDelayMs))
755
+ ? Number(options.maxRetryDelayMs)
756
+ : 60_000;
757
+ // Tool steering: default "one-at-a-time" (safe, deterministic ordering).
758
+ // Opt-in "all" lets pi-agent-core run a model step's tool calls concurrently
759
+ // (QueueMode). Only enable when tools in a step are independent.
760
+ const toolSteeringMode = options.piToolParallelismMode === "all" ? "all" : "one-at-a-time";
761
+
762
+ harness = new AgentHarness({
763
+ env: new NodeExecutionEnv({ cwd: options.cwd || process.cwd() }),
764
+ session,
765
+ model: runtime.model,
766
+ thinkingLevel: effectiveThinkingLevel,
767
+ systemPrompt: appendStructuredOutputInstruction(systemPrompt, options.outputSchema),
768
+ 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
+ },
778
+ 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
+ }
863
+ });
864
+
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
+ // Seed prior transcript (everything before the trailing user turn) into the
875
+ // harness-owned session. On a true resume the session already holds the
876
+ // transcript, so prior messages are skipped; a fresh run AND a create-on-miss
877
+ // (requestedSessionId set but the durable session was just created empty)
878
+ // both seed, since their on-disk transcript is empty.
879
+ const { priorMessages, promptText, promptImages } = splitPromptMessages(options.messages, runtime.model);
880
+ sessionBaselineCount = (await session.buildContext()).messages.length;
881
+ if (!requestedSessionId || createdOnMiss) {
882
+ for (const message of priorMessages) {
883
+ await harness.appendMessage(message);
884
+ sessionBaselineCount += 1;
885
+ }
886
+ }
887
+ // The harness persists each turn INLINE into the live session. To preserve
888
+ // the legacy "a failed resumed turn does not corrupt the session" contract,
889
+ // remember the leaf before the run so a failed resume can be rolled back to
890
+ // the last good transcript via the session tree's moveTo primitive. Only a
891
+ // TRUE resume needs this: a create-on-miss session is fresh, so a failure
892
+ // 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 */ }
895
+ }
896
+
897
+ // 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
+ }
923
+
924
+ // Re-check abort right before issuing the provider request. The abort
925
+ // handler is only installed at ~:639, AFTER a long stretch of awaited setup
926
+ // (reopen, create, MCP init, buildContext, appendMessage, getLeafId). If
927
+ // abort fired DURING any of those awaits the listener was not yet attached,
928
+ // so the event was dropped and no run is active for harness.abort() to
929
+ // target. Without this re-check a full provider/LLM request would be issued
930
+ // for a run the caller already aborted (mirrors the entry pre-check at ~:356).
931
+ 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);
948
+ return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
949
+ }
950
+
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
+ }
991
+
992
+ onEvent({
993
+ type: "provider_request_started",
994
+ sdk: resolved.sdk,
995
+ model: reference,
996
+ runtime: "pi",
997
+ timestamp: Date.now(),
998
+ });
999
+
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();
1015
+
1016
+ // 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;
1026
+
1027
+ externalAbort ||= !!options.abortSignal?.aborted;
1028
+
1029
+ const captureState = async () => {
1030
+ const context = await session.buildContext();
1031
+ const transcript = context.messages || [];
1032
+ const runTranscript = transcript.slice(sessionBaselineCount);
1033
+ const assistantMessages = runTranscript.filter((message) => message?.role === "assistant");
1034
+ const lastAssistant = assistantMessages[assistantMessages.length - 1] || null;
1035
+ return {
1036
+ transcript,
1037
+ runTranscript,
1038
+ assistantMessages,
1039
+ lastAssistant,
1040
+ stopReason: lastAssistant?.stopReason || null,
1041
+ finalText: textFromContent(lastAssistant?.content) || assistantTexts.join(""),
1042
+ finalThinking: thinkingFromContent(lastAssistant?.content) || assistantThinking.join(""),
1043
+ };
1044
+ };
1045
+
1046
+ let state = await captureState();
1047
+
1048
+ // Structured-output finalization retry: if the turn ended with neither
1049
+ // text nor a StructuredOutput call, re-prompt ONCE in the same session with
1050
+ // only StructuredOutput active so the model can submit the required result.
1051
+ // This replicates the legacy bridge's single re-prompt via the harness's
1052
+ // followUp + setActiveTools instead of the low-level agent.continue() loop.
1053
+ if (!runError && shouldRetryStructuredOutputFinalization({
1054
+ outputSchema: options.outputSchema,
1055
+ structuredResult,
1056
+ finalText: state.finalText,
1057
+ stopReason: state.stopReason,
1058
+ externalAbort,
1059
+ maxTurnsHit,
1060
+ })) {
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;
1088
+ state = await captureState();
1089
+ }
1090
+
1091
+ // 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
+ }
1149
+
1150
+ const { runTranscript, lastAssistant, stopReason, finalText, finalThinking } = state;
1151
+ const runAssistantCount = state.assistantMessages.length;
1152
+
1153
+ const usage = usageFromMessages(runTranscript);
1154
+ const estimatedCost = estimateCost({
1155
+ resolveCustomPricing: options.resolveCustomPricing,
1156
+ model: reference,
1157
+ inputTokens: usage.input,
1158
+ outputTokens: usage.output,
1159
+ cachedTokens: usage.cacheRead,
1160
+ cacheWriteTokens: usage.cacheWrite,
1161
+ });
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
+ });
1189
+
1190
+ const rawErrorMessage = externalAbort
1191
+ ? null
1192
+ : maxTurnsHit
1193
+ ? "Pi agent stopped before final output: max turns reached"
1194
+ : (stopReason === "error" || stopReason === "aborted"
1195
+ ? lastAssistant?.errorMessage || runError?.message || "Pi agent aborted before final output"
1196
+ : (runError ? runError.message || String(runError) : null));
1197
+ const errorMessage = normalizePiErrorMessage(rawErrorMessage);
1198
+
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,
1215
+ };
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;
1231
+
1232
+ const capabilitiesUsed = buildCapabilitiesUsed({
1233
+ promptCacheActive: usage.cacheRead > 0 || usage.cacheWrite > 0,
1234
+ thinkingEnabled: effectiveThinkingLevel !== "off" && effectiveThinkingLevel !== "low",
1235
+ structuredOutputEnforced: !!options.outputSchema,
1236
+ subagentInvoked: false,
1237
+ mcpServersUsed: mcpClients.map((entry) => entry?.name).filter(Boolean),
1238
+ nativeSubagentsUsed: [],
1239
+ toolCompactionApplied: toolCompactionAppliedFromWarnings(runtimeWarnings),
1240
+ // Tristate: true = a compaction fired this run (proactive or reactive),
1241
+ // 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,
1244
+ });
1245
+ onEvent({ type: "capabilities_resolved", sdk: resolved.sdk, model: reference, capabilitiesUsed });
1246
+
1247
+ // Re-check the abort signal: a cancel can land during the post-run work above
1248
+ // (live-input teardown, structured-output finalization retry) after the line
1249
+ // ~780 check. Pick it up here so the lifecycle decision below rolls back the
1250
+ // cancelled turn instead of committing it into a durable transcript a later
1251
+ // resume would replay.
1252
+ externalAbort ||= !!options.abortSignal?.aborted;
1253
+
1254
+ // Session lifecycle parity with the legacy bridge. The harness already
1255
+ // durably persisted the transcript into its session object (in-memory for
1256
+ // the default repo, jsonl on disk when piSessionsRoot is set); the registry
1257
+ // 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
+ }
1316
+
1317
+ // Final abort guard (durable cancel TOCTOU): if a cancel raced the lifecycle
1318
+ // commit above — landing AFTER the keep-alive/!externalAbort decision but
1319
+ // before this return — the cancelled turn is still in the durable transcript
1320
+ // 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
+ }
1335
+ }
1336
+
1337
+ return {
1338
+ text: finalText,
1339
+ thinking: finalThinking,
1340
+ 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,
1356
+ errorDetails,
1357
+ failureKind: errorMessage
1358
+ ? failureKindForPiError(errorMessage, diagnostics, { maxTurnsHit })
1359
+ : null,
1360
+ providerSessionId,
1361
+ runtimeWarnings,
1362
+ diagnostics,
1363
+ capabilitiesUsed,
1364
+ ...(structuredResult !== null && structuredResult !== undefined
1365
+ ? { structuredResult, structuredResultSource: "StructuredOutput" }
1366
+ : { structuredResult: undefined, structuredResultSource: null }),
1367
+ };
1368
+ } 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
+ }
1394
+ const errorMessage = normalizePiErrorMessage(err?.message || String(err));
1395
+ const isRetryable = retryableProviderFailureInfo({
1396
+ errorText: errorMessage,
1397
+ failureKind: "provider_unavailable",
1398
+ }).retryable;
1399
+ return {
1400
+ text: assistantTexts.join("") || null,
1401
+ 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 }),
1421
+ providerSessionId,
1422
+ 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
+ };
1432
+ } finally {
1433
+ if (sessionEntry) sessionEntry.busy = false;
1434
+ removeAbortHandler?.();
1435
+ await closePiMcpClients(mcpClients);
1436
+ }
1437
+ }
1438
+
1439
+ export const piNativeRuntimeBridge = {
1440
+ id: "pi",
1441
+ kind: "pi",
1442
+ capabilities: runtimeCapabilities("pi"),
1443
+ supports: (ref) => ref?.sdk === "pi",
1444
+ execute: generatePiNativeResponse,
1445
+ };