@mono-agent/agent-runtime 0.20.14 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +30 -7
  3. package/README.md +219 -35
  4. package/package.json +9 -4
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +104 -5
  7. package/src/agent/tools/bash.js +10 -2
  8. package/src/agent/tools/codex-subscription-search.js +122 -28
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/monitor.js +11 -2
  11. package/src/agent/tools/pi-bridge.js +33 -14
  12. package/src/agent/tools/shared/monitors.js +22 -3
  13. package/src/agent/tools/shared/path-resolver.js +25 -6
  14. package/src/agent/tools/shared/process-jobs.js +6 -1
  15. package/src/agent/tools/shared/process-runner.js +3 -1
  16. package/src/agent/tools/shared/tool-context.js +8 -0
  17. package/src/agent/tools/web-access-interstitial.js +70 -0
  18. package/src/agent/tools/web-browser-render.js +83 -58
  19. package/src/agent/tools/web-controller.js +112 -21
  20. package/src/agent/tools/web-document-extractor.js +379 -0
  21. package/src/agent/tools/web-fetch.js +271 -243
  22. package/src/agent/tools/web-request.js +65 -0
  23. package/src/agent/tools/web-search-output.js +165 -0
  24. package/src/agent/tools/web-search-state.js +75 -0
  25. package/src/agent/tools/web-search.js +532 -71
  26. package/src/ai/failure.js +3 -3
  27. package/src/ai/index.js +1 -0
  28. package/src/ai/observer.js +8 -0
  29. package/src/ai/pi-interop.js +156 -0
  30. package/src/ai/provider-check.js +131 -0
  31. package/src/ai/providers/pi-native/compaction-driver.js +45 -21
  32. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  33. package/src/ai/providers/pi-native/harness-adapter.js +40 -2
  34. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  35. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  36. package/src/ai/providers/pi-native/result-builder.js +28 -4
  37. package/src/ai/providers/pi-native/session-lifecycle.js +167 -24
  38. package/src/ai/providers/pi-native/stream-subscriber.js +30 -2
  39. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  40. package/src/ai/providers/pi-native/turn-runner.js +245 -13
  41. package/src/ai/providers/pi-native.js +159 -40
  42. package/src/ai/runtime/live-input-events.js +250 -54
  43. package/src/ai/runtime/router.js +30 -11
  44. package/src/ai/tool-lifecycle.js +32 -18
  45. package/src/ai/types.js +26 -5
  46. package/src/runtime.js +24 -5
  47. package/types/agent/tool-bloat.d.ts +1 -1
  48. package/types/agent/tools/agent-tool.d.ts +4 -1
  49. package/types/agent/tools/bash.d.ts +5 -3
  50. package/types/agent/tools/codex-subscription-search.d.ts +6 -2
  51. package/types/agent/tools/exec.d.ts +5 -3
  52. package/types/agent/tools/monitor.d.ts +5 -2
  53. package/types/agent/tools/pi-bridge.d.ts +6 -4
  54. package/types/agent/tools/shared/monitors.d.ts +17 -2
  55. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  56. package/types/agent/tools/shared/process-runner.d.ts +3 -2
  57. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  58. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  59. package/types/agent/tools/web-browser-render.d.ts +4 -1
  60. package/types/agent/tools/web-controller.d.ts +4 -2
  61. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  62. package/types/agent/tools/web-fetch.d.ts +19 -24
  63. package/types/agent/tools/web-request.d.ts +20 -0
  64. package/types/agent/tools/web-search-output.d.ts +31 -0
  65. package/types/agent/tools/web-search-state.d.ts +21 -0
  66. package/types/agent/tools/web-search.d.ts +10 -45
  67. package/types/ai/index.d.ts +1 -0
  68. package/types/ai/observer.d.ts +6 -0
  69. package/types/ai/pi-interop.d.ts +61 -0
  70. package/types/ai/provider-check.d.ts +53 -0
  71. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  72. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  73. package/types/ai/providers/pi-native/harness-adapter.d.ts +3 -1
  74. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  75. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  76. package/types/ai/providers/pi-native/result-builder.d.ts +11 -1
  77. package/types/ai/providers/pi-native/session-lifecycle.d.ts +23 -5
  78. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  79. package/types/ai/providers/pi-native/turn-runner.d.ts +36 -5
  80. package/types/ai/runtime/live-input-events.d.ts +32 -8
  81. package/types/ai/tool-lifecycle.d.ts +4 -3
  82. package/types/ai/types.d.ts +140 -12
@@ -10,13 +10,15 @@
10
10
  // createSessionLiveness primitives so the await-free spans are enforced by
11
11
  // construction rather than by inline sequencing.
12
12
 
13
- import { JsonlSessionRepo, MemorySessionRepo } from "@earendil-works/pi-agent-core";
13
+ import { JsonlSessionRepo, MemorySessionRepo, laneConfig, laneState, operationMeta, operationResult, operationState } from "@earendil-works/pi-agent-core";
14
+ import { validRecoveryProjection } from "./terminal-recovery.js";
14
15
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
- import { access, open } from "node:fs/promises";
16
- import { dirname, resolve } from "node:path";
16
+ import { createHash } from "node:crypto";
17
+ import { access, open, readdir, unlink } from "node:fs/promises";
18
+ import { dirname, join, resolve } from "node:path";
17
19
  import { createSessionRegistry } from "../../runtime/sessions.js";
18
20
  import { createSessionLiveness } from "../../runtime/session-liveness.js";
19
- import { createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
21
+ import { buildPiSessionContext, createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
20
22
 
21
23
  async function syncPath(path) {
22
24
  const handle = await open(path, "r");
@@ -28,6 +30,7 @@ async function syncPath(path) {
28
30
  }
29
31
 
30
32
  async function syncDurableTranscript(entry) {
33
+ if (entry.recoveryPending) throw new Error("Pi terminal recovery is pending");
31
34
  if (!entry.durable) return;
32
35
  const path = entry.metadata?.path;
33
36
  if (typeof path !== "string" || !path) {
@@ -91,7 +94,7 @@ async function closeAndDeleteSession(session, repo, knownMetadata) {
91
94
  // reach over native pi sessions that the legacy bridge had.
92
95
  const nativeSessionRepo = new MemorySessionRepo();
93
96
  const nativeSessions = createSessionRegistry({
94
- isBusy: (entry) => entry.busy === true,
97
+ isBusy: (entry) => entry.busy === true || entry.recoveryPending === true,
95
98
  onSync: syncDurableTranscript,
96
99
  onEvict: async (entry, reason) => {
97
100
  // Ordinary disposal/TTL only drops registry metadata so durable sessions
@@ -128,11 +131,13 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
128
131
  }
129
132
 
130
133
  /**
131
- * Permanently retire every durable Pi transcript with this exact logical id.
132
- * This is intentionally stronger than live-session invalidation: history
133
- * rotation and retention can retire an epoch after its registry entry was
134
- * already evicted or after a process restart. Absence is success; any cleanup
135
- * or verification uncertainty rejects so canonical history remains reachable.
134
+ * Retire every currently materialized durable Pi transcript with this exact
135
+ * logical id. This is intentionally stronger than live-session invalidation:
136
+ * history rotation and retention can retire an epoch after its registry entry
137
+ * was already evicted or after a process restart. When an active old run later
138
+ * recreates its pathname, its post-runtime caller retries this operation. The
139
+ * canonical epoch has already rotated, so that old id is never resumable in the
140
+ * interim. Absence is success; cleanup or verification uncertainty rejects.
136
141
  */
137
142
  export async function retireDurableNativeSession(providerSessionId, piSessionsRoot) {
138
143
  if (!isSafeSessionId(providerSessionId)) {
@@ -142,7 +147,15 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
142
147
  throw new TypeError("piSessionsRoot must be a non-empty path");
143
148
  }
144
149
 
145
- // First guarantee this process cannot keep writing through a stale handle.
150
+ // First guarantee this process cannot resume through a stale registry entry.
151
+ // A host cancellation can rotate canonical history while the provider is
152
+ // still unwinding an open Pi session. The Pi repo deliberately refuses to
153
+ // delete an open session, so detach the registry and unlink its current file.
154
+ // Pi appends by pathname and can recreate a headerless orphan if the old run
155
+ // writes later; the raw exact-name sweep below removes those files on the
156
+ // post-runtime retry. The rotated canonical epoch never refers to this id.
157
+ const liveEntry = nativeSessions.get(providerSessionId);
158
+ const providerStillUnwinding = liveEntry?.busy === true;
146
159
  // Other processes are serialized by the history coordinator and must pass
147
160
  // the same cold-refresh barrier before their next turn.
148
161
  await nativeSessions.refresh(providerSessionId);
@@ -155,16 +168,66 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
155
168
  if (typeof metadata?.path !== "string" || !metadata.path) {
156
169
  throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
157
170
  }
158
- await repo.delete(metadata, PI_CONTEXT);
171
+ if (providerStillUnwinding) {
172
+ try {
173
+ await unlink(metadata.path);
174
+ } catch (error) {
175
+ if (error?.code !== "ENOENT") throw error;
176
+ }
177
+ } else {
178
+ await repo.delete(metadata, PI_CONTEXT);
179
+ }
159
180
  changedDirectories.add(dirname(metadata.path));
160
181
  }
182
+ // A prior active-session unlink can be followed by Pi recreating the same
183
+ // pathname with a transaction line but no header. JsonlSessionRepo.list()
184
+ // intentionally ignores that invalid file, so scan only Pi's fixed
185
+ // root/directory/filename layout to make the later retirement retry reclaim
186
+ // it. Safe ids are single filename components and symlink directories/files
187
+ // are ignored.
188
+ for (const path of await exactDurableSessionFiles(piSessionsRoot, providerSessionId)) {
189
+ try {
190
+ await unlink(path);
191
+ } catch (error) {
192
+ if (error?.code !== "ENOENT") throw error;
193
+ }
194
+ changedDirectories.add(dirname(path));
195
+ }
161
196
  for (const directory of changedDirectories) await syncPath(directory);
162
197
  if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
163
198
 
164
199
  const remaining = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
165
- if (remaining.length > 0) {
200
+ const remainingPaths = await exactDurableSessionFiles(piSessionsRoot, providerSessionId);
201
+ if (!providerStillUnwinding && (remaining.length > 0 || remainingPaths.length > 0)) {
166
202
  throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
167
203
  }
204
+ // An active Pi write can race the final exact-name check after the sweep.
205
+ // Canonical history is already preparing a fresh epoch, so the retired id is
206
+ // unreachable; the harness retries retirement when that old run returns.
207
+ }
208
+
209
+ async function exactDurableSessionFiles(piSessionsRoot, providerSessionId) {
210
+ const root = resolve(piSessionsRoot);
211
+ const suffix = `_${encodeURIComponent(providerSessionId)}.jsonl`;
212
+ let rootEntries;
213
+ try {
214
+ rootEntries = await readdir(root, { withFileTypes: true });
215
+ } catch (error) {
216
+ if (error?.code === "ENOENT") return [];
217
+ throw error;
218
+ }
219
+ const paths = [];
220
+ for (const directory of rootEntries) {
221
+ if (!directory.isDirectory()) continue;
222
+ const directoryPath = join(root, directory.name);
223
+ const entries = await readdir(directoryPath, { withFileTypes: true });
224
+ for (const entry of entries) {
225
+ if (entry.isFile() && entry.name.endsWith(suffix)) {
226
+ paths.push(join(directoryPath, entry.name));
227
+ }
228
+ }
229
+ }
230
+ return paths;
168
231
  }
169
232
 
170
233
  // Defense in depth (R4): create-on-miss passes the caller-controlled session id
@@ -355,7 +418,7 @@ export async function resolveSession(runState, {
355
418
  // (F4) safe. Do not introduce any await in this span or the TOCTOU window
356
419
  // reopens. `claim` re-reads the same registry entry and sets busy in one
357
420
  // await-free step; a busy entry loses and returns session_busy.
358
- const claimed = liveness.claim(requestedSessionId);
421
+ const claimed = entry.recoveryPending ? { ok: /** @type {const} */ (false), reason: "busy" } : liveness.claim(requestedSessionId);
359
422
  if (!claimed.ok) {
360
423
  // claim() can lose two ways: "busy" (the entry adopted above is
361
424
  // mid-turn) or "missing" (no live entry). "missing" is UNREACHABLE on
@@ -386,6 +449,7 @@ export async function resolveSession(runState, {
386
449
  };
387
450
  }
388
451
  runState.sessionEntry = claimed.entry;
452
+ delete claimed.entry.recovery;
389
453
  try {
390
454
  runState.session = createPiSessionAdapter(await claimed.entry.repo.open(
391
455
  claimed.entry.metadata,
@@ -403,10 +467,11 @@ export async function resolveSession(runState, {
403
467
  }
404
468
  }
405
469
  } else {
406
- // Fresh runs persist into the durable jsonl repo when piSessionsRoot is
407
- // set, so a kept-alive session can be reopened from disk after the live
408
- // entry is evicted; otherwise the in-memory repo is used.
409
- runState.session = createPiSessionAdapter(await (durableRepo || nativeSessionRepo)
470
+ // Attribution may equal a live primary's id on a stateless retry/backup.
471
+ // A private ephemeral repo prevents both create collisions and cleanup of
472
+ // that primary's transcript. Only keep-alive calls use a shared repository.
473
+ if (options.sessionKeepAlive !== true) runState.ephemeralSessionRepo = new MemorySessionRepo();
474
+ runState.session = createPiSessionAdapter(await (runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo)
410
475
  .create({ id: providerSessionId, cwd: cwd || process.cwd() }, PI_CONTEXT));
411
476
  }
412
477
  return { done: false };
@@ -428,7 +493,7 @@ export async function discardUncommittedSession(runState, { durableRepo }) {
428
493
  if (runState.session && !runState.sessionEntry) {
429
494
  await closeAndDeleteSession(
430
495
  runState.session,
431
- durableRepo || nativeSessionRepo,
496
+ runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo,
432
497
  );
433
498
  }
434
499
  // Drop the create-on-miss BUSY reservation too, else the busy placeholder
@@ -457,7 +522,7 @@ export async function commitSession(runState, {
457
522
  onEvent,
458
523
  }) {
459
524
  const { session, sessionEntry, baselineLeafId, reservation } = runState;
460
- if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
525
+ if (options.sessionKeepAlive === true && ((!externalAbort && !errorMessage) || runState.retainRecoveryTail)) {
461
526
  try {
462
527
  if (sessionEntry) {
463
528
  // Resumed run: the harness appended this run's turns onto the live
@@ -480,6 +545,8 @@ export async function commitSession(runState, {
480
545
  else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
481
546
  runState.registeredSessionEntry = entry;
482
547
  }
548
+ const retained = runState.sessionEntry || runState.registeredSessionEntry;
549
+ if (runState.retainRecoveryTail && retained) retained.recoveryPending = !!externalAbort || !!errorMessage;
483
550
  } catch (err) {
484
551
  // Session persistence must never fail the run; drop the (now
485
552
  // inconsistent) session instead of resuming from a broken transcript.
@@ -513,7 +580,7 @@ export async function commitSession(runState, {
513
580
  // (the success keep-alive path overwrites it with the finalized entry, so
514
581
  // it is only this drop branch that must clean it up).
515
582
  if (reservation) reservation.release();
516
- await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
583
+ await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
517
584
  }
518
585
  }
519
586
 
@@ -534,8 +601,9 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
534
601
  }
535
602
  nativeSessions.delete(requestedSessionId);
536
603
  } else {
537
- nativeSessions.delete(providerSessionId);
538
- await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
604
+ // A stateless call never registered this id; it may belong to the primary.
605
+ if (!runState.ephemeralSessionRepo) nativeSessions.delete(providerSessionId);
606
+ await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
539
607
  }
540
608
  }
541
609
 
@@ -558,7 +626,7 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
558
626
  // resumed user session here would be data loss) and never when the throw
559
627
  // preceded session create.
560
628
  if (session && !sessionEntry) {
561
- await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
629
+ await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
562
630
  }
563
631
  // Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
564
632
  // during/after the reservation — including a throw inside the create await
@@ -582,3 +650,78 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
582
650
  try { await session.close(); } catch { /* best-effort */ }
583
651
  }
584
652
  }
653
+
654
+ /** Capture only after close; pending entries cannot be driven by another turn. */
655
+ export async function captureSessionRecovery(runState, { options, providerSessionId, modelKey, model, pending }) {
656
+ const entry = runState.sessionEntry || runState.registeredSessionEntry;
657
+ if (!entry?.durable) return undefined;
658
+ try {
659
+ if (!Array.isArray(runState.recoveryInputIds)) throw new Error("Pi session recovery input identities are unavailable");
660
+ const tipId = await runState.session.getLeafId();
661
+ if (typeof tipId !== "string" || !tipId) throw new Error("Pi session recovery tip is unavailable");
662
+ const ancestry = createHash("sha256").update(JSON.stringify(await runState.session.getEntries())).digest("hex");
663
+ await runState.session.close();
664
+ const receipt = { runId: options.sessionRecovery.runId, revision: options.sessionRecovery.revision, providerSessionId, modelKey, tipId };
665
+ entry.recovery = { receipt: { ...receipt }, model: { ...model, input: [...model.input] }, ancestry, operationId: runState.recoveryOperationId, baselineTipId: runState.recoveryBaselineTipId, inputIds: runState.recoveryInputIds };
666
+ entry.recoveryPending = pending || !!options.abortSignal?.aborted;
667
+ return receipt;
668
+ } catch (error) {
669
+ // The run's outer catch performs legacy rollback/close or fresh deletion.
670
+ // Release provisional recovery state first so failed capture cannot strand
671
+ // an entry as busy without a receipt that could settle it.
672
+ entry.recoveryPending = false;
673
+ delete entry.recovery;
674
+ throw error;
675
+ }
676
+ }
677
+
678
+ /** Read-only settlement: never drive an operation or append host-authored prose. */
679
+ export async function recoverDurableNativeSession(receipt, context) {
680
+ const entry = nativeSessions.get(receipt?.providerSessionId);
681
+ const proof = entry?.recovery;
682
+ if (!entry?.durable || entry.busy || !proof
683
+ || !["runId", "revision", "providerSessionId", "modelKey", "tipId"].every((key) => receipt[key] === proof.receipt[key])
684
+ || !Array.isArray(context?.appliedInputIds)
685
+ || proof.inputIds.some((id) => typeof id !== "string")
686
+ || JSON.stringify([...proof.inputIds].sort()) !== JSON.stringify([...context.appliedInputIds].sort())) return false;
687
+ entry.busy = true;
688
+ let raw;
689
+ try {
690
+ const matches = (await entry.repo.list(undefined, PI_CONTEXT)).filter((record) => record.id === receipt.providerSessionId);
691
+ if (matches.length !== 1 || matches[0].path !== entry.metadata.path) return false;
692
+ raw = await entry.repo.open(matches[0], PI_CONTEXT);
693
+ const branch = await raw.branch("main", PI_CONTEXT);
694
+ if (!branch || await branch.getTipId(PI_CONTEXT) !== receipt.tipId) return false;
695
+ const state = (await raw.getValue(laneState("main"), PI_CONTEXT))?.value;
696
+ const config = (await raw.getValue(laneConfig("main"), PI_CONTEXT))?.value;
697
+ const terminal = (await raw.getValue(operationResult(proof.operationId), PI_CONTEXT))?.value;
698
+ const meta = (await raw.getValue(operationMeta(proof.operationId), PI_CONTEXT))?.value;
699
+ if (!state || state.currentOperationId !== null || state.lastOperationId !== proof.operationId || state.inbox.length !== 0
700
+ || config?.model.provider !== proof.model.provider || config?.model.modelId !== proof.model.id
701
+ || !terminal || !["completed", "failed", "aborted"].includes(terminal.status) || terminal.tipId !== receipt.tipId
702
+ || meta !== undefined || terminal.kind !== "run" || terminal.fromTipId !== proof.baselineTipId
703
+ || await raw.getValue(operationState(proof.operationId), PI_CONTEXT)) return false;
704
+ const entries = await branch.findEntries({ order: "oldestFirst" }, PI_CONTEXT);
705
+ if (createHash("sha256").update(JSON.stringify(entries)).digest("hex") !== proof.ancestry) return false;
706
+ const baseline = proof.baselineTipId === null ? -1 : entries.findIndex((item) => item.id === proof.baselineTipId);
707
+ if (proof.baselineTipId !== null && baseline < 0) return false;
708
+ const tail = entries.slice(baseline + 1);
709
+ if (tail[0]?.type !== "message" || tail[0].message.role !== "user"
710
+ || tail.filter((item) => item.type === "message" && item.message.role === "user").length !== 1 + proof.inputIds.length) return false;
711
+ if (!validRecoveryProjection(buildPiSessionContext(entries), proof.model)) return false;
712
+ await raw.close(PI_CONTEXT);
713
+ raw = undefined;
714
+ // Pending is cleared only after persistence is certain. Bypass the ordinary
715
+ // sync guard while keeping the public busy reservation throughout the fsync.
716
+ await syncPath(entry.metadata.path);
717
+ await syncPath(dirname(entry.metadata.path));
718
+ entry.recoveryPending = false;
719
+ delete entry.recovery;
720
+ return true;
721
+ } catch {
722
+ return false;
723
+ } finally {
724
+ try { await raw?.close(PI_CONTEXT); } catch { /* already failed closed */ }
725
+ entry.busy = false;
726
+ }
727
+ }
@@ -32,12 +32,30 @@ function toolResultOutcome(result) {
32
32
  code: "code",
33
33
  backend: "backend",
34
34
  signal: "signal",
35
+ contentKind: "content_kind",
36
+ charset: "charset",
37
+ charsetSource: "charset_source",
38
+ extractionStage: "extraction_stage",
39
+ renderReason: "render_reason",
40
+ retryAt: "retry_at",
41
+ nextAction: "next_action",
35
42
  };
36
43
  const numbers = {
37
44
  attempts: "attempts",
45
+ queueWaitMs: "queue_wait_ms",
46
+ backendDurationMs: "backend_duration_ms",
47
+ retryAfterMs: "retry_after_ms",
48
+ maxRequestsPerRun: "max_requests_per_run",
49
+ requestsThisCall: "requests_this_call",
50
+ requestsUsed: "requests_used",
51
+ requestsRemaining: "requests_remaining",
52
+ cooldownSkipCount: "cooldown_skip_count",
53
+ quotaSkipCount: "quota_skip_count",
38
54
  bytes: "bytes",
39
55
  exitCode: "exit_code",
40
56
  statusCode: "status_code",
57
+ redirectCount: "redirect_count",
58
+ parserFailureCount: "parser_failure_count",
41
59
  };
42
60
  const booleans = {
43
61
  retryable: "retryable",
@@ -45,6 +63,11 @@ function toolResultOutcome(result) {
45
63
  truncated: "truncated",
46
64
  timedOut: "timed_out",
47
65
  rendered: "rendered",
66
+ renderFailed: "render_failed",
67
+ browserRecommended: "browser_recommended",
68
+ retryInRun: "retry_in_run",
69
+ fallbackUsed: "fallback_used",
70
+ hadDecodingReplacement: "had_decoding_replacement",
48
71
  };
49
72
  for (const [input, output] of Object.entries(strings)) {
50
73
  if (typeof source[input] === "string") bounded[output] = source[input].slice(0, 120);
@@ -110,18 +133,23 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
110
133
  } else if (event.type === "message_end" && event.message?.role === "assistant") {
111
134
  const contextUsage = contextUsageFromAssistantMessage(event.message);
112
135
  if (contextUsage) {
136
+ const { costUsd, ...contextTokens } = contextUsage;
113
137
  const contextWindow = Number(harness?.getModel?.()?.contextWindow) || 0;
114
138
  const measurementId = typeof event.message?.id === "string" && event.message.id.trim().length > 0
115
139
  ? event.message.id
116
140
  : undefined;
117
141
  onEvent({
118
142
  type: "context_usage",
143
+ ...harness?.getPromptCacheRequest?.(),
119
144
  sdk,
120
145
  model,
121
146
  timestamp: Date.now(),
122
147
  ...(measurementId === undefined ? {} : { measurementId }),
123
148
  ...(contextWindow > 0 ? { contextWindow } : {}),
124
- tokens: contextUsage,
149
+ tokens: contextTokens,
150
+ costUsd,
151
+ providerCostUsd: typeof event.message.usage?.cost?.total === "number" && Number.isFinite(event.message.usage.cost.total) && event.message.usage.cost.total >= 0 ? event.message.usage.cost.total : null,
152
+ costSource: "pi_usage",
125
153
  });
126
154
  }
127
155
  const hasVisibleContent = Array.isArray(event.message?.content)
@@ -216,7 +244,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
216
244
  });
217
245
  } else if (event.type === "turn_end") {
218
246
  runState.turnCount += 1;
219
- // NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.85.0).
247
+ // NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.85.1).
220
248
  // pi's only after-turn stop hook is `shouldStopAfterTurn` on the LOW-LEVEL
221
249
  // `AgentLoopConfig` (dist/types.d.ts) — the config passed to the raw
222
250
  // `agentLoop`. It is NOT surfaced on `AgentHarnessOptions`
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+ import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
3
+
4
+ /** Validate the effective provider projection; Pi supplies honest missing results. */
5
+ export function validRecoveryProjection(messages, model) {
6
+ try {
7
+ const projected = transformMessages(messages, model);
8
+ const seen = new Set();
9
+ const pending = new Map();
10
+ for (const message of projected) {
11
+ if (!["user", "assistant", "toolResult"].includes(message?.role)) return false;
12
+ if (message.role !== "toolResult" && pending.size) return false;
13
+ if (message.role === "user" && typeof message.content === "string") continue;
14
+ if (!Array.isArray(message.content)) return false;
15
+ if (message.role === "toolResult") {
16
+ if (!pending.has(message.toolCallId) || pending.get(message.toolCallId) !== message.toolName) return false;
17
+ pending.delete(message.toolCallId);
18
+ }
19
+ if (message.role === "assistant" && ["error", "aborted", "deferred"].includes(message.stopReason)) return false;
20
+ for (const block of message.content) {
21
+ if (block?.type === "text") { if (typeof block.text !== "string") return false; }
22
+ else if (block?.type === "image") { if (typeof block.data !== "string" || typeof block.mimeType !== "string") return false; }
23
+ else if (block?.type === "thinking") {
24
+ if (message.role !== "assistant" || typeof block.thinking !== "string"
25
+ || (block.thinkingSignature !== undefined && (typeof block.thinkingSignature !== "string" || !block.thinkingSignature))) return false;
26
+ if (block.thinkingSignature && String(model.api).includes("responses")) {
27
+ const signature = JSON.parse(block.thinkingSignature);
28
+ if (signature?.type !== "reasoning" || typeof signature.id !== "string" || !Array.isArray(signature.summary)) return false;
29
+ }
30
+ } else if (block?.type === "toolCall") {
31
+ if (message.role !== "assistant" || typeof block.id !== "string" || !block.id || seen.has(block.id)
32
+ || typeof block.name !== "string" || !block.name || !block.arguments || typeof block.arguments !== "object") return false;
33
+ seen.add(block.id);
34
+ pending.set(block.id, block.name);
35
+ } else return false;
36
+ }
37
+ }
38
+ return pending.size === 0;
39
+ } catch { return false; }
40
+ }