@oh-my-pi/pi-coding-agent 16.2.0 → 16.2.2

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 (67) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli.js +2709 -2715
  3. package/dist/types/advisor/runtime.d.ts +15 -1
  4. package/dist/types/advisor/watchdog.d.ts +11 -0
  5. package/dist/types/config/model-roles.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +32 -12
  7. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  8. package/dist/types/edit/index.d.ts +18 -0
  9. package/dist/types/edit/streaming.d.ts +30 -0
  10. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  11. package/dist/types/extensibility/shared-events.d.ts +1 -0
  12. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  13. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  14. package/dist/types/modes/components/status-line/component.d.ts +0 -2
  15. package/dist/types/sdk.d.ts +1 -1
  16. package/dist/types/session/agent-session.d.ts +32 -2
  17. package/dist/types/session/blob-store.d.ts +4 -0
  18. package/dist/types/session/messages.d.ts +6 -7
  19. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  20. package/dist/types/session/turn-persistence.d.ts +88 -0
  21. package/package.json +12 -12
  22. package/src/advisor/__tests__/advisor.test.ts +217 -0
  23. package/src/advisor/runtime.ts +65 -2
  24. package/src/advisor/watchdog.ts +15 -0
  25. package/src/auto-thinking/classifier.ts +2 -2
  26. package/src/config/model-resolver.ts +5 -1
  27. package/src/config/model-roles.ts +3 -3
  28. package/src/config/settings-schema.ts +30 -8
  29. package/src/discovery/claude-plugins.ts +3 -2
  30. package/src/discovery/omp-extension-roots.ts +38 -13
  31. package/src/edit/index.ts +21 -0
  32. package/src/edit/streaming.ts +170 -0
  33. package/src/extensibility/custom-tools/types.ts +1 -0
  34. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  35. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  36. package/src/extensibility/plugins/manager.ts +74 -4
  37. package/src/extensibility/shared-events.ts +1 -0
  38. package/src/internal-urls/docs-index.generated.txt +1 -1
  39. package/src/mcp/oauth-discovery.ts +5 -29
  40. package/src/mcp/transports/http.ts +3 -1
  41. package/src/mnemopi/backend.ts +2 -2
  42. package/src/modes/acp/acp-agent.ts +15 -2
  43. package/src/modes/acp/acp-event-mapper.ts +89 -25
  44. package/src/modes/components/assistant-message.ts +5 -5
  45. package/src/modes/components/status-line/component.ts +1 -9
  46. package/src/modes/components/status-line/segments.ts +1 -1
  47. package/src/modes/controllers/event-controller.ts +8 -11
  48. package/src/modes/interactive-mode.ts +0 -5
  49. package/src/modes/print-mode.ts +1 -1
  50. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  51. package/src/modes/utils/ui-helpers.ts +5 -4
  52. package/src/prompts/advisor/context-files.md +8 -0
  53. package/src/sdk.ts +23 -27
  54. package/src/session/agent-session.ts +330 -219
  55. package/src/session/blob-store.ts +24 -0
  56. package/src/session/messages.ts +20 -12
  57. package/src/session/session-persistence.ts +1 -2
  58. package/src/session/settings-stream-fn.ts +49 -0
  59. package/src/session/turn-persistence.ts +142 -0
  60. package/src/session/unexpected-stop-classifier.ts +2 -2
  61. package/src/slash-commands/helpers/mcp.ts +2 -1
  62. package/src/tiny/models.ts +8 -6
  63. package/src/tiny/text.ts +14 -7
  64. package/src/tools/image-gen.ts +2 -1
  65. package/src/tools/tts.ts +2 -1
  66. package/src/utils/title-generator.ts +1 -1
  67. package/src/web/search/providers/tavily.ts +36 -19
@@ -36,6 +36,7 @@ import {
36
36
  type CompactionSummaryMessage,
37
37
  countTokens,
38
38
  resolveTelemetry,
39
+ type StreamFn,
39
40
  ThinkingLevel,
40
41
  type ToolChoiceDirective,
41
42
  } from "@oh-my-pi/pi-agent-core";
@@ -80,6 +81,7 @@ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/to
80
81
  import type {
81
82
  AssistantMessage,
82
83
  AssistantMessageEvent,
84
+ Context,
83
85
  ImageContent,
84
86
  Message,
85
87
  MessageAttribution,
@@ -102,18 +104,13 @@ import {
102
104
  clearAnthropicFastModeFallback,
103
105
  deriveClaudeDeviceId,
104
106
  Effort,
105
- isContextOverflow,
106
- isUsageLimitError,
107
107
  parseRateLimitReason,
108
108
  resolveServiceTier,
109
109
  streamSimple,
110
110
  } from "@oh-my-pi/pi-ai";
111
+ import * as AIError from "@oh-my-pi/pi-ai/error";
111
112
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
112
- import {
113
- GeminiHeaderRunDetector,
114
- isGeminiThinkingModel,
115
- THINKING_LOOP_ERROR_MARKER,
116
- } from "@oh-my-pi/pi-ai/utils/thinking-loop";
113
+ import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
117
114
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
118
115
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
119
116
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
@@ -125,7 +122,6 @@ import {
125
122
  getInstallId,
126
123
  isBunTestRuntime,
127
124
  isEnoent,
128
- isUnexpectedSocketCloseMessage,
129
125
  logger,
130
126
  prompt,
131
127
  relativePathWithinRoot,
@@ -307,7 +303,6 @@ import {
307
303
  type BashExecutionMessage,
308
304
  type CustomMessage,
309
305
  convertToLlm,
310
- GENERIC_ABORT_SENTINEL,
311
306
  type PythonExecutionMessage,
312
307
  readQueueChipText,
313
308
  SILENT_ABORT_MARKER,
@@ -324,6 +319,7 @@ import { formatSessionHistoryMarkdown } from "./session-history-format";
324
319
  import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
325
320
  import type { ShakeMode, ShakeResult } from "./shake-types";
326
321
  import { ToolChoiceQueue } from "./tool-choice-queue";
322
+ import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence";
327
323
  import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
328
324
  import { YieldQueue } from "./yield-queue";
329
325
 
@@ -369,7 +365,14 @@ export type AgentSessionEvent =
369
365
  /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
370
366
  skipped?: boolean;
371
367
  }
372
- | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
368
+ | {
369
+ type: "auto_retry_start";
370
+ attempt: number;
371
+ maxAttempts: number;
372
+ delayMs: number;
373
+ errorMessage: string;
374
+ errorId?: number;
375
+ }
373
376
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
374
377
  | { type: "retry_fallback_applied"; from: string; to: string; role: string }
375
378
  | { type: "retry_fallback_succeeded"; model: string; role: string }
@@ -512,6 +515,21 @@ export interface AgentSessionConfig {
512
515
  builtInToolNames?: Iterable<string>;
513
516
  /** Current session pre-LLM message transform pipeline */
514
517
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
518
+ /**
519
+ * Per-request transform applied after `convertToLlm` and before the
520
+ * provider call. Used for snapcompact, secret obfuscation, and image
521
+ * clamping. When supplied via {@link createAgentSession}, the advisor agent
522
+ * inherits this so its requests undergo the same shaping as the main turn.
523
+ */
524
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
525
+ /**
526
+ * Stream wrapper passed to the advisor agent so its requests apply the
527
+ * session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
528
+ * `providers.maxInFlightRequests`, and `model.loopGuard.*` settings —
529
+ * keeping OpenRouter sticky-routing / response caching consistent with the
530
+ * main agent. Defaults to plain `streamSimple` when omitted.
531
+ */
532
+ advisorStreamFn?: StreamFn;
515
533
  /** Provider payload hook used by the active session request path */
516
534
  onPayload?: SimpleStreamOptions["onPayload"];
517
535
  /** Provider response hook used by the active session request path */
@@ -589,6 +607,12 @@ export interface AgentSessionConfig {
589
607
  advisorReadOnlyTools?: AgentTool[];
590
608
  /** Preloaded watchdog prompt content for the advisor. */
591
609
  advisorWatchdogPrompt?: string;
610
+ /**
611
+ * Preloaded project context files (AGENTS.md, etc.) rendered as a system-prompt
612
+ * block for the advisor — the same standing instructions the primary agent
613
+ * receives, so the reviewer holds the agent to them.
614
+ */
615
+ advisorContextPrompt?: string;
592
616
  /**
593
617
  * Strip tool descriptions from provider-bound tool specs on side requests
594
618
  * (handoff). Must match the session-start value used to build the system
@@ -1220,6 +1244,7 @@ export class AgentSession {
1220
1244
  #advisorAdviseTool?: AdviseTool;
1221
1245
  #advisorReadOnlyTools?: AgentTool[];
1222
1246
  #advisorWatchdogPrompt?: string;
1247
+ #advisorContextPrompt?: string;
1223
1248
  #advisorYieldQueueUnsubscribe?: () => void;
1224
1249
  /** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
1225
1250
  * attribution and Agent Hub observability. Undefined when no advisor is active. */
@@ -1326,6 +1351,8 @@ export class AgentSession {
1326
1351
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
1327
1352
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
1328
1353
  #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
1354
+ #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1355
+ #advisorStreamFn: StreamFn | undefined;
1329
1356
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1330
1357
  #rebuildSystemPrompt:
1331
1358
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -1380,6 +1407,7 @@ export class AgentSession {
1380
1407
  * `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so
1381
1408
  * it cannot leak into later unrelated aborts. */
1382
1409
  #planInternalAbortPending = false;
1410
+ #pendingAbortErrorId?: number;
1383
1411
 
1384
1412
  #postPromptTasks = new Set<Promise<unknown>>();
1385
1413
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
@@ -1643,12 +1671,15 @@ export class AgentSession {
1643
1671
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1644
1672
  this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1645
1673
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1674
+ this.#advisorContextPrompt = config.advisorContextPrompt;
1646
1675
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1647
1676
  this.#validateRetryFallbackChains();
1648
1677
  this.#toolRegistry = config.toolRegistry ?? new Map();
1649
1678
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
1650
1679
  this.#requestedToolNames = config.requestedToolNames;
1651
1680
  this.#transformContext = config.transformContext ?? (messages => messages);
1681
+ this.#transformProviderContext = config.transformProviderContext;
1682
+ this.#advisorStreamFn = config.advisorStreamFn;
1652
1683
  this.#onPayload = config.onPayload;
1653
1684
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
1654
1685
  // Avoid wrapping in an `async` closure when no user callback is configured: the
@@ -1960,6 +1991,9 @@ export class AgentSession {
1960
1991
  const appendOnlyContext = new AppendOnlyContextManager();
1961
1992
  const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
1962
1993
  const systemPrompt = [advisorSystemPrompt];
1994
+ if (this.#advisorContextPrompt) {
1995
+ systemPrompt.push(this.#advisorContextPrompt);
1996
+ }
1963
1997
  if (this.#advisorWatchdogPrompt) {
1964
1998
  systemPrompt.push(this.#advisorWatchdogPrompt);
1965
1999
  }
@@ -1992,6 +2026,25 @@ export class AgentSession {
1992
2026
  conversationId: undefined,
1993
2027
  }
1994
2028
  : undefined;
2029
+ // Mirror the provider-shaping options the SDK installs on the main agent
2030
+ // so the advisor's requests cache, route, and obfuscate identically:
2031
+ //
2032
+ // - `streamFn`/`advisorStreamFn` carries the session's OpenRouter sticky
2033
+ // variant, antigravity endpoint mode, in-flight cap, and loop guard.
2034
+ // - `onPayload`/`onResponse`/`onSseEvent` keep extension hooks plus the
2035
+ // per-session `RawSseDebugBuffer` recording advisor traffic too.
2036
+ // - `providerSessionState` shares Codex websockets / Anthropic fast-mode
2037
+ // fallback state with the main agent so both turns reuse the same
2038
+ // transport caches.
2039
+ // - `promptCacheKey` pins OpenAI Responses (and any provider that reads
2040
+ // `prompt_cache_key`) to the advisor session id so consecutive advisor
2041
+ // turns land on the same cache shard.
2042
+ // - `transformProviderContext` applies snapcompact, secret obfuscation,
2043
+ // and image clamping to advisor requests like the main turn.
2044
+ //
2045
+ // Without this parity, OpenRouter advisor calls bypassed the variant
2046
+ // suffix and prompt-cache key and produced inconsistent cache hits
2047
+ // (see can1357/oh-my-pi#3639).
1995
2048
  const advisorAgent = new Agent({
1996
2049
  initialState: {
1997
2050
  systemPrompt,
@@ -2001,7 +2054,14 @@ export class AgentSession {
2001
2054
  },
2002
2055
  appendOnlyContext,
2003
2056
  sessionId: advisorSessionId,
2057
+ promptCacheKey: advisorSessionId,
2058
+ providerSessionState: this.#providerSessionState,
2004
2059
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2060
+ streamFn: this.#advisorStreamFn,
2061
+ onPayload: this.#onPayload,
2062
+ onResponse: this.#onResponse,
2063
+ onSseEvent: this.#onSseEvent,
2064
+ transformProviderContext: this.#transformProviderContext,
2005
2065
  intentTracing: false,
2006
2066
  telemetry: advisorTelemetry,
2007
2067
  serviceTier: advisorServiceTier,
@@ -2016,6 +2076,19 @@ export class AgentSession {
2016
2076
  advisorAgent.reset();
2017
2077
  appendOnlyContext.log.clear();
2018
2078
  },
2079
+ rollbackTo: count => {
2080
+ // Drop the failed user batch + synthetic assistant-error turn
2081
+ // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
2082
+ // The append-only context auto-resyncs on the next prompt's
2083
+ // `syncMessages` shrink path, but reset the sync cursor so the log
2084
+ // can't carry the dropped tail forward if no further turn ever runs.
2085
+ const messages = advisorAgent.state.messages;
2086
+ if (count < messages.length) {
2087
+ messages.length = count;
2088
+ }
2089
+ appendOnlyContext.resetSyncCursor();
2090
+ advisorAgent.state.error = undefined;
2091
+ },
2019
2092
  state: advisorAgent.state,
2020
2093
  };
2021
2094
 
@@ -2038,6 +2111,14 @@ export class AgentSession {
2038
2111
  maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
2039
2112
  obfuscator: this.#obfuscator,
2040
2113
  beginAdvisorUpdate: () => this.#advisorEmissionGuard.beginUpdate(),
2114
+ notifyFailure: error => {
2115
+ const message = error instanceof Error ? error.message : String(error);
2116
+ this.emitNotice(
2117
+ "warning",
2118
+ `Advisor unavailable for ${formatModelString(advisorSel.model)}: ${message}`,
2119
+ "advisor",
2120
+ );
2121
+ },
2041
2122
  });
2042
2123
  if (seedToCurrent) {
2043
2124
  this.#advisorRuntime.seedTo(this.agent.state.messages.length);
@@ -2575,82 +2656,8 @@ export class AgentSession {
2575
2656
  }
2576
2657
  };
2577
2658
 
2578
- #messageValueSignature(value: unknown): string {
2579
- return JSON.stringify(value) ?? "undefined";
2580
- }
2581
-
2582
- #sessionMessagesReferToSameTurn(left: AgentMessage, right: AgentMessage): boolean {
2583
- if (left === right) return true;
2584
- if (left.role !== right.role) return false;
2585
- switch (left.role) {
2586
- case "assistant":
2587
- if (right.role !== "assistant") return false;
2588
- return (
2589
- left.timestamp === right.timestamp &&
2590
- left.provider === right.provider &&
2591
- left.model === right.model &&
2592
- left.responseId === right.responseId &&
2593
- left.stopReason === right.stopReason &&
2594
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2595
- );
2596
- case "toolResult":
2597
- if (right.role !== "toolResult") return false;
2598
- return (
2599
- left.timestamp === right.timestamp &&
2600
- left.toolCallId === right.toolCallId &&
2601
- left.toolName === right.toolName &&
2602
- left.isError === right.isError &&
2603
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2604
- );
2605
- case "user":
2606
- if (right.role !== "user") return false;
2607
- return (
2608
- left.timestamp === right.timestamp &&
2609
- left.attribution === right.attribution &&
2610
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2611
- );
2612
- case "developer":
2613
- if (right.role !== "developer") return false;
2614
- return (
2615
- left.timestamp === right.timestamp &&
2616
- left.attribution === right.attribution &&
2617
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2618
- );
2619
- case "fileMention":
2620
- if (right.role !== "fileMention") return false;
2621
- return (
2622
- left.timestamp === right.timestamp &&
2623
- this.#messageValueSignature(left.files) === this.#messageValueSignature(right.files)
2624
- );
2625
- default:
2626
- return false;
2627
- }
2628
- }
2629
-
2630
- #sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
2631
- switch (message.role) {
2632
- case "assistant":
2633
- return [
2634
- "assistant",
2635
- message.timestamp,
2636
- message.provider,
2637
- message.model,
2638
- message.responseId ?? "",
2639
- message.stopReason,
2640
- ].join(":");
2641
- case "toolResult":
2642
- return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
2643
- case "user":
2644
- case "developer":
2645
- case "fileMention":
2646
- return `${message.role}:${message.timestamp}`;
2647
- default:
2648
- return undefined;
2649
- }
2650
- }
2651
-
2652
2659
  #createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
2653
- const key = this.#sessionMessagePersistenceKey(message);
2660
+ const key = sessionMessagePersistenceKey(message);
2654
2661
  if (!key) return undefined;
2655
2662
  const previous = this.#messageEndPersistenceTail;
2656
2663
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -2680,31 +2687,61 @@ export class AgentSession {
2680
2687
  }
2681
2688
 
2682
2689
  async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
2683
- const key = this.#sessionMessagePersistenceKey(message);
2690
+ const key = sessionMessagePersistenceKey(message);
2684
2691
  if (!key) return;
2685
2692
  await this.#pendingMessageEndPersistence.get(key);
2686
2693
  }
2687
2694
 
2688
- #sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
2689
- const branch = this.sessionManager.getBranch();
2690
- for (let index = branch.length - 1; index >= 0; index--) {
2691
- const entry = branch[index];
2692
- if (entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message)) return true;
2695
+ /**
2696
+ * Index every message entry on the current branch by persistence key, so
2697
+ * the mid-run-compaction planner can ask "is this turn message already on
2698
+ * the branch?" in O(1) instead of re-walking the branch per check.
2699
+ *
2700
+ * The Map's value is the list of branch messages that share a key — almost
2701
+ * always one. We only need the LIST when content equality matters (rare
2702
+ * collision tiebreaker via {@link sameMessageContent}); the empty/single-
2703
+ * entry common case lets the caller's lookup short-circuit at presence.
2704
+ *
2705
+ * Pre-#3629 the equivalent was `sessionManager.getBranch()` called twice
2706
+ * per turn message, each call rebuilding the path via O(n²) `unshift` and
2707
+ * structurally JSON-comparing every entry — seconds of synchronous work
2708
+ * per `onTurnEnd` on a long session and the load-bearing source of the
2709
+ * `ui.loop-blocked` warnings in the bug report.
2710
+ */
2711
+ #indexPersistedMessagesByKey(): Map<string, AgentMessage[]> {
2712
+ const index = new Map<string, AgentMessage[]>();
2713
+ for (const entry of this.sessionManager.getBranch()) {
2714
+ if (entry.type !== "message") continue;
2715
+ const key = sessionMessagePersistenceKey(entry.message);
2716
+ if (key === undefined) continue;
2717
+ const existing = index.get(key);
2718
+ if (existing) existing.push(entry.message);
2719
+ else index.set(key, [entry.message]);
2693
2720
  }
2694
- return false;
2721
+ return index;
2695
2722
  }
2696
2723
 
2697
- #hasPersistedLaterTurnMessage(turnMessages: AgentMessage[], messageIndex: number): boolean {
2724
+ /**
2725
+ * True when {@link message} is structurally identical to a message already
2726
+ * appended to the current branch. Pairs a fast persistence-key lookup with
2727
+ * a content-equality fallback so two logically distinct messages that
2728
+ * happen to collide on the cheap key (e.g. two assistant turns at the same
2729
+ * millisecond with `undefined` responseId) still count as DISTINCT.
2730
+ */
2731
+ #sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
2732
+ const key = sessionMessagePersistenceKey(message);
2733
+ if (key === undefined) return false;
2698
2734
  const branch = this.sessionManager.getBranch();
2699
- for (let index = messageIndex + 1; index < turnMessages.length; index++) {
2700
- const message = turnMessages[index];
2701
- if (
2702
- branch.some(
2703
- entry => entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message),
2704
- )
2705
- ) {
2706
- return true;
2707
- }
2735
+ // Reverse walk: recently-appended entries are at the tail, so the common
2736
+ // "is the message I just emitted already in the branch?" lookup short-
2737
+ // circuits in O(1) hot, O(branch) cold. Cheap-key compare for every
2738
+ // entry; content compare only when the cheap check matches, so the
2739
+ // expensive `JSON.stringify(content)` path stays off the hot loop.
2740
+ for (let index = branch.length - 1; index >= 0; index--) {
2741
+ const entry = branch[index];
2742
+ if (entry.type !== "message") continue;
2743
+ if (sessionMessagePersistenceKey(entry.message) !== key) continue;
2744
+ if (sameMessageContent(entry.message, message)) return true;
2708
2745
  }
2709
2746
  return false;
2710
2747
  }
@@ -2745,17 +2782,36 @@ export class AgentSession {
2745
2782
  for (const message of turnMessages) {
2746
2783
  await this.#waitForSessionMessagePersistence(message);
2747
2784
  }
2785
+ // One branch snapshot + one persistence-key index drives the entire
2786
+ // planning pass. Pre-#3629 this re-walked the branch and structurally
2787
+ // JSON-compared every entry per turn message, which on long sessions
2788
+ // turned each `onTurnEnd` into a seconds-long sync block (the
2789
+ // `ui.loop-blocked` warnings tagged `subagent:*` in the bug report).
2790
+ const branchIndex = this.#indexPersistedMessagesByKey();
2791
+ const turnKeys = turnMessages.map(sessionMessagePersistenceKey);
2792
+ const persistedKeys = new Set<string>();
2748
2793
  for (let index = 0; index < turnMessages.length; index++) {
2749
- const message = turnMessages[index];
2750
- if (this.#sessionMessageAlreadyPersisted(message)) continue;
2751
- if (this.#hasPersistedLaterTurnMessage(turnMessages, index)) {
2752
- logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
2753
- role: message.role,
2754
- timestamp: message.timestamp,
2755
- });
2756
- return false;
2757
- }
2758
- this.#persistSessionMessageIfMissing(message);
2794
+ const key = turnKeys[index];
2795
+ if (key === undefined) continue;
2796
+ const candidates = branchIndex.get(key);
2797
+ if (!candidates) continue;
2798
+ // Key match only counts when content also matches — two distinct
2799
+ // messages that collided on the cheap key must STILL be persisted.
2800
+ if (candidates.some(persisted => sameMessageContent(persisted, turnMessages[index]))) {
2801
+ persistedKeys.add(key);
2802
+ }
2803
+ }
2804
+ const plan = planTurnPersistence(turnKeys, persistedKeys);
2805
+ if (plan.kind === "out-of-order") {
2806
+ const message = turnMessages[plan.messageIndex];
2807
+ logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
2808
+ role: message.role,
2809
+ timestamp: message.timestamp,
2810
+ });
2811
+ return false;
2812
+ }
2813
+ for (const index of plan.toPersist) {
2814
+ this.#persistSessionMessageIfMissing(turnMessages[index]);
2759
2815
  }
2760
2816
  return true;
2761
2817
  }
@@ -2774,11 +2830,17 @@ export class AgentSession {
2774
2830
  if (
2775
2831
  event.type === "message_end" &&
2776
2832
  event.message.role === "assistant" &&
2777
- event.message.stopReason === "aborted" &&
2778
- this.#planInternalAbortPending
2833
+ event.message.stopReason === "aborted"
2779
2834
  ) {
2780
- (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
2781
- this.#planInternalAbortPending = false;
2835
+ const message = event.message as AssistantMessage;
2836
+ if (this.#planInternalAbortPending) {
2837
+ message.errorMessage = SILENT_ABORT_MARKER;
2838
+ message.errorId = AIError.create(AIError.Flag.SilentAbort);
2839
+ this.#planInternalAbortPending = false;
2840
+ } else if (this.#pendingAbortErrorId) {
2841
+ message.errorId = this.#pendingAbortErrorId;
2842
+ this.#pendingAbortErrorId = undefined;
2843
+ }
2782
2844
  }
2783
2845
 
2784
2846
  const messageEndPersistence =
@@ -3091,7 +3153,7 @@ export class AgentSession {
3091
3153
  if (
3092
3154
  msg.stopReason === "error" &&
3093
3155
  msg.provider === "github-copilot" &&
3094
- msg.errorMessage?.includes("GitHub Copilot authentication failed")
3156
+ AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed)
3095
3157
  ) {
3096
3158
  await this.#modelRegistry.authStorage.remove("github-copilot");
3097
3159
  }
@@ -3705,10 +3767,34 @@ export class AgentSession {
3705
3767
 
3706
3768
  context.toolName = toolCall.name;
3707
3769
  context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
3708
- context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
3770
+ context.filePaths = this.#extractTtsrToolFilePaths(toolCall);
3709
3771
  return context;
3710
3772
  }
3711
3773
 
3774
+ /**
3775
+ * Resolve the file paths a tool call would touch for TTSR path-glob matching.
3776
+ *
3777
+ * Prefer the tool's own `matcherPaths` hook — it understands the wire format
3778
+ * (hashline `[path#TAG]` section headers, apply_patch envelope markers) and
3779
+ * surfaces paths the generic top-level argument scan never sees. Fall back
3780
+ * to {@link #extractTtsrFilePathsFromArgs} for tools that pass paths as
3781
+ * `path`/`paths` arguments and for tool calls whose payload has not yet
3782
+ * streamed a header.
3783
+ */
3784
+ #extractTtsrToolFilePaths(toolCall: ToolCall): string[] | undefined {
3785
+ const args = toolCall.arguments ?? {};
3786
+ const tools = this.agent.state.tools;
3787
+ const tool =
3788
+ tools.find(t => t.name === toolCall.name) ??
3789
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
3790
+ const toolPaths = tool?.matcherPaths?.(args);
3791
+ if (toolPaths && toolPaths.length > 0) {
3792
+ const normalized = toolPaths.flatMap(p => this.#normalizeTtsrPathCandidates(p));
3793
+ if (normalized.length > 0) return Array.from(new Set(normalized));
3794
+ }
3795
+ return this.#extractTtsrFilePathsFromArgs(args);
3796
+ }
3797
+
3712
3798
  /**
3713
3799
  * Match a stream delta against TTSR rules.
3714
3800
  *
@@ -3722,6 +3808,14 @@ export class AgentSession {
3722
3808
  if (!manager) {
3723
3809
  return [];
3724
3810
  }
3811
+ const entries = this.#resolveTtsrMatcherEntries(toolCall);
3812
+ if (entries) {
3813
+ const matches: Rule[] = [];
3814
+ for (const entry of entries) {
3815
+ matches.push(...manager.checkSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path)));
3816
+ }
3817
+ return matches;
3818
+ }
3725
3819
  const digest = this.#resolveTtsrMatcherDigest(toolCall);
3726
3820
  if (digest !== undefined) {
3727
3821
  return manager.checkSnapshot(digest, matchContext);
@@ -3731,14 +3825,44 @@ export class AgentSession {
3731
3825
 
3732
3826
  /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
3733
3827
  #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
3734
- if (!toolCall) {
3735
- return undefined;
3736
- }
3828
+ const tool = this.#resolveTtsrTool(toolCall);
3829
+ return tool?.matcherDigest?.(toolCall?.arguments ?? {});
3830
+ }
3831
+
3832
+ /**
3833
+ * Per-file split of a streamed call (one entry per touched file paired with
3834
+ * the digest of only that file's added lines). Lets {@link #checkTtsrStream}
3835
+ * and {@link #checkTtsrAstStream} evaluate each file in isolation so a
3836
+ * path-scoped rule like `tool:edit(*.ts)` never fires on text that belongs
3837
+ * to a sibling Markdown hunk in a multi-file payload.
3838
+ */
3839
+ #resolveTtsrMatcherEntries(toolCall: ToolCall | undefined): readonly { path: string; digest: string }[] | undefined {
3840
+ const tool = this.#resolveTtsrTool(toolCall);
3841
+ const entries = tool?.matcherEntries?.(toolCall?.arguments ?? {});
3842
+ return entries && entries.length > 0 ? entries : undefined;
3843
+ }
3844
+
3845
+ #resolveTtsrTool(toolCall: ToolCall | undefined) {
3846
+ if (!toolCall) return undefined;
3737
3847
  const tools = this.agent.state.tools;
3738
- const tool =
3848
+ return (
3739
3849
  tools.find(t => t.name === toolCall.name) ??
3740
- tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
3741
- return tool?.matcherDigest?.(toolCall.arguments ?? {});
3850
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
3851
+ );
3852
+ }
3853
+
3854
+ /**
3855
+ * Replace `matchContext`'s `filePaths` + `streamKey` so a per-file entry
3856
+ * gets its own glob-eligible path and its own TTSR buffer/repeat tracking
3857
+ * (each file's stream is independent inside the same tool call).
3858
+ */
3859
+ #perFileTtsrContext(base: TtsrMatchContext, filePath: string): TtsrMatchContext {
3860
+ const filePaths = this.#normalizeTtsrPathCandidates(filePath);
3861
+ return {
3862
+ ...base,
3863
+ filePaths: filePaths.length > 0 ? filePaths : [filePath],
3864
+ streamKey: base.streamKey ? `${base.streamKey}#${filePath}` : undefined,
3865
+ };
3742
3866
  }
3743
3867
 
3744
3868
  /**
@@ -3753,6 +3877,16 @@ export class AgentSession {
3753
3877
  if (!manager) {
3754
3878
  return [];
3755
3879
  }
3880
+ const entries = this.#resolveTtsrMatcherEntries(toolCall);
3881
+ if (entries) {
3882
+ const matches: Rule[] = [];
3883
+ for (const entry of entries) {
3884
+ matches.push(
3885
+ ...(await manager.checkAstSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path))),
3886
+ );
3887
+ }
3888
+ return matches;
3889
+ }
3756
3890
  const digest = this.#resolveTtsrMatcherDigest(toolCall);
3757
3891
  if (digest === undefined) {
3758
3892
  return [];
@@ -4482,6 +4616,7 @@ export class AgentSession {
4482
4616
  maxAttempts: event.maxAttempts,
4483
4617
  delayMs: event.delayMs,
4484
4618
  errorMessage: event.errorMessage,
4619
+ errorId: event.errorId,
4485
4620
  });
4486
4621
  } else if (event.type === "auto_retry_end") {
4487
4622
  await this.#extensionRunner.emit({
@@ -7256,6 +7391,7 @@ export class AgentSession {
7256
7391
  preserveCompaction?: boolean;
7257
7392
  }): Promise<void> {
7258
7393
  const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
7394
+ this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined;
7259
7395
  if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
7260
7396
  // Pull advisor concerns out of the steer/follow-up queues before any await so
7261
7397
  // the post-abort stranded-message drain can't auto-resume the run on them.
@@ -8962,7 +9098,7 @@ export class AgentSession {
8962
9098
  const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
8963
9099
  const errorIsFromBeforeCompaction =
8964
9100
  compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
8965
- if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
9101
+ if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
8966
9102
  // Remove the error message from agent state (it IS saved to session for history,
8967
9103
  // but we don't want it in context for the retry)
8968
9104
  const messages = this.agent.state.messages;
@@ -10764,11 +10900,12 @@ export class AgentSession {
10764
10900
  }
10765
10901
 
10766
10902
  const message = error instanceof Error ? error.message : String(error);
10903
+ const id = AIError.classify(error, candidate.api);
10767
10904
  if (this.#isCompactionAuthFailure(error)) {
10768
10905
  lastError = this.#buildCompactionAuthError();
10769
10906
  break;
10770
10907
  }
10771
- if (this.#isCompactionSummarizationTimeoutMessage(message)) {
10908
+ if (AIError.is(id, AIError.Flag.Timeout)) {
10772
10909
  logger.warn(
10773
10910
  hasMoreCandidates
10774
10911
  ? "Auto-compaction summarization timed out, trying next model"
@@ -10787,8 +10924,8 @@ export class AgentSession {
10787
10924
  retrySettings.enabled &&
10788
10925
  attempt < retrySettings.maxRetries &&
10789
10926
  (retryAfterMs !== undefined ||
10790
- this.#isTransientErrorMessage(message) ||
10791
- isUsageLimitError(message));
10927
+ AIError.is(id, AIError.Flag.Transient) ||
10928
+ AIError.is(id, AIError.Flag.UsageLimit));
10792
10929
  if (!shouldRetry) {
10793
10930
  lastError = error;
10794
10931
  break;
@@ -11187,6 +11324,31 @@ export class AgentSession {
11187
11324
  // Auto-Retry
11188
11325
  // =========================================================================
11189
11326
 
11327
+ /**
11328
+ * Classify retry decisions against the active session model. Test stream
11329
+ * shims and provider adapters can emit generic assistant metadata, but retry
11330
+ * policy belongs to the model that was actually requested for this turn.
11331
+ */
11332
+ #classifyRetryMessage(message: AssistantMessage): number {
11333
+ const activeModel = this.model;
11334
+ if (!activeModel || message.api === activeModel.api) {
11335
+ return AIError.classifyMessage(message);
11336
+ }
11337
+
11338
+ const id = AIError.classifyMessage({
11339
+ api: activeModel.api,
11340
+ errorId: message.errorId,
11341
+ errorMessage: message.errorMessage,
11342
+ errorStatus: message.errorStatus,
11343
+ });
11344
+ message.errorId = id;
11345
+ return id;
11346
+ }
11347
+
11348
+ #isGenericAbortSentinel(message: AssistantMessage): boolean {
11349
+ return message.errorMessage === "Request was aborted" || message.errorMessage === "Request was aborted.";
11350
+ }
11351
+
11190
11352
  /**
11191
11353
  * Retry an empty, reason-less provider abort: a turn that ended `aborted`
11192
11354
  * with no content and the generic sentinel (bare `abort()`), but only while
@@ -11199,14 +11361,22 @@ export class AgentSession {
11199
11361
  * `prompt()`) or silently undo the guard's intended abort.
11200
11362
  */
11201
11363
  #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
11202
- return (
11203
- message.stopReason === "aborted" &&
11204
- message.content.length === 0 &&
11205
- message.errorMessage === GENERIC_ABORT_SENTINEL &&
11206
- !this.#abortInProgress &&
11207
- !this.#isDisposed &&
11208
- !this.#streamingEditAbortTriggered
11209
- );
11364
+ if (
11365
+ message.stopReason !== "aborted" ||
11366
+ message.content.length !== 0 ||
11367
+ this.#abortInProgress ||
11368
+ this.#isDisposed ||
11369
+ this.#streamingEditAbortTriggered
11370
+ ) {
11371
+ return false;
11372
+ }
11373
+
11374
+ const id = this.#classifyRetryMessage(message);
11375
+ if (AIError.is(id, AIError.Flag.Abort)) return true;
11376
+ if (!this.#isGenericAbortSentinel(message)) return false;
11377
+
11378
+ message.errorId = AIError.create(AIError.Flag.Abort);
11379
+ return true;
11210
11380
  }
11211
11381
 
11212
11382
  /**
@@ -11215,21 +11385,15 @@ export class AgentSession {
11215
11385
  * Usage-limit errors are retryable because the retry handler performs credential switching.
11216
11386
  */
11217
11387
  #isRetryableError(message: AssistantMessage): boolean {
11218
- if (message.stopReason !== "error" || !message.errorMessage) return false;
11388
+ if (message.stopReason !== "error") return false;
11219
11389
 
11390
+ const id = this.#classifyRetryMessage(message);
11220
11391
  // Context overflow is handled by compaction, not retry
11221
11392
  const contextWindow = this.model?.contextWindow ?? 0;
11222
- if (isContextOverflow(message, contextWindow)) return false;
11393
+ if (AIError.isContextOverflow(message, contextWindow)) return false;
11223
11394
 
11224
11395
  if (this.#isClassifierRefusal(message)) return true;
11225
- if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true;
11226
- if (this.#isMalformedFunctionCallError(message)) return true;
11227
- if (this.#hasReplayUnsafeToolOutput(message)) return false;
11228
- if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true;
11229
- if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
11230
-
11231
- const err = message.errorMessage;
11232
- return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
11396
+ return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
11233
11397
  }
11234
11398
  /**
11235
11399
  * Retried turns remove the failed assistant message from active context.
@@ -11241,73 +11405,12 @@ export class AgentSession {
11241
11405
  return message.content.some(block => block.type === "toolCall");
11242
11406
  }
11243
11407
 
11244
- #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
11245
- const currentApi = this.model?.api;
11246
- if (
11247
- message.api !== "openai-responses" &&
11248
- message.api !== "openai-codex-responses" &&
11249
- currentApi !== "openai-responses" &&
11250
- currentApi !== "openai-codex-responses"
11251
- ) {
11252
- return false;
11253
- }
11254
-
11255
- const errorMessage = message.errorMessage;
11256
- if (!errorMessage) return false;
11257
-
11258
- return (
11259
- /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
11260
- (/previous[ _]?response/i.test(errorMessage) &&
11261
- /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
11262
- );
11263
- }
11264
-
11265
11408
  #isClassifierRefusal(message: AssistantMessage): boolean {
11266
11409
  if (message.stopReason !== "error") return false;
11267
11410
  const stopType = message.stopDetails?.type;
11268
11411
  return stopType === "refusal" || stopType === "sensitive";
11269
11412
  }
11270
11413
 
11271
- #isProviderErrorFinishReasonBeforeToolUse(message: AssistantMessage): boolean {
11272
- if (!message.errorMessage) return false;
11273
- if (message.content.some(block => block.type === "toolCall")) return false;
11274
- return /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i.test(message.errorMessage);
11275
- }
11276
-
11277
- #isMalformedFunctionCallError(message: AssistantMessage): boolean {
11278
- if (!message.errorMessage) return false;
11279
- return /\bmalformed.?function.?call\b/i.test(message.errorMessage);
11280
- }
11281
-
11282
- #isTransientErrorMessage(errorMessage: string): boolean {
11283
- return (
11284
- this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
11285
- );
11286
- }
11287
-
11288
- #isTransientEnvelopeErrorMessage(errorMessage: string): boolean {
11289
- // Match Anthropic stream-envelope failures that indicate a broken stream before any content starts.
11290
- return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
11291
- }
11292
-
11293
- #isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean {
11294
- return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage);
11295
- }
11296
-
11297
- #isTransientTransportErrorMessage(errorMessage: string): boolean {
11298
- // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
11299
- // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
11300
- // gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets
11301
- // (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from
11302
- // src/http/h2_client/dispatch.zig)
11303
- return (
11304
- isUnexpectedSocketCloseMessage(errorMessage) ||
11305
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i.test(
11306
- errorMessage,
11307
- )
11308
- );
11309
- }
11310
-
11311
11414
  #getRetryFallbackChains(): RetryFallbackChains {
11312
11415
  const configuredChains = this.settings.get("retry.fallbackChains");
11313
11416
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -11540,20 +11643,15 @@ export class AgentSession {
11540
11643
  #isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
11541
11644
  const model = this.#activeFireworksFastModel();
11542
11645
  if (!model) return false;
11543
- if (message.stopReason !== "error" || !message.errorMessage) return false;
11646
+ if (message.stopReason !== "error") return false;
11544
11647
  if (message.content.some(block => block.type === "toolCall")) return false;
11545
11648
  // A content refusal/sensitivity stop is the model's decision, not a route
11546
11649
  // failure — switching to the base model would just re-trigger it.
11547
11650
  if (this.#isClassifierRefusal(message)) return false;
11548
- if (isContextOverflow(message, model.contextWindow ?? 0)) return false;
11549
- const err = message.errorMessage;
11550
- if (isUsageLimitError(err)) return false;
11551
- if (
11552
- /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i.test(
11553
- err,
11554
- )
11555
- )
11556
- return false;
11651
+ const id = this.#classifyRetryMessage(message);
11652
+ if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
11653
+ if (AIError.is(id, AIError.Flag.UsageLimit)) return false;
11654
+ if (AIError.is(id, AIError.Flag.AuthFailed)) return false;
11557
11655
  return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
11558
11656
  }
11559
11657
 
@@ -11719,7 +11817,8 @@ export class AgentSession {
11719
11817
  }
11720
11818
 
11721
11819
  const errorMessage = message.errorMessage || "Unknown error";
11722
- const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
11820
+ const id = this.#classifyRetryMessage(message);
11821
+ const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
11723
11822
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
11724
11823
  let delayMs = staleOpenAIResponsesReplayError
11725
11824
  ? 0
@@ -11734,7 +11833,7 @@ export class AgentSession {
11734
11833
  this.#resetCurrentResponsesProviderSession("stale replay error");
11735
11834
  }
11736
11835
 
11737
- if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
11836
+ if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
11738
11837
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
11739
11838
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
11740
11839
  this.model.provider,
@@ -11841,6 +11940,7 @@ export class AgentSession {
11841
11940
  maxAttempts: retrySettings.maxRetries,
11842
11941
  delayMs,
11843
11942
  errorMessage,
11943
+ errorId: message.errorId,
11844
11944
  });
11845
11945
 
11846
11946
  // Remove the failed assistant message from active context before retrying.
@@ -13752,6 +13852,17 @@ export class AgentSession {
13752
13852
  return this.#advisorAgent !== undefined;
13753
13853
  }
13754
13854
 
13855
+ /**
13856
+ * The live advisor `Agent`, or `undefined` when no advisor runtime is
13857
+ * attached. Surfaced for diagnostics (`/dump advisor` already serializes
13858
+ * its transcript via {@link formatAdvisorHistoryAsText}) and so callers can
13859
+ * verify the advisor inherits the session's provider-shaping options
13860
+ * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...).
13861
+ */
13862
+ getAdvisorAgent(): Agent | undefined {
13863
+ return this.#advisorAgent;
13864
+ }
13865
+
13755
13866
  /**
13756
13867
  * Return structured advisor stats for the status command and TUI panel.
13757
13868
  */