@oh-my-pi/pi-coding-agent 16.2.1 → 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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli.js +2648 -2662
  3. package/dist/types/advisor/runtime.d.ts +15 -1
  4. package/dist/types/config/model-roles.d.ts +1 -1
  5. package/dist/types/config/settings-schema.d.ts +32 -12
  6. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  7. package/dist/types/edit/index.d.ts +18 -0
  8. package/dist/types/edit/streaming.d.ts +30 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  10. package/dist/types/extensibility/shared-events.d.ts +1 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  12. package/dist/types/modes/components/status-line/component.d.ts +0 -2
  13. package/dist/types/sdk.d.ts +1 -1
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/messages.d.ts +6 -7
  16. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  17. package/dist/types/session/turn-persistence.d.ts +88 -0
  18. package/package.json +12 -12
  19. package/src/advisor/__tests__/advisor.test.ts +196 -0
  20. package/src/advisor/runtime.ts +65 -2
  21. package/src/auto-thinking/classifier.ts +2 -2
  22. package/src/config/model-resolver.ts +5 -1
  23. package/src/config/model-roles.ts +3 -3
  24. package/src/config/settings-schema.ts +30 -8
  25. package/src/discovery/omp-extension-roots.ts +38 -13
  26. package/src/edit/index.ts +21 -0
  27. package/src/edit/streaming.ts +170 -0
  28. package/src/extensibility/custom-tools/types.ts +1 -0
  29. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  30. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  31. package/src/extensibility/plugins/manager.ts +74 -4
  32. package/src/extensibility/shared-events.ts +1 -0
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/mcp/oauth-discovery.ts +5 -29
  35. package/src/mcp/transports/http.ts +3 -1
  36. package/src/mnemopi/backend.ts +2 -2
  37. package/src/modes/acp/acp-agent.ts +1 -1
  38. package/src/modes/components/assistant-message.ts +5 -5
  39. package/src/modes/components/status-line/component.ts +1 -9
  40. package/src/modes/components/status-line/segments.ts +1 -1
  41. package/src/modes/controllers/event-controller.ts +8 -11
  42. package/src/modes/interactive-mode.ts +0 -5
  43. package/src/modes/print-mode.ts +1 -1
  44. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  45. package/src/modes/utils/ui-helpers.ts +5 -4
  46. package/src/sdk.ts +12 -26
  47. package/src/session/agent-session.ts +319 -219
  48. package/src/session/messages.ts +20 -12
  49. package/src/session/session-persistence.ts +1 -2
  50. package/src/session/settings-stream-fn.ts +49 -0
  51. package/src/session/turn-persistence.ts +142 -0
  52. package/src/session/unexpected-stop-classifier.ts +2 -2
  53. package/src/slash-commands/helpers/mcp.ts +2 -1
  54. package/src/tiny/models.ts +8 -6
  55. package/src/tiny/text.ts +14 -7
  56. package/src/tools/image-gen.ts +2 -1
  57. package/src/tools/tts.ts +2 -1
  58. package/src/utils/title-generator.ts +1 -1
  59. 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 */
@@ -1333,6 +1351,8 @@ export class AgentSession {
1333
1351
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
1334
1352
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
1335
1353
  #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
1354
+ #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1355
+ #advisorStreamFn: StreamFn | undefined;
1336
1356
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1337
1357
  #rebuildSystemPrompt:
1338
1358
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -1387,6 +1407,7 @@ export class AgentSession {
1387
1407
  * `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so
1388
1408
  * it cannot leak into later unrelated aborts. */
1389
1409
  #planInternalAbortPending = false;
1410
+ #pendingAbortErrorId?: number;
1390
1411
 
1391
1412
  #postPromptTasks = new Set<Promise<unknown>>();
1392
1413
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
@@ -1657,6 +1678,8 @@ export class AgentSession {
1657
1678
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
1658
1679
  this.#requestedToolNames = config.requestedToolNames;
1659
1680
  this.#transformContext = config.transformContext ?? (messages => messages);
1681
+ this.#transformProviderContext = config.transformProviderContext;
1682
+ this.#advisorStreamFn = config.advisorStreamFn;
1660
1683
  this.#onPayload = config.onPayload;
1661
1684
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
1662
1685
  // Avoid wrapping in an `async` closure when no user callback is configured: the
@@ -2003,6 +2026,25 @@ export class AgentSession {
2003
2026
  conversationId: undefined,
2004
2027
  }
2005
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).
2006
2048
  const advisorAgent = new Agent({
2007
2049
  initialState: {
2008
2050
  systemPrompt,
@@ -2012,7 +2054,14 @@ export class AgentSession {
2012
2054
  },
2013
2055
  appendOnlyContext,
2014
2056
  sessionId: advisorSessionId,
2057
+ promptCacheKey: advisorSessionId,
2058
+ providerSessionState: this.#providerSessionState,
2015
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,
2016
2065
  intentTracing: false,
2017
2066
  telemetry: advisorTelemetry,
2018
2067
  serviceTier: advisorServiceTier,
@@ -2027,6 +2076,19 @@ export class AgentSession {
2027
2076
  advisorAgent.reset();
2028
2077
  appendOnlyContext.log.clear();
2029
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
+ },
2030
2092
  state: advisorAgent.state,
2031
2093
  };
2032
2094
 
@@ -2049,6 +2111,14 @@ export class AgentSession {
2049
2111
  maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
2050
2112
  obfuscator: this.#obfuscator,
2051
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
+ },
2052
2122
  });
2053
2123
  if (seedToCurrent) {
2054
2124
  this.#advisorRuntime.seedTo(this.agent.state.messages.length);
@@ -2586,82 +2656,8 @@ export class AgentSession {
2586
2656
  }
2587
2657
  };
2588
2658
 
2589
- #messageValueSignature(value: unknown): string {
2590
- return JSON.stringify(value) ?? "undefined";
2591
- }
2592
-
2593
- #sessionMessagesReferToSameTurn(left: AgentMessage, right: AgentMessage): boolean {
2594
- if (left === right) return true;
2595
- if (left.role !== right.role) return false;
2596
- switch (left.role) {
2597
- case "assistant":
2598
- if (right.role !== "assistant") return false;
2599
- return (
2600
- left.timestamp === right.timestamp &&
2601
- left.provider === right.provider &&
2602
- left.model === right.model &&
2603
- left.responseId === right.responseId &&
2604
- left.stopReason === right.stopReason &&
2605
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2606
- );
2607
- case "toolResult":
2608
- if (right.role !== "toolResult") return false;
2609
- return (
2610
- left.timestamp === right.timestamp &&
2611
- left.toolCallId === right.toolCallId &&
2612
- left.toolName === right.toolName &&
2613
- left.isError === right.isError &&
2614
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2615
- );
2616
- case "user":
2617
- if (right.role !== "user") return false;
2618
- return (
2619
- left.timestamp === right.timestamp &&
2620
- left.attribution === right.attribution &&
2621
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2622
- );
2623
- case "developer":
2624
- if (right.role !== "developer") return false;
2625
- return (
2626
- left.timestamp === right.timestamp &&
2627
- left.attribution === right.attribution &&
2628
- this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2629
- );
2630
- case "fileMention":
2631
- if (right.role !== "fileMention") return false;
2632
- return (
2633
- left.timestamp === right.timestamp &&
2634
- this.#messageValueSignature(left.files) === this.#messageValueSignature(right.files)
2635
- );
2636
- default:
2637
- return false;
2638
- }
2639
- }
2640
-
2641
- #sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
2642
- switch (message.role) {
2643
- case "assistant":
2644
- return [
2645
- "assistant",
2646
- message.timestamp,
2647
- message.provider,
2648
- message.model,
2649
- message.responseId ?? "",
2650
- message.stopReason,
2651
- ].join(":");
2652
- case "toolResult":
2653
- return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
2654
- case "user":
2655
- case "developer":
2656
- case "fileMention":
2657
- return `${message.role}:${message.timestamp}`;
2658
- default:
2659
- return undefined;
2660
- }
2661
- }
2662
-
2663
2659
  #createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
2664
- const key = this.#sessionMessagePersistenceKey(message);
2660
+ const key = sessionMessagePersistenceKey(message);
2665
2661
  if (!key) return undefined;
2666
2662
  const previous = this.#messageEndPersistenceTail;
2667
2663
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -2691,31 +2687,61 @@ export class AgentSession {
2691
2687
  }
2692
2688
 
2693
2689
  async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
2694
- const key = this.#sessionMessagePersistenceKey(message);
2690
+ const key = sessionMessagePersistenceKey(message);
2695
2691
  if (!key) return;
2696
2692
  await this.#pendingMessageEndPersistence.get(key);
2697
2693
  }
2698
2694
 
2699
- #sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
2700
- const branch = this.sessionManager.getBranch();
2701
- for (let index = branch.length - 1; index >= 0; index--) {
2702
- const entry = branch[index];
2703
- 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]);
2704
2720
  }
2705
- return false;
2721
+ return index;
2706
2722
  }
2707
2723
 
2708
- #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;
2709
2734
  const branch = this.sessionManager.getBranch();
2710
- for (let index = messageIndex + 1; index < turnMessages.length; index++) {
2711
- const message = turnMessages[index];
2712
- if (
2713
- branch.some(
2714
- entry => entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message),
2715
- )
2716
- ) {
2717
- return true;
2718
- }
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;
2719
2745
  }
2720
2746
  return false;
2721
2747
  }
@@ -2756,17 +2782,36 @@ export class AgentSession {
2756
2782
  for (const message of turnMessages) {
2757
2783
  await this.#waitForSessionMessagePersistence(message);
2758
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>();
2759
2793
  for (let index = 0; index < turnMessages.length; index++) {
2760
- const message = turnMessages[index];
2761
- if (this.#sessionMessageAlreadyPersisted(message)) continue;
2762
- if (this.#hasPersistedLaterTurnMessage(turnMessages, index)) {
2763
- logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
2764
- role: message.role,
2765
- timestamp: message.timestamp,
2766
- });
2767
- return false;
2768
- }
2769
- 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]);
2770
2815
  }
2771
2816
  return true;
2772
2817
  }
@@ -2785,11 +2830,17 @@ export class AgentSession {
2785
2830
  if (
2786
2831
  event.type === "message_end" &&
2787
2832
  event.message.role === "assistant" &&
2788
- event.message.stopReason === "aborted" &&
2789
- this.#planInternalAbortPending
2833
+ event.message.stopReason === "aborted"
2790
2834
  ) {
2791
- (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
2792
- 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
+ }
2793
2844
  }
2794
2845
 
2795
2846
  const messageEndPersistence =
@@ -3102,7 +3153,7 @@ export class AgentSession {
3102
3153
  if (
3103
3154
  msg.stopReason === "error" &&
3104
3155
  msg.provider === "github-copilot" &&
3105
- msg.errorMessage?.includes("GitHub Copilot authentication failed")
3156
+ AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed)
3106
3157
  ) {
3107
3158
  await this.#modelRegistry.authStorage.remove("github-copilot");
3108
3159
  }
@@ -3716,10 +3767,34 @@ export class AgentSession {
3716
3767
 
3717
3768
  context.toolName = toolCall.name;
3718
3769
  context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
3719
- context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
3770
+ context.filePaths = this.#extractTtsrToolFilePaths(toolCall);
3720
3771
  return context;
3721
3772
  }
3722
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
+
3723
3798
  /**
3724
3799
  * Match a stream delta against TTSR rules.
3725
3800
  *
@@ -3733,6 +3808,14 @@ export class AgentSession {
3733
3808
  if (!manager) {
3734
3809
  return [];
3735
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
+ }
3736
3819
  const digest = this.#resolveTtsrMatcherDigest(toolCall);
3737
3820
  if (digest !== undefined) {
3738
3821
  return manager.checkSnapshot(digest, matchContext);
@@ -3742,14 +3825,44 @@ export class AgentSession {
3742
3825
 
3743
3826
  /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
3744
3827
  #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
3745
- if (!toolCall) {
3746
- return undefined;
3747
- }
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;
3748
3847
  const tools = this.agent.state.tools;
3749
- const tool =
3848
+ return (
3750
3849
  tools.find(t => t.name === toolCall.name) ??
3751
- tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
3752
- 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
+ };
3753
3866
  }
3754
3867
 
3755
3868
  /**
@@ -3764,6 +3877,16 @@ export class AgentSession {
3764
3877
  if (!manager) {
3765
3878
  return [];
3766
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
+ }
3767
3890
  const digest = this.#resolveTtsrMatcherDigest(toolCall);
3768
3891
  if (digest === undefined) {
3769
3892
  return [];
@@ -4493,6 +4616,7 @@ export class AgentSession {
4493
4616
  maxAttempts: event.maxAttempts,
4494
4617
  delayMs: event.delayMs,
4495
4618
  errorMessage: event.errorMessage,
4619
+ errorId: event.errorId,
4496
4620
  });
4497
4621
  } else if (event.type === "auto_retry_end") {
4498
4622
  await this.#extensionRunner.emit({
@@ -7267,6 +7391,7 @@ export class AgentSession {
7267
7391
  preserveCompaction?: boolean;
7268
7392
  }): Promise<void> {
7269
7393
  const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
7394
+ this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined;
7270
7395
  if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
7271
7396
  // Pull advisor concerns out of the steer/follow-up queues before any await so
7272
7397
  // the post-abort stranded-message drain can't auto-resume the run on them.
@@ -8973,7 +9098,7 @@ export class AgentSession {
8973
9098
  const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
8974
9099
  const errorIsFromBeforeCompaction =
8975
9100
  compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
8976
- if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
9101
+ if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
8977
9102
  // Remove the error message from agent state (it IS saved to session for history,
8978
9103
  // but we don't want it in context for the retry)
8979
9104
  const messages = this.agent.state.messages;
@@ -10775,11 +10900,12 @@ export class AgentSession {
10775
10900
  }
10776
10901
 
10777
10902
  const message = error instanceof Error ? error.message : String(error);
10903
+ const id = AIError.classify(error, candidate.api);
10778
10904
  if (this.#isCompactionAuthFailure(error)) {
10779
10905
  lastError = this.#buildCompactionAuthError();
10780
10906
  break;
10781
10907
  }
10782
- if (this.#isCompactionSummarizationTimeoutMessage(message)) {
10908
+ if (AIError.is(id, AIError.Flag.Timeout)) {
10783
10909
  logger.warn(
10784
10910
  hasMoreCandidates
10785
10911
  ? "Auto-compaction summarization timed out, trying next model"
@@ -10798,8 +10924,8 @@ export class AgentSession {
10798
10924
  retrySettings.enabled &&
10799
10925
  attempt < retrySettings.maxRetries &&
10800
10926
  (retryAfterMs !== undefined ||
10801
- this.#isTransientErrorMessage(message) ||
10802
- isUsageLimitError(message));
10927
+ AIError.is(id, AIError.Flag.Transient) ||
10928
+ AIError.is(id, AIError.Flag.UsageLimit));
10803
10929
  if (!shouldRetry) {
10804
10930
  lastError = error;
10805
10931
  break;
@@ -11198,6 +11324,31 @@ export class AgentSession {
11198
11324
  // Auto-Retry
11199
11325
  // =========================================================================
11200
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
+
11201
11352
  /**
11202
11353
  * Retry an empty, reason-less provider abort: a turn that ended `aborted`
11203
11354
  * with no content and the generic sentinel (bare `abort()`), but only while
@@ -11210,14 +11361,22 @@ export class AgentSession {
11210
11361
  * `prompt()`) or silently undo the guard's intended abort.
11211
11362
  */
11212
11363
  #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
11213
- return (
11214
- message.stopReason === "aborted" &&
11215
- message.content.length === 0 &&
11216
- message.errorMessage === GENERIC_ABORT_SENTINEL &&
11217
- !this.#abortInProgress &&
11218
- !this.#isDisposed &&
11219
- !this.#streamingEditAbortTriggered
11220
- );
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;
11221
11380
  }
11222
11381
 
11223
11382
  /**
@@ -11226,21 +11385,15 @@ export class AgentSession {
11226
11385
  * Usage-limit errors are retryable because the retry handler performs credential switching.
11227
11386
  */
11228
11387
  #isRetryableError(message: AssistantMessage): boolean {
11229
- if (message.stopReason !== "error" || !message.errorMessage) return false;
11388
+ if (message.stopReason !== "error") return false;
11230
11389
 
11390
+ const id = this.#classifyRetryMessage(message);
11231
11391
  // Context overflow is handled by compaction, not retry
11232
11392
  const contextWindow = this.model?.contextWindow ?? 0;
11233
- if (isContextOverflow(message, contextWindow)) return false;
11393
+ if (AIError.isContextOverflow(message, contextWindow)) return false;
11234
11394
 
11235
11395
  if (this.#isClassifierRefusal(message)) return true;
11236
- if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true;
11237
- if (this.#isMalformedFunctionCallError(message)) return true;
11238
- if (this.#hasReplayUnsafeToolOutput(message)) return false;
11239
- if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true;
11240
- if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
11241
-
11242
- const err = message.errorMessage;
11243
- return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
11396
+ return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
11244
11397
  }
11245
11398
  /**
11246
11399
  * Retried turns remove the failed assistant message from active context.
@@ -11252,73 +11405,12 @@ export class AgentSession {
11252
11405
  return message.content.some(block => block.type === "toolCall");
11253
11406
  }
11254
11407
 
11255
- #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
11256
- const currentApi = this.model?.api;
11257
- if (
11258
- message.api !== "openai-responses" &&
11259
- message.api !== "openai-codex-responses" &&
11260
- currentApi !== "openai-responses" &&
11261
- currentApi !== "openai-codex-responses"
11262
- ) {
11263
- return false;
11264
- }
11265
-
11266
- const errorMessage = message.errorMessage;
11267
- if (!errorMessage) return false;
11268
-
11269
- return (
11270
- /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
11271
- (/previous[ _]?response/i.test(errorMessage) &&
11272
- /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
11273
- );
11274
- }
11275
-
11276
11408
  #isClassifierRefusal(message: AssistantMessage): boolean {
11277
11409
  if (message.stopReason !== "error") return false;
11278
11410
  const stopType = message.stopDetails?.type;
11279
11411
  return stopType === "refusal" || stopType === "sensitive";
11280
11412
  }
11281
11413
 
11282
- #isProviderErrorFinishReasonBeforeToolUse(message: AssistantMessage): boolean {
11283
- if (!message.errorMessage) return false;
11284
- if (message.content.some(block => block.type === "toolCall")) return false;
11285
- return /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i.test(message.errorMessage);
11286
- }
11287
-
11288
- #isMalformedFunctionCallError(message: AssistantMessage): boolean {
11289
- if (!message.errorMessage) return false;
11290
- return /\bmalformed.?function.?call\b/i.test(message.errorMessage);
11291
- }
11292
-
11293
- #isTransientErrorMessage(errorMessage: string): boolean {
11294
- return (
11295
- this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
11296
- );
11297
- }
11298
-
11299
- #isTransientEnvelopeErrorMessage(errorMessage: string): boolean {
11300
- // Match Anthropic stream-envelope failures that indicate a broken stream before any content starts.
11301
- return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
11302
- }
11303
-
11304
- #isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean {
11305
- return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage);
11306
- }
11307
-
11308
- #isTransientTransportErrorMessage(errorMessage: string): boolean {
11309
- // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
11310
- // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
11311
- // gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets
11312
- // (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from
11313
- // src/http/h2_client/dispatch.zig)
11314
- return (
11315
- isUnexpectedSocketCloseMessage(errorMessage) ||
11316
- /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(
11317
- errorMessage,
11318
- )
11319
- );
11320
- }
11321
-
11322
11414
  #getRetryFallbackChains(): RetryFallbackChains {
11323
11415
  const configuredChains = this.settings.get("retry.fallbackChains");
11324
11416
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -11551,20 +11643,15 @@ export class AgentSession {
11551
11643
  #isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
11552
11644
  const model = this.#activeFireworksFastModel();
11553
11645
  if (!model) return false;
11554
- if (message.stopReason !== "error" || !message.errorMessage) return false;
11646
+ if (message.stopReason !== "error") return false;
11555
11647
  if (message.content.some(block => block.type === "toolCall")) return false;
11556
11648
  // A content refusal/sensitivity stop is the model's decision, not a route
11557
11649
  // failure — switching to the base model would just re-trigger it.
11558
11650
  if (this.#isClassifierRefusal(message)) return false;
11559
- if (isContextOverflow(message, model.contextWindow ?? 0)) return false;
11560
- const err = message.errorMessage;
11561
- if (isUsageLimitError(err)) return false;
11562
- if (
11563
- /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i.test(
11564
- err,
11565
- )
11566
- )
11567
- 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;
11568
11655
  return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
11569
11656
  }
11570
11657
 
@@ -11730,7 +11817,8 @@ export class AgentSession {
11730
11817
  }
11731
11818
 
11732
11819
  const errorMessage = message.errorMessage || "Unknown error";
11733
- const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
11820
+ const id = this.#classifyRetryMessage(message);
11821
+ const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
11734
11822
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
11735
11823
  let delayMs = staleOpenAIResponsesReplayError
11736
11824
  ? 0
@@ -11745,7 +11833,7 @@ export class AgentSession {
11745
11833
  this.#resetCurrentResponsesProviderSession("stale replay error");
11746
11834
  }
11747
11835
 
11748
- if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
11836
+ if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
11749
11837
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
11750
11838
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
11751
11839
  this.model.provider,
@@ -11852,6 +11940,7 @@ export class AgentSession {
11852
11940
  maxAttempts: retrySettings.maxRetries,
11853
11941
  delayMs,
11854
11942
  errorMessage,
11943
+ errorId: message.errorId,
11855
11944
  });
11856
11945
 
11857
11946
  // Remove the failed assistant message from active context before retrying.
@@ -13763,6 +13852,17 @@ export class AgentSession {
13763
13852
  return this.#advisorAgent !== undefined;
13764
13853
  }
13765
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
+
13766
13866
  /**
13767
13867
  * Return structured advisor stats for the status command and TUI panel.
13768
13868
  */