@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
@@ -1,68 +1,102 @@
1
- // Metadata-only live-input acknowledgement instrumentation.
2
- //
3
- // Provider bridges already call message.acknowledge() only after their native
4
- // steering boundary accepts guidance. Wrapping that callback here creates one
5
- // adapter-neutral `live_input_applied` event without copying the guidance body
6
- // into runtime telemetry. A wrapper owns one logical-run dedupe set and is
7
- // intentionally reused by the fallback router across provider attempts.
1
+ // Metadata-only live-input lifecycle instrumentation and logical-run replay fencing.
2
+ // Stable ids identify one logical delivery across provider retries. The first
3
+ // occurrence owns the body and identity; later same-id occurrences are invalid
4
+ // duplicates unless an exact opaque logical-owner token proves they carry a new
5
+ // callback lease for that first owner. Anonymous input remains legal but is
6
+ // never replayed.
8
7
 
9
8
  // @ts-check
10
9
 
11
- const LIVE_INPUT_APPLIED_INSTRUMENTED = Symbol("mono-agent.live-input-applied-instrumented");
10
+ const LIVE_INPUT_INSTRUMENTED = Symbol("mono-agent.live-input-instrumented");
11
+ const MAX_DIAGNOSTICS_PER_KIND = 100;
12
12
 
13
13
  /**
14
- * @typedef {{body: string, id?: string, receivedAt?: string, acknowledge?: () => void, reject?: (reason?: unknown) => void}} RuntimeLiveInputMessage
15
- * @typedef {{type: "live_input_applied", inputId: string, receivedAt?: string}} LiveInputAppliedEvent
14
+ * @typedef {{providerEntryId?: string, providerRunId?: string}} RuntimeLiveInputEvidence
15
+ * @typedef {{reason: "delivery_uncertain", providerEntryId?: string, providerRunId?: string}} RuntimeLiveInputUncertainty
16
+ * @typedef {{body: string, id?: string, receivedAt?: string, logicalOwner?: object, accepted?: (evidence?: RuntimeLiveInputEvidence) => unknown, acknowledge?: (evidence?: RuntimeLiveInputEvidence) => unknown, uncertain?: (details: RuntimeLiveInputUncertainty) => unknown, reject?: (reason?: unknown) => unknown}} RuntimeLiveInputMessage
17
+ * @typedef {{message: RuntimeLiveInputMessage, callbackMessage: RuntimeLiveInputMessage, logicalOwner?: object, inputId: string, receivedAt?: string, phase: "available"|"leased"|"native_accepted"|"consumed"|"uncertain", attempt: number, generation: number}} LiveInputOwner
18
+ * @typedef {{type: string, [key: string]: unknown}} LiveInputEvent
16
19
  */
17
20
 
18
21
  /**
19
22
  * @param {AsyncIterable<RuntimeLiveInputMessage>|undefined} liveInput
20
- * @param {(event: LiveInputAppliedEvent) => void} onApplied
23
+ * @param {(event: LiveInputEvent) => void} onEvent
21
24
  * @returns {AsyncIterable<RuntimeLiveInputMessage>|undefined}
22
25
  */
23
- export function instrumentLiveInputAppliedEvents(liveInput, onApplied) {
26
+ export function instrumentLiveInputAppliedEvents(liveInput, onEvent) {
24
27
  if (liveInput === undefined || isInstrumented(liveInput)) return liveInput;
25
28
 
26
- const appliedInputIds = new Set();
29
+ /** @type {Map<string, LiveInputOwner>} */
30
+ const owners = new Map();
31
+ const diagnosticCounts = new Map();
32
+ let generation = 0;
33
+
27
34
  const instrumented = {
28
- [LIVE_INPUT_APPLIED_INSTRUMENTED]: true,
35
+ [LIVE_INPUT_INSTRUMENTED]: true,
29
36
  [Symbol.asyncIterator]() {
37
+ generation += 1;
38
+ const iteratorGeneration = generation;
30
39
  const iterator = liveInput[Symbol.asyncIterator]();
40
+ const yieldedIds = new Set();
31
41
  let ordinal = 0;
42
+
32
43
  return {
33
44
  async next() {
34
- const next = await iterator.next();
35
- if (next.done === true) return next;
36
- ordinal += 1;
37
- const message = next.value;
38
- const inputId = stableInputId(message?.id, ordinal);
39
- const receivedAt = typeof message?.receivedAt === "string" && message.receivedAt.length > 0
40
- ? message.receivedAt
41
- : undefined;
42
- const acknowledge = typeof message?.acknowledge === "function"
43
- ? message.acknowledge.bind(message)
44
- : undefined;
45
- return {
46
- done: false,
47
- value: {
48
- ...message,
49
- acknowledge: () => {
50
- acknowledge?.();
51
- if (appliedInputIds.has(inputId)) return;
52
- appliedInputIds.add(inputId);
53
- try {
54
- onApplied({
55
- type: "live_input_applied",
56
- inputId,
57
- ...(receivedAt === undefined ? {} : { receivedAt }),
58
- });
59
- } catch {
60
- // Telemetry must never turn accepted guidance into a provider
61
- // failure after the native steering call already succeeded.
62
- }
63
- },
64
- },
65
- };
45
+ while (true) {
46
+ const next = await iterator.next();
47
+ if (next.done === true) return next;
48
+ ordinal += 1;
49
+ const message = next.value;
50
+ const stableId = normalizedStableId(message?.id);
51
+
52
+ if (stableId === undefined) {
53
+ if (iteratorGeneration > 1) {
54
+ emitBounded("anonymous_identity", {
55
+ type: "live_input_replay_suppressed",
56
+ reason: "anonymous_identity",
57
+ generation: iteratorGeneration,
58
+ ordinal,
59
+ });
60
+ continue;
61
+ }
62
+ const owner = createOwner(message, `anonymous:${iteratorGeneration}:${ordinal}`, iteratorGeneration);
63
+ return { done: false, value: lease(owner) };
64
+ }
65
+
66
+ const existing = owners.get(stableId);
67
+ if (existing === undefined) {
68
+ const owner = createOwner(message, stableId, iteratorGeneration);
69
+ owners.set(stableId, owner);
70
+ yieldedIds.add(stableId);
71
+ return { done: false, value: lease(owner) };
72
+ }
73
+
74
+ emitBounded("duplicate_id", {
75
+ type: "live_input_duplicate_suppressed",
76
+ inputId: stableId,
77
+ generation: iteratorGeneration,
78
+ });
79
+ if (
80
+ iteratorGeneration > existing.generation
81
+ && existing.phase === "available"
82
+ && !yieldedIds.has(stableId)
83
+ ) {
84
+ // Suppress the later occurrence itself. It merely reveals that the
85
+ // first owner is present in this replay generation; replay the
86
+ // first owner's immutable body and identity.
87
+ yieldedIds.add(stableId);
88
+ existing.generation = iteratorGeneration;
89
+ if (
90
+ existing.logicalOwner !== undefined
91
+ && existing.logicalOwner === validLogicalOwner(message?.logicalOwner)
92
+ ) {
93
+ // The logical owner remains the first occurrence. This is only
94
+ // its fresh, independently fenced host callback lease.
95
+ existing.callbackMessage = message;
96
+ }
97
+ return { done: false, value: lease(existing) };
98
+ }
99
+ }
66
100
  },
67
101
  async return(value) {
68
102
  return typeof iterator.return === "function"
@@ -76,19 +110,181 @@ export function instrumentLiveInputAppliedEvents(liveInput, onApplied) {
76
110
  };
77
111
  },
78
112
  };
113
+
79
114
  return /** @type {AsyncIterable<RuntimeLiveInputMessage>} */ (instrumented);
115
+
116
+ /** @param {RuntimeLiveInputMessage} message @param {string} inputId @param {number} ownerGeneration */
117
+ function createOwner(message, inputId, ownerGeneration) {
118
+ const receivedAt = validString(message?.receivedAt);
119
+ const logicalOwner = validLogicalOwner(message?.logicalOwner);
120
+ return {
121
+ message,
122
+ callbackMessage: message,
123
+ ...(logicalOwner === undefined ? {} : { logicalOwner }),
124
+ inputId,
125
+ ...(receivedAt === undefined ? {} : { receivedAt }),
126
+ phase: /** @type {const} */ ("available"),
127
+ attempt: 0,
128
+ generation: ownerGeneration,
129
+ };
130
+ }
131
+
132
+ /** @param {LiveInputOwner} owner */
133
+ function lease(owner) {
134
+ owner.phase = "leased";
135
+ owner.attempt += 1;
136
+ const attempt = owner.attempt;
137
+ const host = owner.callbackMessage;
138
+ const base = eventBase(owner);
139
+
140
+ /** @type {RuntimeLiveInputMessage} */
141
+ const wrapped = {
142
+ ...owner.message,
143
+ accepted(evidence) {
144
+ if (!isCurrent(owner, attempt, "leased")) return "ignored";
145
+ owner.phase = "native_accepted";
146
+ const disposition = callHost(host.accepted, host, evidence);
147
+ emit({
148
+ type: "live_input_native_accepted",
149
+ ...base,
150
+ ...safeEvidence(evidence),
151
+ settlementDisposition: disposition,
152
+ });
153
+ return publicDisposition(disposition);
154
+ },
155
+ acknowledge(evidence) {
156
+ if (!isCurrent(owner, attempt, "leased", "native_accepted")) {
157
+ emit({ type: "live_input_consumed", ...base, ...safeEvidence(evidence), late: true });
158
+ return "ignored";
159
+ }
160
+ owner.phase = "consumed";
161
+ emit({ type: "live_input_consumed", ...base, ...safeEvidence(evidence) });
162
+ const disposition = callHost(host.acknowledge, host, evidence);
163
+ if (disposition === "recorded") {
164
+ emit({ type: "live_input_applied", ...base, ...safeEvidence(evidence) });
165
+ } else {
166
+ emit({
167
+ type: "live_input_settlement_unconfirmed",
168
+ ...base,
169
+ phase: "consumed",
170
+ settlementDisposition: disposition,
171
+ });
172
+ }
173
+ return publicDisposition(disposition);
174
+ },
175
+ uncertain(details) {
176
+ if (!isCurrent(owner, attempt, "leased", "native_accepted")) return "ignored";
177
+ owner.phase = "uncertain";
178
+ const normalized = {
179
+ reason: /** @type {const} */ ("delivery_uncertain"),
180
+ ...safeEvidence(details),
181
+ };
182
+ const disposition = callHost(host.uncertain, host, normalized);
183
+ emit({
184
+ type: "live_input_uncertain",
185
+ ...base,
186
+ ...normalized,
187
+ settlementDisposition: disposition,
188
+ });
189
+ return publicDisposition(disposition);
190
+ },
191
+ reject(reason) {
192
+ if (!isCurrent(owner, attempt, "leased", "native_accepted")) return "ignored";
193
+ if (owner.phase === "native_accepted" && !isNativeQueueRemoved(reason)) {
194
+ return wrapped.uncertain?.({ reason: "delivery_uncertain" });
195
+ }
196
+ const disposition = callHost(host.reject, host, reason);
197
+ // "ignored" is affirmative host evidence that this attempt no longer
198
+ // owns settlement (normally because the mailbox already sealed it).
199
+ // Legacy void and thrown callbacks remain replay-compatible because
200
+ // native removal still proves the provider did not consume the input.
201
+ owner.phase = disposition === "ignored" ? "uncertain" : "available";
202
+ return publicDisposition(disposition);
203
+ },
204
+ };
205
+ return wrapped;
206
+ }
207
+
208
+ /** @param {string} kind @param {LiveInputEvent} event */
209
+ function emitBounded(kind, event) {
210
+ const count = diagnosticCounts.get(kind) ?? 0;
211
+ if (count >= MAX_DIAGNOSTICS_PER_KIND) return;
212
+ diagnosticCounts.set(kind, count + 1);
213
+ emit(event);
214
+ }
215
+
216
+ /** @param {LiveInputEvent} event */
217
+ function emit(event) {
218
+ try { onEvent(event); } catch { /* telemetry never changes delivery */ }
219
+ }
220
+ }
221
+
222
+ /** @param {unknown} callback @param {RuntimeLiveInputMessage} receiver @param {unknown} argument */
223
+ function callHost(callback, receiver, argument) {
224
+ if (typeof callback !== "function") return "unconfirmed";
225
+ try {
226
+ const result = callback.call(receiver, argument);
227
+ return result === "recorded" || result === "ignored" ? result : "unconfirmed";
228
+ } catch {
229
+ return "threw";
230
+ }
231
+ }
232
+
233
+ /** @param {string} disposition */
234
+ function publicDisposition(disposition) {
235
+ return disposition === "recorded" || disposition === "ignored" ? disposition : undefined;
236
+ }
237
+
238
+ /** @param {LiveInputOwner} owner */
239
+ function eventBase(owner) {
240
+ return {
241
+ inputId: owner.inputId,
242
+ ...(owner.receivedAt === undefined ? {} : { receivedAt: owner.receivedAt }),
243
+ };
244
+ }
245
+
246
+ /** @param {LiveInputOwner} owner @param {number} attempt @param {...string} phases */
247
+ function isCurrent(owner, attempt, ...phases) {
248
+ return owner.attempt === attempt && phases.includes(owner.phase);
80
249
  }
81
250
 
82
251
  /** @param {unknown} value */
83
- function isInstrumented(value) {
84
- return typeof value === "object"
85
- && value !== null
86
- && value[LIVE_INPUT_APPLIED_INSTRUMENTED] === true;
252
+ function normalizedStableId(value) {
253
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
87
254
  }
88
255
 
89
- /** @param {unknown} value @param {number} ordinal */
90
- function stableInputId(value, ordinal) {
91
- return typeof value === "string" && value.trim().length > 0
256
+ /** @param {unknown} value */
257
+ function validLogicalOwner(value) {
258
+ return value !== null && (typeof value === "object" || typeof value === "function")
92
259
  ? value
93
- : `anonymous:${ordinal}`;
260
+ : undefined;
261
+ }
262
+
263
+ /** @param {unknown} value */
264
+ function validString(value) {
265
+ return typeof value === "string" && value.length > 0 ? value : undefined;
266
+ }
267
+
268
+ /** @param {unknown} value */
269
+ function safeEvidence(value) {
270
+ if (typeof value !== "object" || value === null) return {};
271
+ const evidence = /** @type {{providerEntryId?: unknown, providerRunId?: unknown}} */ (value);
272
+ return {
273
+ ...(validString(evidence.providerEntryId) === undefined ? {} : { providerEntryId: evidence.providerEntryId }),
274
+ ...(validString(evidence.providerRunId) === undefined ? {} : { providerRunId: evidence.providerRunId }),
275
+ };
276
+ }
277
+
278
+ /** @param {unknown} reason */
279
+ function isNativeQueueRemoved(reason) {
280
+ return typeof reason === "object"
281
+ && reason !== null
282
+ && /** @type {{code?: unknown}} */ (reason).code === "native_queue_removed";
283
+ }
284
+
285
+ /** @param {unknown} value */
286
+ function isInstrumented(value) {
287
+ return typeof value === "object"
288
+ && value !== null
289
+ && value[LIVE_INPUT_INSTRUMENTED] === true;
94
290
  }
@@ -28,6 +28,8 @@
28
28
  // Result:
29
29
  // The success run's result, with `failoverHistory` appended describing every
30
30
  // prior attempt: [{ model, failureKind, requestId, retryableSubkind }].
31
+ // Successful retries/backups withhold providerSessionId: their stateless
32
+ // answer cannot synchronize the primary provider session.
31
33
  // If every eligible retryable/auth entry in the chain fails, returns the last
32
34
  // result with `failureKind: "provider_unavailable_exhausted"`. Terminal
33
35
  // non-retryable failures are returned as-is with their failover history.
@@ -42,6 +44,7 @@ import { passthroughSandbox } from "../../agent/sandbox-seam.js";
42
44
  import { resolveRuntimeBrand } from "../../runtime-brand.js";
43
45
  import { createObserverHub } from "../observer.js";
44
46
  import { instrumentLiveInputAppliedEvents } from "./live-input-events.js";
47
+ import { createWebSearchRunState } from "../../agent/tools/web-search-state.js";
45
48
 
46
49
  /**
47
50
  * @typedef {import('../types.js').RuntimeModelRef} RuntimeModelRef
@@ -89,14 +92,17 @@ import { instrumentLiveInputAppliedEvents } from "./live-input-events.js";
89
92
 
90
93
  const ATTEMPT_SCOPED_OPTION_KEYS = ["customProvider", "customModel", "modelCapabilities", "isPrivateProvider"];
91
94
  const ROUTER_TOOL_CONTEXT_KEYS = [
92
- "workspace", "repoRoot", "ripgrepPath", "qaOutputDir", "sandboxPolicy", "sandboxEngine",
95
+ "workspace", "repoRoot", "additionalReadRoots", "additionalWriteRoots",
96
+ "ripgrepPath", "qaOutputDir", "sandboxPolicy", "sandboxEngine",
93
97
  ];
94
98
  const RESOLVER_PROTECTED_OPTION_KEYS = new Set([
95
99
  "model", "effort", "messages", "abortSignal", "onEvent",
96
- "sessionId", "providerSessionId", "sessionKeepAlive", "sessionIdleTimeoutMs",
100
+ "sessionRecovery", "sessionId", "providerSessionId", "providerAttributionSessionId", "sessionKeepAlive", "sessionIdleTimeoutMs",
97
101
  "diagnosticsSeed", "systemPromptPrefix", "sandboxPolicy", "sandboxEngine", "sandbox",
98
102
  "allowedTools", "disallowedTools", "permissionMode", "mcpServers", "mcpApps", "skills",
99
- "outputSchema", "liveInput", "toolEnvironment",
103
+ "mcpCallNoTotalTimeoutTools",
104
+ "webSearchState",
105
+ "outputSchema", "liveInput", "toolEnvironment", "persistArtifact",
100
106
  ]);
101
107
 
102
108
  class ResolverProtectedOptionError extends Error {
@@ -141,6 +147,10 @@ export function createRouterRuntime({ host = {}, chain = [], resolveAttempt, ret
141
147
  * @returns {Promise<RuntimeResult>}
142
148
  */
143
149
  async run(systemPrompt, options = {}) {
150
+ options = {
151
+ ...options,
152
+ webSearchState: createWebSearchRunState(options.webSearchConfig, options.webSearchState),
153
+ };
144
154
  const liveInputHub = options.liveInput === undefined
145
155
  ? undefined
146
156
  : createObserverHub({
@@ -247,13 +257,12 @@ export function createRouterRuntime({ host = {}, chain = [], resolveAttempt, ret
247
257
  }
248
258
 
249
259
  applyEntryEffort(callOptions, entry.effort);
250
- // A provider session belongs to the route AND to the attempt that
251
- // created it. The entire chain is stateless whenever a fallback exists,
252
- // keeping the full logical run replayable regardless of which route is
253
- // attempted. A same-model retry re-sends the whole logical turn, so
254
- // resuming the session the failed attempt already appended into would
255
- // duplicate the turn or hit session_busy.
256
- if (entries.length > 1 || i > 0 || retryIndex > 0 || !entrySupportsSessionResume(entry)) {
260
+ // Only the primary's first attempt may own a provider session. Retries
261
+ // replay the logical turn and must not resume a transcript the failed
262
+ // attempt may have appended to; backup routes never inherit that session.
263
+ const sessionEligibleAttempt = i === 0 && retryIndex === 0 && entrySupportsSessionResume(entry);
264
+ if (!sessionEligibleAttempt) {
265
+ delete callOptions.sessionRecovery;
257
266
  delete callOptions.sessionId;
258
267
  delete callOptions.providerSessionId;
259
268
  delete callOptions.sessionKeepAlive;
@@ -311,6 +320,10 @@ export function createRouterRuntime({ host = {}, chain = [], resolveAttempt, ret
311
320
  }
312
321
 
313
322
  result = normalizeProviderAuthFailure(result);
323
+ if (!sessionEligibleAttempt) {
324
+ const { providerSessionRecovery: _receipt, ...unownedResult } = result;
325
+ result = unownedResult;
326
+ }
314
327
 
315
328
  const retryability = retryableProviderFailureInfo({
316
329
  errorText: result.error || "",
@@ -333,7 +346,10 @@ export function createRouterRuntime({ host = {}, chain = [], resolveAttempt, ret
333
346
  model: modelKey(entry.model),
334
347
  });
335
348
  }
336
- return { ...result, failoverHistory };
349
+ // Pi may report the attribution id as its session id even on a
350
+ // stateless call. Withhold that resumable id so host history cannot
351
+ // synchronize an untouched or failed primary transcript.
352
+ return { ...result, ...(sessionEligibleAttempt ? {} : { providerSessionId: undefined }), failoverHistory };
337
353
  }
338
354
 
339
355
  failoverHistory.push({
@@ -428,6 +444,9 @@ export function createRouterRuntime({ host = {}, chain = [], resolveAttempt, ret
428
444
  configuredTools = { ...(configuredTools || {}), ...next };
429
445
  inner.configureTools?.(next);
430
446
  },
447
+ async recoverSession(receipt, context) {
448
+ return await inner.recoverSession?.(receipt, context) === true;
449
+ },
431
450
  async syncSession(providerSessionId) {
432
451
  return Boolean(await inner.syncSession?.(providerSessionId));
433
452
  },
@@ -15,9 +15,9 @@ const HOST_HISTORY_METADATA = Symbol("mono-agent.host-tool-history");
15
15
  const HOST_TOOL_LIFECYCLE_METADATA = Symbol("mono-agent.host-tool-lifecycle");
16
16
 
17
17
  /**
18
- * @param {{sink?: (event: any) => Promise<any>, onObserve?: (event: any) => void, onEvent?: (event: any) => void, abortSignal?: AbortSignal}} options
18
+ * @param {{sink?: (event: any) => Promise<any>, onObserve?: (event: any) => void, onLifecycleAdmitted?: (event: any) => void, onEvent?: (event: any) => void, abortSignal?: AbortSignal}} options
19
19
  */
20
- export function createToolLifecycleEventGate({ sink, onObserve, onEvent, abortSignal }) {
20
+ export function createToolLifecycleEventGate({ sink, onObserve, onLifecycleAdmitted, onEvent, abortSignal }) {
21
21
  /** @type {Promise<void>} */
22
22
  let tail = Promise.resolve();
23
23
  let pendingDeliveries = 0;
@@ -25,22 +25,30 @@ export function createToolLifecycleEventGate({ sink, onObserve, onEvent, abortSi
25
25
  const timing = new Map();
26
26
  /** @type {Map<string, any>} */
27
27
  const approvals = new Map();
28
+ const preparedBlocks = new WeakSet();
28
29
 
29
30
  const emit = (event) => {
30
31
  stripProviderLifecycleMetadata(event);
31
32
  try { onObserve?.(event); } catch { /* observer callback semantics remain best-effort */ }
33
+ observeClassification(event, timing, approvals);
32
34
  const requiresPersistence = typeof sink === "function" && eventNeedsPersistence(event);
35
+ // Admission/classification follows native emission, not delayed storage.
36
+ const writes = requiresPersistence ? prepareEvent(event, { timing, approvals, abortSignal }, preparedBlocks) : [];
37
+ for (const write of writes) {
38
+ try { onLifecycleAdmitted?.(write.lifecycle); } catch { /* observers remain best-effort */ }
39
+ }
33
40
  if (!requiresPersistence && pendingDeliveries === 0) {
34
- observeClassification(event, timing, approvals);
35
41
  try { onEvent?.(event); } catch { /* host callback semantics remain best-effort */ }
36
42
  return;
37
43
  }
38
44
 
39
45
  pendingDeliveries += 1;
40
46
  const delivery = tail.then(async () => {
41
- observeClassification(event, timing, approvals);
42
47
  if (requiresPersistence) {
43
- await persistEvent(event, sink, { timing, approvals, abortSignal });
48
+ for (const write of writes) {
49
+ const persisted = await safePersist(sink, write.lifecycle);
50
+ try { write.block.history = historyMetadata(persisted, write.state); } catch { /* still settle every admitted write */ }
51
+ }
44
52
  }
45
53
  try { onEvent?.(event); } catch { /* host callback semantics remain best-effort */ }
46
54
  }).catch((error) => {
@@ -118,32 +126,34 @@ function observeClassification(event, timing, approvals) {
118
126
  }
119
127
  }
120
128
 
121
- /** @param {any} event @param {(event:any)=>Promise<any>} sink @param {{timing:Map<string,any>,approvals:Map<string,any>,abortSignal?:AbortSignal}} context */
122
- async function persistEvent(event, sink, context) {
123
- if (!record(event) || (event.type !== "assistant" && event.type !== "user")) return;
129
+ /** @param {any} event @param {{timing:Map<string,any>,approvals:Map<string,any>,abortSignal?:AbortSignal}} context @param {WeakSet<object>} preparedBlocks */
130
+ function prepareEvent(event, context, preparedBlocks) {
131
+ const writes = [];
132
+ if (!record(event) || (event.type !== "assistant" && event.type !== "user")) return writes;
124
133
  const message = event.message;
125
- if (!record(message) || !Array.isArray(message.content)) return;
134
+ if (!record(message) || !Array.isArray(message.content)) return writes;
126
135
  for (const block of message.content) {
127
136
  if (!record(block)) continue;
128
137
  if (event.type === "assistant" && block.type === "tool_use") {
129
- if (hostHistoryMetadata(block.history)) continue;
138
+ if (hostHistoryMetadata(block.history) || preparedBlocks.has(block)) continue;
130
139
  if (typeof block.id !== "string" || typeof block.name !== "string") continue;
131
- const persisted = await safePersist(sink, {
140
+ const lifecycle = {
132
141
  phase: "invocation",
133
142
  toolCallId: block.id,
134
143
  toolName: block.name,
135
144
  ...(Object.hasOwn(block, "input") ? { arguments: block.input } : {}),
136
- });
137
- block.history = historyMetadata(persisted, undefined);
145
+ };
146
+ preparedBlocks.add(block);
147
+ writes.push({ block, lifecycle, state: undefined });
138
148
  continue;
139
149
  }
140
150
  if (event.type === "user" && block.type === "tool_result") {
141
- if (hostHistoryMetadata(block.history)) continue;
151
+ if (hostHistoryMetadata(block.history) || preparedBlocks.has(block)) continue;
142
152
  const id = typeof block.tool_use_id === "string" ? block.tool_use_id
143
153
  : typeof block.tool_call_id === "string" ? block.tool_call_id : undefined;
144
154
  if (id === undefined) continue;
145
155
  const classified = classifyGenericResult(block, context.timing.get(id), context.approvals.get(id), context.abortSignal);
146
- const persisted = await safePersist(sink, {
156
+ const lifecycle = {
147
157
  phase: "result",
148
158
  toolCallId: id,
149
159
  ...(typeof block.name === "string" ? { toolName: block.name } : {}),
@@ -153,12 +163,14 @@ async function persistEvent(event, sink, context) {
153
163
  ? { executionMs: context.timing.get(id).execution_ms }
154
164
  : {}),
155
165
  artifacts: artifactPaths(block),
156
- });
157
- block.history = historyMetadata(persisted, classified.state);
166
+ };
167
+ preparedBlocks.add(block);
168
+ writes.push({ block, lifecycle, state: classified.state });
158
169
  context.timing.delete(id);
159
170
  context.approvals.delete(id);
160
171
  }
161
172
  }
173
+ return writes;
162
174
  }
163
175
 
164
176
  /** @param {any} block @param {any} timing @param {any} approval @param {AbortSignal|undefined} abortSignal */
@@ -219,7 +231,9 @@ export function historyMetadata(persisted, terminalStateValue) {
219
231
  const metadata = {
220
232
  ...(typeof persisted?.recordId === "string" ? { recordId: persisted.recordId } : {}),
221
233
  ...(Number.isFinite(Number(persisted?.sequence)) ? { sequence: Number(persisted.sequence) } : {}),
222
- persistence: persisted?.persistence === "persisted" ? "persisted" : "failed",
234
+ persistence: persisted?.persistence === "persisted" || persisted?.persistence === "deferred"
235
+ ? persisted.persistence
236
+ : "failed",
223
237
  ...(terminalStateValue === undefined ? {} : { terminalState: terminalStateValue }),
224
238
  ...(typeof persisted?.truncated === "boolean" ? { truncated: persisted.truncated } : {}),
225
239
  ...(Number.isFinite(Number(persisted?.originalBytes)) ? { originalBytes: Number(persisted.originalBytes) } : {}),