@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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 (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. 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,
@@ -134,18 +130,22 @@ import {
134
130
  } from "@oh-my-pi/pi-utils";
135
131
  import * as snapcompact from "@oh-my-pi/snapcompact";
136
132
  import {
133
+ ADVISOR_DEFAULT_TOOL_NAMES,
137
134
  AdviseTool,
138
135
  type AdvisorAgent,
136
+ type AdvisorConfig,
139
137
  AdvisorEmissionGuard,
140
138
  type AdvisorMessageDetails,
141
139
  type AdvisorNote,
142
140
  AdvisorRuntime,
143
141
  type AdvisorSeverity,
144
142
  AdvisorTranscriptRecorder,
143
+ advisorTranscriptFilename,
145
144
  formatAdvisorBatchContent,
146
145
  isAdvisorInterruptImmuneTurnActive,
147
146
  isInterruptingSeverity,
148
147
  resolveAdvisorDeliveryChannel,
148
+ slugifyAdvisorName,
149
149
  } from "../advisor";
150
150
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
151
151
  import { classifyDifficulty } from "../auto-thinking/classifier";
@@ -236,6 +236,7 @@ import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text
236
236
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
237
237
  import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
238
238
  import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
239
+ import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" };
239
240
  import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
240
241
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
241
242
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
@@ -265,6 +266,7 @@ import {
265
266
  shouldDisableReasoning,
266
267
  toReasoningEffort,
267
268
  } from "../thinking";
269
+ import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/text";
268
270
  import { shutdownTinyTitleClient } from "../tiny/title-client";
269
271
  import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
270
272
  import {
@@ -292,6 +294,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
292
294
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
293
295
  import { normalizeModelContextImages } from "../utils/image-loading";
294
296
  import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
297
+ import { generateSessionTitle } from "../utils/title-generator";
295
298
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
296
299
  import type { AuthStorage } from "./auth-storage";
297
300
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
@@ -307,7 +310,10 @@ import {
307
310
  type BashExecutionMessage,
308
311
  type CustomMessage,
309
312
  convertToLlm,
310
- GENERIC_ABORT_SENTINEL,
313
+ demoteInterruptedThinking,
314
+ INTERRUPTED_THINKING_MESSAGE_TYPE,
315
+ type InterruptedThinkingDetails,
316
+ isUserInterruptAbort,
311
317
  type PythonExecutionMessage,
312
318
  readQueueChipText,
313
319
  SILENT_ABORT_MARKER,
@@ -324,6 +330,7 @@ import { formatSessionHistoryMarkdown } from "./session-history-format";
324
330
  import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
325
331
  import type { ShakeMode, ShakeResult } from "./shake-types";
326
332
  import { ToolChoiceQueue } from "./tool-choice-queue";
333
+ import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence";
327
334
  import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
328
335
  import { YieldQueue } from "./yield-queue";
329
336
 
@@ -369,7 +376,14 @@ export type AgentSessionEvent =
369
376
  /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
370
377
  skipped?: boolean;
371
378
  }
372
- | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
379
+ | {
380
+ type: "auto_retry_start";
381
+ attempt: number;
382
+ maxAttempts: number;
383
+ delayMs: number;
384
+ errorMessage: string;
385
+ errorId?: number;
386
+ }
373
387
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
374
388
  | { type: "retry_fallback_applied"; from: string; to: string; role: string }
375
389
  | { type: "retry_fallback_succeeded"; model: string; role: string }
@@ -512,6 +526,21 @@ export interface AgentSessionConfig {
512
526
  builtInToolNames?: Iterable<string>;
513
527
  /** Current session pre-LLM message transform pipeline */
514
528
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
529
+ /**
530
+ * Per-request transform applied after `convertToLlm` and before the
531
+ * provider call. Used for snapcompact, secret obfuscation, and image
532
+ * clamping. When supplied via {@link createAgentSession}, the advisor agent
533
+ * inherits this so its requests undergo the same shaping as the main turn.
534
+ */
535
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
536
+ /**
537
+ * Stream wrapper passed to the advisor agent so its requests apply the
538
+ * session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
539
+ * `providers.maxInFlightRequests`, and `model.loopGuard.*` settings —
540
+ * keeping OpenRouter sticky-routing / response caching consistent with the
541
+ * main agent. Defaults to plain `streamSimple` when omitted.
542
+ */
543
+ advisorStreamFn?: StreamFn;
515
544
  /** Provider payload hook used by the active session request path */
516
545
  onPayload?: SimpleStreamOptions["onPayload"];
517
546
  /** Provider response hook used by the active session request path */
@@ -581,20 +610,29 @@ export interface AgentSessionConfig {
581
610
  */
582
611
  providerSessionId?: string;
583
612
  /**
584
- * Hard-isolated read-only tools (read/search/find) for the advisor agent,
585
- * pre-built in `createAgentSession` against a distinct `ToolSession` so the
586
- * advisor's reads never share the primary's snapshot/seen-lines/conflict
587
- * caches. Undefined when the advisor is disabled.
613
+ * Full advisor toolset, pre-built in `createAgentSession` against a distinct,
614
+ * advisor-scoped `ToolSession` (its own `-advisor` session/agent id) so the
615
+ * advisor's tool state stays isolated from the primary. The advisor is a full
616
+ * agent; its config `tools` selects a subset (default read/grep/glob). Undefined
617
+ * when the advisor is disabled.
588
618
  */
589
- advisorReadOnlyTools?: AgentTool[];
619
+ advisorTools?: AgentTool[];
590
620
  /** Preloaded watchdog prompt content for the advisor. */
591
621
  advisorWatchdogPrompt?: string;
622
+ /** Preloaded YAML top-level `instructions` shared baseline, kept separate from
623
+ * `advisorWatchdogPrompt` so `/advisor configure` can swap it live. */
624
+ advisorSharedInstructions?: string;
592
625
  /**
593
626
  * Preloaded project context files (AGENTS.md, etc.) rendered as a system-prompt
594
627
  * block for the advisor — the same standing instructions the primary agent
595
628
  * receives, so the reviewer holds the agent to them.
596
629
  */
597
630
  advisorContextPrompt?: string;
631
+ /**
632
+ * Advisors discovered from `WATCHDOG.yml`. Empty/undefined runs a single
633
+ * legacy advisor on the `advisor` role (byte-for-byte the pre-config path).
634
+ */
635
+ advisorConfigs?: AdvisorConfig[];
598
636
  /**
599
637
  * Strip tool descriptions from provider-bound tool specs on side requests
600
638
  * (handoff). Must match the session-start value used to build the system
@@ -699,6 +737,7 @@ export interface SessionStats {
699
737
  tokens: {
700
738
  input: number;
701
739
  output: number;
740
+ reasoning: number;
702
741
  cacheRead: number;
703
742
  cacheWrite: number;
704
743
  total: number;
@@ -717,6 +756,7 @@ export interface AdvisorStats {
717
756
  tokens: {
718
757
  input: number;
719
758
  output: number;
759
+ reasoning: number;
720
760
  cacheRead: number;
721
761
  cacheWrite: number;
722
762
  total: number;
@@ -727,6 +767,43 @@ export interface AdvisorStats {
727
767
  assistant: number;
728
768
  total: number;
729
769
  };
770
+ /** Per-advisor breakdown; one entry per active advisor (single-advisor sessions have one). */
771
+ advisors: PerAdvisorStat[];
772
+ }
773
+
774
+ /** One advisor's slice of {@link AdvisorStats}, surfaced for the multi-advisor status panel. */
775
+ export interface PerAdvisorStat {
776
+ name: string;
777
+ model: Model;
778
+ contextWindow: number;
779
+ contextTokens: number;
780
+ tokens: AdvisorStats["tokens"];
781
+ cost: number;
782
+ messages: AdvisorStats["messages"];
783
+ }
784
+
785
+ /**
786
+ * One live advisor instance: its own agent/runtime/tools/recorder plus a
787
+ * per-advisor emission guard and identity. The session holds an array of these;
788
+ * primary-scoped state (turn counters, interrupt latches, the shared yield
789
+ * channel) stays on the session.
790
+ */
791
+ interface ActiveAdvisor {
792
+ /** Display name from config ("default" for the legacy no-YAML advisor). */
793
+ name: string;
794
+ /** Slug for the transcript filename/session id; "" → `__advisor.jsonl`. */
795
+ slug: string;
796
+ agent: Agent;
797
+ runtime: AdvisorRuntime;
798
+ adviseTool: AdviseTool;
799
+ emissionGuard: AdvisorEmissionGuard;
800
+ recorder: AdvisorTranscriptRecorder;
801
+ /** Latest recorder close, awaited by dispose() so the final turn lands on disk. */
802
+ recorderClosed: Promise<void>;
803
+ /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
804
+ agentUnsubscribe?: () => void;
805
+ model: Model;
806
+ thinkingLevel: ThinkingLevel;
730
807
  }
731
808
 
732
809
  export interface FreshSessionResult {
@@ -1171,6 +1248,60 @@ type MessageEndPersistenceSlot = {
1171
1248
  release: () => void;
1172
1249
  };
1173
1250
 
1251
+ const REPLAN_TITLE_CONTEXT_TURN_LIMIT = 6;
1252
+
1253
+ type SessionTitleSource = "auto" | "user";
1254
+ type SessionNameTrigger = "replan";
1255
+ type SetSessionNameWithTrigger = (
1256
+ name: string,
1257
+ source?: SessionTitleSource,
1258
+ trigger?: SessionNameTrigger,
1259
+ ) => Promise<boolean>;
1260
+
1261
+ function isRecord(value: unknown): value is Record<string, unknown> {
1262
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1263
+ }
1264
+
1265
+ function textFromContent(content: unknown): string {
1266
+ if (typeof content === "string") return content.trim();
1267
+ if (!Array.isArray(content)) return "";
1268
+ const parts: string[] = [];
1269
+ for (const block of content) {
1270
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
1271
+ const text = block.text.trim();
1272
+ if (text) parts.push(text);
1273
+ }
1274
+ return parts.join("\n\n");
1275
+ }
1276
+
1277
+ function thinkingFromContent(content: unknown): string {
1278
+ if (!Array.isArray(content)) return "";
1279
+ const parts: string[] = [];
1280
+ for (const block of content) {
1281
+ if (!isRecord(block) || block.type !== "thinking" || typeof block.thinking !== "string") continue;
1282
+ const thinking = block.thinking.trim();
1283
+ if (thinking) parts.push(thinking);
1284
+ }
1285
+ return parts.join("\n\n");
1286
+ }
1287
+
1288
+ function toolCallOpFromMessage(message: AgentMessage, toolCallId: string): string | undefined {
1289
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return undefined;
1290
+ for (const block of message.content) {
1291
+ if (!isRecord(block) || block.type !== "toolCall" || block.id !== toolCallId) continue;
1292
+ return isRecord(block.arguments) ? getStringProperty(block.arguments, "op") : undefined;
1293
+ }
1294
+ return undefined;
1295
+ }
1296
+
1297
+ function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined {
1298
+ if (message.role !== "user" && message.role !== "assistant") return undefined;
1299
+ const text = textFromContent(message.content);
1300
+ const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined;
1301
+ if (!text && !thinking) return undefined;
1302
+ return { role: message.role, ...(text ? { text } : {}), ...(thinking ? { thinking } : {}) };
1303
+ }
1304
+
1174
1305
  export class AgentSession {
1175
1306
  readonly agent: Agent;
1176
1307
  readonly sessionManager: SessionManager;
@@ -1211,29 +1342,21 @@ export class AgentSession {
1211
1342
  #advisorAutoResumeSuppressed = false;
1212
1343
  #advisorPrimaryTurnsCompleted = 0;
1213
1344
  #advisorInterruptImmuneTurnStart: number | undefined;
1214
- /** Dedupe + per-update rate-limit + content-free-phrase filter applied to
1215
- * every accepted advisor `advise()` call. Owned by the session because the
1216
- * session is what routes accepted notes back to the primary transcript.
1217
- * Reset on advisor reset (compaction, session switch, `/new`). */
1218
- readonly #advisorEmissionGuard = new AdvisorEmissionGuard();
1219
1345
  #planModeState: PlanModeState | undefined;
1220
1346
  #goalModeState: GoalModeState | undefined;
1221
1347
  #goalRuntime: GoalRuntime;
1222
- #advisorRuntime?: AdvisorRuntime;
1223
1348
  #advisorEnabled = false;
1224
- /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
1225
- #advisorAgent?: Agent;
1226
- #advisorAdviseTool?: AdviseTool;
1227
- #advisorReadOnlyTools?: AgentTool[];
1349
+ #advisorTools?: AgentTool[];
1228
1350
  #advisorWatchdogPrompt?: string;
1351
+ #advisorSharedInstructions?: string;
1229
1352
  #advisorContextPrompt?: string;
1230
1353
  #advisorYieldQueueUnsubscribe?: () => void;
1231
- /** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
1232
- * attribution and Agent Hub observability. Undefined when no advisor is active. */
1233
- #advisorTranscriptRecorder?: AdvisorTranscriptRecorder;
1234
- /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
1235
- #advisorAgentUnsubscribe?: () => void;
1236
- /** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */
1354
+ /** Live advisors. Empty when no advisor is active. */
1355
+ #advisors: ActiveAdvisor[] = [];
1356
+ /** Configured advisor roster from WATCHDOG.yml; undefined/empty → single legacy advisor. */
1357
+ #advisorConfigs?: AdvisorConfig[];
1358
+ /** Aggregate of the most recent stop's recorder closes; awaited by dispose() and
1359
+ * used as the open barrier for the next build so two writers never share a file. */
1237
1360
  #advisorRecorderClosed: Promise<void> = Promise.resolve();
1238
1361
  #goalTurnCounter = 0;
1239
1362
  #planReferenceSent = false;
@@ -1272,6 +1395,7 @@ export class AgentSession {
1272
1395
  */
1273
1396
  #todoReminderAwaitingProgress = false;
1274
1397
  #todoPhases: TodoPhase[] = [];
1398
+ #replanTitleRefreshInFlight: Promise<void> | undefined = undefined;
1275
1399
  #toolChoiceQueue = new ToolChoiceQueue();
1276
1400
 
1277
1401
  // Bash execution state
@@ -1333,6 +1457,8 @@ export class AgentSession {
1333
1457
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
1334
1458
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
1335
1459
  #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
1460
+ #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1461
+ #advisorStreamFn: StreamFn | undefined;
1336
1462
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1337
1463
  #rebuildSystemPrompt:
1338
1464
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -1387,6 +1513,7 @@ export class AgentSession {
1387
1513
  * `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so
1388
1514
  * it cannot leak into later unrelated aborts. */
1389
1515
  #planInternalAbortPending = false;
1516
+ #pendingAbortErrorId?: number;
1390
1517
 
1391
1518
  #postPromptTasks = new Set<Promise<unknown>>();
1392
1519
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
@@ -1648,15 +1775,19 @@ export class AgentSession {
1648
1775
  // toggle scopes priority to Fireworks alone, without mutating the shared
1649
1776
  // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
1650
1777
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1651
- this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1778
+ this.#advisorTools = config.advisorTools;
1652
1779
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1780
+ this.#advisorSharedInstructions = config.advisorSharedInstructions;
1653
1781
  this.#advisorContextPrompt = config.advisorContextPrompt;
1782
+ this.#advisorConfigs = config.advisorConfigs;
1654
1783
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1655
1784
  this.#validateRetryFallbackChains();
1656
1785
  this.#toolRegistry = config.toolRegistry ?? new Map();
1657
1786
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
1658
1787
  this.#requestedToolNames = config.requestedToolNames;
1659
1788
  this.#transformContext = config.transformContext ?? (messages => messages);
1789
+ this.#transformProviderContext = config.transformProviderContext;
1790
+ this.#advisorStreamFn = config.advisorStreamFn;
1660
1791
  this.#onPayload = config.onPayload;
1661
1792
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
1662
1793
  // Avoid wrapping in an `async` closure when no user callback is configured: the
@@ -1693,12 +1824,15 @@ export class AgentSession {
1693
1824
  await this.#applyRewind(rewindReport, messages);
1694
1825
  }
1695
1826
  this.#advisorPrimaryTurnsCompleted++;
1696
- if (this.#advisorRuntime && !this.#advisorRuntime.disposed) {
1697
- this.#advisorRuntime.onTurnEnd(messages);
1827
+ if (this.#advisors.length > 0) {
1828
+ for (const a of this.#advisors) {
1829
+ if (!a.runtime.disposed) a.runtime.onTurnEnd(messages);
1830
+ }
1698
1831
  const syncBacklog = this.settings.get("advisor.syncBacklog");
1699
1832
  if (syncBacklog !== "off") {
1700
1833
  const threshold = parseInt(syncBacklog, 10);
1701
- await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1834
+ // Parallel so the 30s catch-up budget is shared across advisors, not summed.
1835
+ await Promise.all(this.#advisors.map(a => a.runtime.waitForCatchup(30000, threshold, signal)));
1702
1836
  }
1703
1837
  }
1704
1838
  await this.#maintainContextMidRun(messages, signal, context);
@@ -1868,12 +2002,14 @@ export class AgentSession {
1868
2002
  // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
1869
2003
  // loop, and that abort can emit an `aborted` message_end we must not attribute to
1870
2004
  // either session's transcript. Detach, reset, then re-attach the live agent's feed.
1871
- this.#advisorAgentUnsubscribe?.();
1872
- this.#advisorAgentUnsubscribe = undefined;
1873
- this.#advisorRuntime?.reset();
1874
- this.#advisorAdviseTool?.resetDeliveredNotes();
1875
- this.#advisorEmissionGuard.reset();
1876
- this.#attachAdvisorRecorderFeed();
2005
+ for (const a of this.#advisors) {
2006
+ a.agentUnsubscribe?.();
2007
+ a.agentUnsubscribe = undefined;
2008
+ a.runtime.reset();
2009
+ a.adviseTool.resetDeliveredNotes();
2010
+ a.emissionGuard.reset();
2011
+ this.#attachAdvisorRecorderFeed(a);
2012
+ }
1877
2013
  this.#advisorPrimaryTurnsCompleted = 0;
1878
2014
  this.#advisorInterruptImmuneTurnStart = undefined;
1879
2015
  this.#advisorAutoResumeSuppressed = false;
@@ -1886,214 +2022,298 @@ export class AgentSession {
1886
2022
 
1887
2023
  #buildAdvisorRuntime(seedToCurrent = false): boolean {
1888
2024
  if (this.#isDisposed) return false;
1889
- if (this.#advisorRuntime) return true;
2025
+ if (this.#advisors.length > 0) return true;
1890
2026
  if (!this.#advisorEnabled) return false;
1891
2027
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1892
2028
 
1893
- const advisorSel = resolveAdvisorRoleSelection(
1894
- this.settings,
1895
- this.#modelRegistry.getAvailable(),
1896
- this.#modelRegistry,
1897
- );
1898
- if (!advisorSel) {
1899
- logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive");
1900
- return false;
1901
- }
1902
-
1903
- // Concern and blocker interrupt the running agent through the steering
1904
- // channel (aborting in-flight tools at the next steering boundary); when the
1905
- // loop has already yielded, triggerTurn resumes it so the advice is acted on
1906
- // immediately rather than waiting for the next user prompt. After a deliberate
1907
- // user interrupt the auto-resume is suppressed — but only while the agent is
1908
- // idle or still tearing the interrupted turn down: a concern is then recorded
1909
- // as a visible card and re-enters context when the user resumes. Once a turn
1910
- // is streaming again (a resume the user already drove) it is steered in live,
1911
- // since steering an active run auto-resumes nothing; parking it there would
1912
- // strand the advice and dump the backlog as one burst at the next prompt. A
1913
- // plain nit always rides the non-interrupting YieldQueue aside.
1914
- // Apply the per-session emission policy (one-advise-per-update gate,
1915
- // exact-text dedupe, content-free phrase filter) before any routing.
1916
- // Suppression here means the advisor model called `advise()` but the call
1917
- // is dropped silently — the model still sees `Recorded.` from the tool, so
1918
- // telling it "suppressed" doesn't tempt it into rephrasing the same useless
1919
- // note to bypass the dedupe.
1920
- const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1921
- if (!this.#advisorEmissionGuard.accept(note)) {
1922
- logger.debug("advisor advice suppressed by emission guard", { severity });
1923
- return;
1924
- }
1925
- const interrupting = isInterruptingSeverity(severity);
1926
- const channel = resolveAdvisorDeliveryChannel({
1927
- severity,
1928
- autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
1929
- // Key on the live agent-core loop, not session `isStreaming` (which also
1930
- // counts `#promptInFlightCount` during post-turn unwind). Only a running
1931
- // loop will consume a steer at its next boundary; steering into the unwind
1932
- // window would strand the card and let #drainStrandedQueuedMessages
1933
- // auto-resume it despite the user's interrupt.
1934
- streaming: this.agent.state.isStreaming,
1935
- aborting: this.#abortInProgress,
1936
- interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
1937
- });
1938
- if (channel === "aside") {
1939
- this.yieldQueue.enqueue("advisor", { note, severity });
1940
- return;
1941
- }
1942
- const notes: AdvisorNote[] = [{ note, severity }];
1943
- const content = formatAdvisorBatchContent(notes);
1944
- const details = { notes } satisfies AdvisorMessageDetails;
1945
- if (channel === "preserve") {
1946
- this.#preserveAdvisorCard({
1947
- role: "custom",
1948
- customType: "advisor",
1949
- content,
1950
- display: true,
1951
- attribution: "agent",
1952
- details,
1953
- timestamp: Date.now(),
1954
- });
1955
- return;
1956
- }
1957
- this.#recordAdvisorInterruptDelivered();
1958
- void this.sendCustomMessage(
1959
- { customType: "advisor", content, display: true, attribution: "agent", details },
1960
- { deliverAs: "steer", triggerTurn: true },
1961
- ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1962
- };
1963
-
1964
- const adviseTool = new AdviseTool(enqueueAdvice);
1965
- this.#advisorAdviseTool = adviseTool;
1966
- const advisorReadOnlyTools = this.#advisorReadOnlyTools ?? [];
1967
-
1968
- const appendOnlyContext = new AppendOnlyContextManager();
1969
- const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
1970
- const systemPrompt = [advisorSystemPrompt];
1971
- if (this.#advisorContextPrompt) {
1972
- systemPrompt.push(this.#advisorContextPrompt);
1973
- }
1974
- if (this.#advisorWatchdogPrompt) {
1975
- systemPrompt.push(this.#advisorWatchdogPrompt);
1976
- }
1977
- const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
2029
+ // The lone implicit "default" advisor (slug "") reproduces the legacy
2030
+ // single-advisor path: the `advisor` role model + `__advisor.jsonl`.
2031
+ const legacy = !this.#advisorConfigs?.length;
2032
+ const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
1978
2033
 
1979
2034
  // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
1980
2035
  // advisor on standard processing; "inherit" tracks the session's live tier
1981
2036
  // per request (like the main agent, including /fast toggles) via a resolver;
1982
- // a concrete value pins the advisor to that tier regardless of the session.
2037
+ // a concrete value pins the advisor to that tier. One value for all advisors.
1983
2038
  const advisorTierSetting = this.settings.get("serviceTierAdvisor");
1984
2039
  const advisorServiceTier =
1985
2040
  advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
1986
2041
  const advisorServiceTierResolver =
1987
2042
  advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
1988
2043
 
1989
- // Thread the primary's telemetry into the advisor loop so the advisor
1990
- // model's GenAI spans + usage/cost hooks fire like every other model call,
1991
- // stamped with the advisor's own identity. `conversationId` is cleared so
1992
- // the advisor loop falls back to its own `-advisor` session id for
1993
- // `gen_ai.conversation.id` instead of inheriting the primary's
1994
- // conversation; undefined telemetry stays undefined (zero-overhead no-op).
1995
- const advisorTelemetry = this.agent.telemetry
1996
- ? {
1997
- ...this.agent.telemetry,
1998
- agent: {
1999
- id: advisorSessionId,
2000
- name: MODEL_ROLES.advisor.name,
2001
- description: formatModelString(advisorSel.model),
2002
- },
2003
- conversationId: undefined,
2044
+ const usedSlugs = new Set<string>();
2045
+ for (const config of roster) {
2046
+ let slug = legacy ? "" : slugifyAdvisorName(config.name);
2047
+ if (slug) {
2048
+ let candidate = slug;
2049
+ let n = 2;
2050
+ while (usedSlugs.has(candidate)) candidate = `${slug}-${n++}`;
2051
+ slug = candidate;
2052
+ usedSlugs.add(slug);
2053
+ }
2054
+
2055
+ // Resolve the advisor's model: an explicit `model` override wins; else the
2056
+ // `advisor` role chain. A model that fails to resolve skips just this advisor.
2057
+ let model: Model | undefined;
2058
+ let thinkingLevel: ThinkingLevel | undefined;
2059
+ if (config.model) {
2060
+ const resolved = resolveModelOverride([config.model], this.#modelRegistry, this.settings);
2061
+ model = resolved.model;
2062
+ thinkingLevel = resolved.thinkingLevel;
2063
+ if (!model) {
2064
+ this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2065
+ continue;
2004
2066
  }
2005
- : undefined;
2006
- const advisorAgent = new Agent({
2007
- initialState: {
2008
- systemPrompt,
2009
- model: advisorSel.model,
2010
- thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2011
- tools: [adviseTool, ...advisorReadOnlyTools],
2012
- },
2013
- appendOnlyContext,
2014
- sessionId: advisorSessionId,
2015
- getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2016
- intentTracing: false,
2017
- telemetry: advisorTelemetry,
2018
- serviceTier: advisorServiceTier,
2019
- serviceTierResolver: advisorServiceTierResolver,
2020
- });
2021
- advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2022
-
2023
- const advisorAgentFacade: AdvisorAgent = {
2024
- prompt: input => advisorAgent.prompt(input),
2025
- abort: reason => advisorAgent.abort(reason),
2026
- reset: () => {
2027
- advisorAgent.reset();
2028
- appendOnlyContext.log.clear();
2029
- },
2030
- state: advisorAgent.state,
2031
- };
2067
+ } else {
2068
+ const sel = resolveAdvisorRoleSelection(
2069
+ this.settings,
2070
+ this.#modelRegistry.getAvailable(),
2071
+ this.#modelRegistry,
2072
+ );
2073
+ if (!sel) {
2074
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2075
+ advisor: config.name,
2076
+ });
2077
+ continue;
2078
+ }
2079
+ model = sel.model;
2080
+ thinkingLevel = sel.thinkingLevel;
2081
+ }
2082
+ const advisorModel = model;
2083
+ const advisorName = config.name;
2084
+ const advisorThinkingLevel = thinkingLevel ?? ThinkingLevel.Medium;
2085
+
2086
+ const emissionGuard = new AdvisorEmissionGuard();
2087
+ const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
2088
+
2089
+ // `#advisorWatchdogPrompt` already carries WATCHDOG.md + YAML shared
2090
+ // instructions; `config.instructions` adds this advisor's specialization.
2091
+ const systemPrompt = [advisorSystemPrompt];
2092
+ if (this.#advisorContextPrompt) systemPrompt.push(this.#advisorContextPrompt);
2093
+ if (this.#advisorWatchdogPrompt) systemPrompt.push(this.#advisorWatchdogPrompt);
2094
+ if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
2095
+ if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
2096
+
2097
+ const names = config.tools?.length ? new Set(config.tools) : ADVISOR_DEFAULT_TOOL_NAMES;
2098
+ const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2099
+
2100
+ const advisorSessionId = this.#advisorSessionId(slug);
2101
+ const appendOnlyContext = new AppendOnlyContextManager();
2102
+
2103
+ // Thread the primary's telemetry into the advisor loop so the advisor
2104
+ // model's GenAI spans + usage/cost hooks fire stamped with the advisor's
2105
+ // own identity. `conversationId` is cleared so the advisor loop falls back
2106
+ // to its own session id; undefined telemetry stays undefined.
2107
+ const advisorTelemetry = this.agent.telemetry
2108
+ ? {
2109
+ ...this.agent.telemetry,
2110
+ agent: {
2111
+ id: advisorSessionId,
2112
+ name: slug ? `${MODEL_ROLES.advisor.name}: ${advisorName}` : MODEL_ROLES.advisor.name,
2113
+ description: formatModelString(advisorModel),
2114
+ },
2115
+ conversationId: undefined,
2116
+ }
2117
+ : undefined;
2118
+ // Mirror the SDK's provider-shaping options (streamFn/onPayload/...,
2119
+ // providerSessionState, promptCacheKey, transformProviderContext) so each
2120
+ // advisor's requests cache, route, and obfuscate like the main turn.
2121
+ const advisorAgent = new Agent({
2122
+ initialState: {
2123
+ systemPrompt,
2124
+ model: advisorModel,
2125
+ thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2126
+ tools: [adviseTool, ...tools],
2127
+ },
2128
+ appendOnlyContext,
2129
+ sessionId: advisorSessionId,
2130
+ promptCacheKey: advisorSessionId,
2131
+ providerSessionState: this.#providerSessionState,
2132
+ getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2133
+ streamFn: this.#advisorStreamFn,
2134
+ onPayload: this.#onPayload,
2135
+ onResponse: this.#onResponse,
2136
+ onSseEvent: this.#onSseEvent,
2137
+ transformProviderContext: this.#transformProviderContext,
2138
+ intentTracing: false,
2139
+ telemetry: advisorTelemetry,
2140
+ serviceTier: advisorServiceTier,
2141
+ serviceTierResolver: advisorServiceTierResolver,
2142
+ });
2143
+ advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2144
+
2145
+ const advisorAgentFacade: AdvisorAgent = {
2146
+ prompt: input => advisorAgent.prompt(input),
2147
+ abort: reason => advisorAgent.abort(reason),
2148
+ reset: () => {
2149
+ advisorAgent.reset();
2150
+ appendOnlyContext.log.clear();
2151
+ },
2152
+ rollbackTo: count => {
2153
+ // Drop the failed user batch + synthetic assistant-error turn
2154
+ // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
2155
+ const messages = advisorAgent.state.messages;
2156
+ if (count < messages.length) {
2157
+ messages.length = count;
2158
+ }
2159
+ appendOnlyContext.resetSyncCursor();
2160
+ advisorAgent.state.error = undefined;
2161
+ },
2162
+ state: advisorAgent.state,
2163
+ };
2032
2164
 
2033
- this.#advisorAgent = advisorAgent;
2034
- // Persist the advisor's turns to `<session>/__advisor.jsonl` (resolved lazily
2035
- // so it follows session switches) so its model usage is attributed in stats
2036
- // and its transcript shows in the Agent Hub — without registering it as a peer.
2037
- const recorder = new AdvisorTranscriptRecorder(
2038
- () => this.sessionManager.getSessionFile(),
2039
- () => this.sessionManager.getCwd(),
2040
- // On the advisor on→off→on toggle, wait for the prior recorder's close so
2041
- // two SessionManagers never hold the same __advisor.jsonl at once.
2042
- this.#advisorRecorderClosed,
2043
- );
2044
- this.#advisorTranscriptRecorder = recorder;
2045
- this.#attachAdvisorRecorderFeed();
2046
- this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
2047
- snapshotMessages: () => this.agent.state.messages,
2048
- enqueueAdvice,
2049
- maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
2050
- obfuscator: this.#obfuscator,
2051
- beginAdvisorUpdate: () => this.#advisorEmissionGuard.beginUpdate(),
2052
- });
2053
- if (seedToCurrent) {
2054
- this.#advisorRuntime.seedTo(this.agent.state.messages.length);
2055
- }
2056
-
2057
- // Batch non-blocking advisor notes into one injected custom message.
2058
- this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
2059
- build: entries =>
2060
- entries.length === 0
2061
- ? null
2062
- : ({
2063
- role: "custom",
2064
- customType: "advisor",
2065
- display: true,
2066
- attribution: "agent",
2067
- timestamp: Date.now(),
2068
- content: formatAdvisorBatchContent(entries),
2069
- details: { notes: entries } satisfies AdvisorMessageDetails,
2070
- } satisfies CustomMessage),
2071
- skipIdleFlush: true,
2165
+ // Persist this advisor's turns to `<session>/__advisor[.<slug>].jsonl`
2166
+ // (resolved lazily so it follows session switches) for stats attribution
2167
+ // and Agent Hub observability, without registering it as a peer.
2168
+ const recorder = new AdvisorTranscriptRecorder(
2169
+ () => this.sessionManager.getSessionFile(),
2170
+ () => this.sessionManager.getCwd(),
2171
+ advisorTranscriptFilename(slug),
2172
+ // On the advisor on→off→on toggle, wait for the prior recorders' closes
2173
+ // so two SessionManagers never hold the same file at once.
2174
+ this.#advisorRecorderClosed,
2175
+ );
2176
+ const runtime = new AdvisorRuntime(advisorAgentFacade, {
2177
+ snapshotMessages: () => this.agent.state.messages,
2178
+ enqueueAdvice: (note, severity) => this.#routeAdvice(advisorRef, note, severity),
2179
+ maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
2180
+ obfuscator: this.#obfuscator,
2181
+ beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
2182
+ notifyFailure: error => {
2183
+ const message = error instanceof Error ? error.message : String(error);
2184
+ this.emitNotice(
2185
+ "warning",
2186
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorModel)}: ${message}`,
2187
+ "advisor",
2188
+ );
2189
+ },
2190
+ });
2191
+
2192
+ const advisorRef: ActiveAdvisor = {
2193
+ name: advisorName,
2194
+ slug,
2195
+ agent: advisorAgent,
2196
+ runtime,
2197
+ adviseTool,
2198
+ emissionGuard,
2199
+ recorder,
2200
+ recorderClosed: Promise.resolve(),
2201
+ model: advisorModel,
2202
+ thinkingLevel: advisorThinkingLevel,
2203
+ };
2204
+ this.#attachAdvisorRecorderFeed(advisorRef);
2205
+ if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
2206
+ this.#advisors.push(advisorRef);
2207
+ }
2208
+
2209
+ // One shared non-blocking aside channel for all advisors; the build callback
2210
+ // aggregates every advisor's queued nits into one card (each entry already
2211
+ // carries its own `advisor` name).
2212
+ if (this.#advisors.length > 0 && !this.#advisorYieldQueueUnsubscribe) {
2213
+ this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
2214
+ build: entries =>
2215
+ entries.length === 0
2216
+ ? null
2217
+ : ({
2218
+ role: "custom",
2219
+ customType: "advisor",
2220
+ display: true,
2221
+ attribution: "agent",
2222
+ timestamp: Date.now(),
2223
+ content: formatAdvisorBatchContent(entries),
2224
+ details: { notes: entries } satisfies AdvisorMessageDetails,
2225
+ } satisfies CustomMessage),
2226
+ skipIdleFlush: true,
2227
+ });
2228
+ }
2229
+
2230
+ return this.#advisors.length > 0;
2231
+ }
2232
+
2233
+ /** Provider/session id for an advisor's loop. The slug suffix MUST match the
2234
+ * advisor's transcript filename so stats/telemetry attribute the same advisor. */
2235
+ #advisorSessionId(slug: string): string | undefined {
2236
+ if (!this.sessionId) return undefined;
2237
+ return slug ? `${this.sessionId}-advisor-${slug}` : `${this.sessionId}-advisor`;
2238
+ }
2239
+
2240
+ /**
2241
+ * Route one accepted advice note from `advisor` to the primary. Concern and
2242
+ * blocker interrupt the running agent through the steering channel; once the
2243
+ * loop has yielded, `triggerTurn` resumes it. After a deliberate user interrupt
2244
+ * auto-resume is suppressed while idle/unwinding (the note becomes a preserved
2245
+ * card re-entering on resume); a live-streaming turn is steered in directly. A
2246
+ * plain nit always rides the non-interrupting YieldQueue aside. Suppression by
2247
+ * the per-advisor emission guard drops the note silently — the model still saw
2248
+ * `Recorded.`, so it isn't tempted to rephrase the same note past the dedupe.
2249
+ */
2250
+ #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void {
2251
+ if (!advisor.emissionGuard.accept(note)) {
2252
+ logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name });
2253
+ return;
2254
+ }
2255
+ // The implicit single ("default") advisor stamps no source name, so its
2256
+ // agent-facing `<advisory>` bytes stay identical to the pre-multi-advisor path.
2257
+ const source = advisor.slug ? advisor.name : undefined;
2258
+ const interrupting = isInterruptingSeverity(severity);
2259
+ const channel = resolveAdvisorDeliveryChannel({
2260
+ severity,
2261
+ autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
2262
+ // Key on the live agent-core loop, not session `isStreaming` (which also
2263
+ // counts `#promptInFlightCount` during post-turn unwind). Only a running
2264
+ // loop consumes a steer at its next boundary.
2265
+ streaming: this.agent.state.isStreaming,
2266
+ aborting: this.#abortInProgress,
2267
+ interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
2072
2268
  });
2269
+ if (channel === "aside") {
2270
+ this.yieldQueue.enqueue("advisor", { note, severity, advisor: source });
2271
+ return;
2272
+ }
2273
+ const notes: AdvisorNote[] = [{ note, severity, advisor: source }];
2274
+ const content = formatAdvisorBatchContent(notes);
2275
+ const details = { notes } satisfies AdvisorMessageDetails;
2276
+ if (channel === "preserve") {
2277
+ this.#preserveAdvisorCard({
2278
+ role: "custom",
2279
+ customType: "advisor",
2280
+ content,
2281
+ display: true,
2282
+ attribution: "agent",
2283
+ details,
2284
+ timestamp: Date.now(),
2285
+ });
2286
+ return;
2287
+ }
2288
+ this.#recordAdvisorInterruptDelivered();
2289
+ void this.sendCustomMessage(
2290
+ { customType: "advisor", content, display: true, attribution: "agent", details },
2291
+ { deliverAs: "steer", triggerTurn: true },
2292
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
2293
+ }
2073
2294
 
2074
- return true;
2295
+ /** Re-prime every advisor's transcript view (compaction/shake/rewind) without the
2296
+ * session-level latch reset {@link #resetAdvisorSessionState} performs. */
2297
+ #resetAllAdvisorRuntimes(): void {
2298
+ for (const a of this.#advisors) a.runtime.reset();
2075
2299
  }
2076
2300
 
2077
2301
  #stopAdvisorRuntime(): void {
2078
- // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts
2302
+ // Detach each recorder feed BEFORE aborting its advisor agent: dispose() aborts
2079
2303
  // the loop, and an abort emits a final `message_end` we must not enqueue against
2080
2304
  // a closing recorder (it would reopen and resurrect an already-released file).
2081
- this.#advisorAgentUnsubscribe?.();
2082
- this.#advisorAgentUnsubscribe = undefined;
2083
- if (this.#advisorRuntime) {
2084
- this.#advisorRuntime.dispose();
2085
- this.#advisorRuntime = undefined;
2086
- }
2087
- if (this.#advisorTranscriptRecorder) {
2088
- // Capture the close so dispose()/`/drop` can await the queued open+append+close —
2305
+ const closes: Promise<void>[] = [];
2306
+ for (const a of this.#advisors) {
2307
+ a.agentUnsubscribe?.();
2308
+ a.agentUnsubscribe = undefined;
2309
+ a.runtime.dispose();
2310
+ // Capture each close so dispose()/`/drop` can await the queued open+append+close —
2089
2311
  // the last advisor turn would otherwise be lost on a fast process exit.
2090
- this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close();
2091
- this.#advisorTranscriptRecorder = undefined;
2092
- }
2093
- if (this.#advisorAgent) {
2094
- this.#advisorAgent = undefined;
2312
+ a.recorderClosed = a.recorder.close();
2313
+ closes.push(a.recorderClosed);
2095
2314
  }
2096
- this.#advisorAdviseTool = undefined;
2315
+ this.#advisorRecorderClosed = Promise.all(closes).then(() => {});
2316
+ this.#advisors = [];
2097
2317
  this.#advisorYieldQueueUnsubscribe?.();
2098
2318
  this.#advisorYieldQueueUnsubscribe = undefined;
2099
2319
  }
@@ -2101,16 +2321,13 @@ export class AgentSession {
2101
2321
  /** Subscribe the advisor agent's finalized messages into the transcript recorder.
2102
2322
  * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
2103
2323
  * so the re-prime path can mute the feed across an abort-driven reset. */
2104
- #attachAdvisorRecorderFeed(): void {
2105
- const agent = this.#advisorAgent;
2106
- const recorder = this.#advisorTranscriptRecorder;
2107
- if (!agent || !recorder) return;
2108
- this.#advisorAgentUnsubscribe = agent.subscribe(event => {
2109
- if (event.type === "message_end") recorder.record(event.message);
2324
+ #attachAdvisorRecorderFeed(advisor: ActiveAdvisor): void {
2325
+ advisor.agentUnsubscribe = advisor.agent.subscribe(event => {
2326
+ if (event.type === "message_end") advisor.recorder.record(event.message);
2110
2327
  });
2111
2328
  }
2112
2329
 
2113
- async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
2330
+ async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
2114
2331
  const promotionSettings = this.settings.getGroup("contextPromotion");
2115
2332
  if (!promotionSettings.enabled) return false;
2116
2333
  const contextWindow = currentModel.contextWindow ?? 0;
@@ -2118,25 +2335,23 @@ export class AgentSession {
2118
2335
  const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
2119
2336
  if (!targetModel) return false;
2120
2337
 
2121
- const advisorSel = resolveAdvisorRoleSelection(
2122
- this.settings,
2123
- this.#modelRegistry.getAvailable(),
2124
- this.#modelRegistry,
2125
- );
2126
- const advisorThinkingLevel = advisorSel?.thinkingLevel ?? ThinkingLevel.Medium;
2127
-
2338
+ // Preserve this advisor's own thinking level (a configured `model:...:high`
2339
+ // keeps its suffix across a promotion); only the model changes.
2340
+ const advisorThinkingLevel = advisor.thinkingLevel;
2128
2341
  try {
2129
- this.#advisorAgent?.setModel(targetModel);
2130
- this.#advisorAgent?.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
2131
- this.#advisorAgent?.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2132
- this.#advisorAgent?.appendOnlyContext?.invalidateForModelChange();
2342
+ advisor.agent.setModel(targetModel);
2343
+ advisor.agent.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
2344
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2345
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
2133
2346
  logger.debug("Advisor context promotion switched model on overflow", {
2347
+ advisor: advisor.name,
2134
2348
  from: `${currentModel.provider}/${currentModel.id}`,
2135
2349
  to: `${targetModel.provider}/${targetModel.id}`,
2136
2350
  });
2137
2351
  return true;
2138
2352
  } catch (error) {
2139
2353
  logger.warn("Advisor context promotion failed", {
2354
+ advisor: advisor.name,
2140
2355
  from: `${currentModel.provider}/${currentModel.id}`,
2141
2356
  to: `${targetModel.provider}/${targetModel.id}`,
2142
2357
  error: String(error),
@@ -2145,19 +2360,18 @@ export class AgentSession {
2145
2360
  }
2146
2361
  }
2147
2362
 
2148
- async #maintainAdvisorContext(incomingTokens: number): Promise<boolean> {
2149
- const advisor = this.#advisorAgent;
2150
- if (!advisor) return false;
2363
+ async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
2364
+ const agent = advisor.agent;
2151
2365
 
2152
2366
  const compactionSettings = this.settings.getGroup("compaction");
2153
2367
  if (compactionSettings.strategy === "off") return false;
2154
2368
  if (!compactionSettings.enabled) return false;
2155
2369
 
2156
- const advisorModel = advisor.state.model;
2370
+ const advisorModel = agent.state.model;
2157
2371
  const contextWindow = advisorModel.contextWindow ?? 0;
2158
2372
  if (contextWindow <= 0) return false;
2159
2373
 
2160
- const messages = advisor.state.messages;
2374
+ const messages = agent.state.messages;
2161
2375
  let contextTokens = incomingTokens;
2162
2376
  for (const message of messages) {
2163
2377
  contextTokens += estimateTokens(message);
@@ -2168,9 +2382,9 @@ export class AgentSession {
2168
2382
  }
2169
2383
 
2170
2384
  // 1. Try promotion first
2171
- if (await this.#promoteAdvisorContextModel(advisorModel)) {
2385
+ if (await this.#promoteAdvisorContextModel(advisor, advisorModel)) {
2172
2386
  // Promotion succeeded, check if new model has enough space
2173
- const newModel = advisor.state.model;
2387
+ const newModel = agent.state.model;
2174
2388
  const newWindow = newModel.contextWindow ?? 0;
2175
2389
  if (newWindow > 0) {
2176
2390
  const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings);
@@ -2192,7 +2406,9 @@ export class AgentSession {
2192
2406
  timestamp,
2193
2407
  summary: message.summary,
2194
2408
  shortSummary: message.shortSummary,
2195
- firstKeptEntryId: (message as any).firstKeptEntryId || `msg-${i + 1}`,
2409
+ firstKeptEntryId:
2410
+ (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId ||
2411
+ `msg-${i + 1}`,
2196
2412
  tokensBefore: message.tokensBefore,
2197
2413
  } satisfies CompactionEntry;
2198
2414
  }
@@ -2206,33 +2422,37 @@ export class AgentSession {
2206
2422
  } satisfies SessionMessageEntry;
2207
2423
  });
2208
2424
 
2209
- const preparation = prepareCompaction(pathEntries, compactionSettings);
2425
+ const availableModels = this.#modelRegistry.getAvailable();
2426
+ const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
2427
+ if (candidates.length === 0) {
2428
+ // No compaction candidates, fallback to re-prime
2429
+ return true;
2430
+ }
2431
+ const advisorSessionId = this.#advisorSessionId(advisor.slug);
2432
+ const preparation = prepareCompaction(
2433
+ pathEntries,
2434
+ compactionSettings,
2435
+ await this.#runnableCompactionCandidates(candidates, advisorSessionId),
2436
+ );
2210
2437
  if (!preparation) {
2211
2438
  // Cannot prepare compaction, fallback to re-prime
2212
2439
  return true;
2213
2440
  }
2214
2441
 
2215
- const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning
2442
+ const advisorCompactionThinkingLevel: ThinkingLevel | undefined = agent.state.disableReasoning
2216
2443
  ? ThinkingLevel.Off
2217
- : advisor.state.thinkingLevel;
2444
+ : agent.state.thinkingLevel;
2218
2445
 
2219
2446
  // Advisor state is in-memory-only, so snapcompact's frame archive has no
2220
2447
  // stable SessionEntry preserveData slot to carry across future advisor
2221
2448
  // maintenance runs. Use an LLM summary even when the primary session is
2222
2449
  // configured for snapcompact.
2223
- const availableModels = this.#modelRegistry.getAvailable();
2224
- const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
2225
- if (candidates.length === 0) {
2226
- // No compaction candidates, fallback to re-prime
2227
- return true;
2228
- }
2229
2450
 
2230
2451
  let compactResult: CompactionResult | undefined;
2231
2452
  let lastError: unknown;
2232
- const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
2233
2453
  // Instrument the advisor's overflow-compaction one-shot like the primary
2234
2454
  // compaction path so the advisor model's maintenance call also emits spans.
2235
- const telemetry = resolveTelemetry(advisor.telemetry, advisorSessionId);
2455
+ const telemetry = resolveTelemetry(agent.telemetry, advisorSessionId);
2236
2456
 
2237
2457
  for (const candidate of candidates) {
2238
2458
  const apiKey = await this.#modelRegistry.getApiKey(candidate, advisorSessionId);
@@ -2249,6 +2469,9 @@ export class AgentSession {
2249
2469
  thinkingLevel: advisorCompactionThinkingLevel,
2250
2470
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
2251
2471
  telemetry,
2472
+ tools: agent.state.tools,
2473
+ sessionId: advisorSessionId,
2474
+ promptCacheKey: advisorSessionId,
2252
2475
  },
2253
2476
  );
2254
2477
  break;
@@ -2273,7 +2496,7 @@ export class AgentSession {
2273
2496
  firstKeptEntryId,
2274
2497
  } as CompactionSummaryMessage & { firstKeptEntryId?: string };
2275
2498
 
2276
- advisor.replaceMessages([summaryMessage, ...preparation.recentMessages]);
2499
+ agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
2277
2500
  return false;
2278
2501
  }
2279
2502
 
@@ -2586,82 +2809,8 @@ export class AgentSession {
2586
2809
  }
2587
2810
  };
2588
2811
 
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
2812
  #createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
2664
- const key = this.#sessionMessagePersistenceKey(message);
2813
+ const key = sessionMessagePersistenceKey(message);
2665
2814
  if (!key) return undefined;
2666
2815
  const previous = this.#messageEndPersistenceTail;
2667
2816
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -2691,31 +2840,61 @@ export class AgentSession {
2691
2840
  }
2692
2841
 
2693
2842
  async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
2694
- const key = this.#sessionMessagePersistenceKey(message);
2843
+ const key = sessionMessagePersistenceKey(message);
2695
2844
  if (!key) return;
2696
2845
  await this.#pendingMessageEndPersistence.get(key);
2697
2846
  }
2698
2847
 
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;
2848
+ /**
2849
+ * Index every message entry on the current branch by persistence key, so
2850
+ * the mid-run-compaction planner can ask "is this turn message already on
2851
+ * the branch?" in O(1) instead of re-walking the branch per check.
2852
+ *
2853
+ * The Map's value is the list of branch messages that share a key — almost
2854
+ * always one. We only need the LIST when content equality matters (rare
2855
+ * collision tiebreaker via {@link sameMessageContent}); the empty/single-
2856
+ * entry common case lets the caller's lookup short-circuit at presence.
2857
+ *
2858
+ * Pre-#3629 the equivalent was `sessionManager.getBranch()` called twice
2859
+ * per turn message, each call rebuilding the path via O(n²) `unshift` and
2860
+ * structurally JSON-comparing every entry — seconds of synchronous work
2861
+ * per `onTurnEnd` on a long session and the load-bearing source of the
2862
+ * `ui.loop-blocked` warnings in the bug report.
2863
+ */
2864
+ #indexPersistedMessagesByKey(): Map<string, AgentMessage[]> {
2865
+ const index = new Map<string, AgentMessage[]>();
2866
+ for (const entry of this.sessionManager.getBranch()) {
2867
+ if (entry.type !== "message") continue;
2868
+ const key = sessionMessagePersistenceKey(entry.message);
2869
+ if (key === undefined) continue;
2870
+ const existing = index.get(key);
2871
+ if (existing) existing.push(entry.message);
2872
+ else index.set(key, [entry.message]);
2704
2873
  }
2705
- return false;
2874
+ return index;
2706
2875
  }
2707
2876
 
2708
- #hasPersistedLaterTurnMessage(turnMessages: AgentMessage[], messageIndex: number): boolean {
2877
+ /**
2878
+ * True when {@link message} is structurally identical to a message already
2879
+ * appended to the current branch. Pairs a fast persistence-key lookup with
2880
+ * a content-equality fallback so two logically distinct messages that
2881
+ * happen to collide on the cheap key (e.g. two assistant turns at the same
2882
+ * millisecond with `undefined` responseId) still count as DISTINCT.
2883
+ */
2884
+ #sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
2885
+ const key = sessionMessagePersistenceKey(message);
2886
+ if (key === undefined) return false;
2709
2887
  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
- }
2888
+ // Reverse walk: recently-appended entries are at the tail, so the common
2889
+ // "is the message I just emitted already in the branch?" lookup short-
2890
+ // circuits in O(1) hot, O(branch) cold. Cheap-key compare for every
2891
+ // entry; content compare only when the cheap check matches, so the
2892
+ // expensive `JSON.stringify(content)` path stays off the hot loop.
2893
+ for (let index = branch.length - 1; index >= 0; index--) {
2894
+ const entry = branch[index];
2895
+ if (entry.type !== "message") continue;
2896
+ if (sessionMessagePersistenceKey(entry.message) !== key) continue;
2897
+ if (sameMessageContent(entry.message, message)) return true;
2719
2898
  }
2720
2899
  return false;
2721
2900
  }
@@ -2750,23 +2929,73 @@ export class AgentSession {
2750
2929
  }
2751
2930
  }
2752
2931
 
2932
+ /**
2933
+ * On a user-interrupted (`Esc`) abort, copy the trailing thinking run into a
2934
+ * hidden `display: false` continuity message for the next turn WITHOUT
2935
+ * mutating the assistant message. The original thinking stays on the message
2936
+ * so live render, reload, and Ctrl+L rebuilds keep showing it; `convertToLlm`
2937
+ * strips the run from the provider request (incomplete/unsigned thinking is
2938
+ * rejected on resend) when this continuity message follows the assistant turn.
2939
+ */
2940
+ #demoteInterruptedThinkingOnUserInterrupt(
2941
+ message: AssistantMessage,
2942
+ ): CustomMessage<InterruptedThinkingDetails> | undefined {
2943
+ if (message.stopReason !== "aborted" || !isUserInterruptAbort(message)) return undefined;
2944
+ const demoted = demoteInterruptedThinking(message);
2945
+ if (!demoted) return undefined;
2946
+ const interruptedAt = Date.now();
2947
+ return {
2948
+ role: "custom",
2949
+ customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
2950
+ content: prompt.render(interruptedThinkingTemplate, { reasoning: demoted.reasoning }),
2951
+ display: false,
2952
+ details: {
2953
+ interruptedAt,
2954
+ provider: message.provider,
2955
+ model: message.model,
2956
+ blockCount: demoted.blockCount,
2957
+ },
2958
+ attribution: "agent",
2959
+ timestamp: interruptedAt,
2960
+ };
2961
+ }
2962
+
2753
2963
  async #persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean> {
2754
2964
  if (!context) return true;
2755
2965
  const turnMessages = [context.message, ...context.toolResults];
2756
2966
  for (const message of turnMessages) {
2757
2967
  await this.#waitForSessionMessagePersistence(message);
2758
2968
  }
2969
+ // One branch snapshot + one persistence-key index drives the entire
2970
+ // planning pass. Pre-#3629 this re-walked the branch and structurally
2971
+ // JSON-compared every entry per turn message, which on long sessions
2972
+ // turned each `onTurnEnd` into a seconds-long sync block (the
2973
+ // `ui.loop-blocked` warnings tagged `subagent:*` in the bug report).
2974
+ const branchIndex = this.#indexPersistedMessagesByKey();
2975
+ const turnKeys = turnMessages.map(sessionMessagePersistenceKey);
2976
+ const persistedKeys = new Set<string>();
2759
2977
  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);
2978
+ const key = turnKeys[index];
2979
+ if (key === undefined) continue;
2980
+ const candidates = branchIndex.get(key);
2981
+ if (!candidates) continue;
2982
+ // Key match only counts when content also matches — two distinct
2983
+ // messages that collided on the cheap key must STILL be persisted.
2984
+ if (candidates.some(persisted => sameMessageContent(persisted, turnMessages[index]))) {
2985
+ persistedKeys.add(key);
2986
+ }
2987
+ }
2988
+ const plan = planTurnPersistence(turnKeys, persistedKeys);
2989
+ if (plan.kind === "out-of-order") {
2990
+ const message = turnMessages[plan.messageIndex];
2991
+ logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
2992
+ role: message.role,
2993
+ timestamp: message.timestamp,
2994
+ });
2995
+ return false;
2996
+ }
2997
+ for (const index of plan.toPersist) {
2998
+ this.#persistSessionMessageIfMissing(turnMessages[index]);
2770
2999
  }
2771
3000
  return true;
2772
3001
  }
@@ -2785,11 +3014,28 @@ export class AgentSession {
2785
3014
  if (
2786
3015
  event.type === "message_end" &&
2787
3016
  event.message.role === "assistant" &&
2788
- event.message.stopReason === "aborted" &&
2789
- this.#planInternalAbortPending
3017
+ event.message.stopReason === "aborted"
2790
3018
  ) {
2791
- (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
2792
- this.#planInternalAbortPending = false;
3019
+ const message = event.message as AssistantMessage;
3020
+ if (this.#planInternalAbortPending) {
3021
+ message.errorMessage = SILENT_ABORT_MARKER;
3022
+ message.errorId = AIError.create(AIError.Flag.SilentAbort);
3023
+ this.#planInternalAbortPending = false;
3024
+ } else if (this.#pendingAbortErrorId) {
3025
+ message.errorId = this.#pendingAbortErrorId;
3026
+ this.#pendingAbortErrorId = undefined;
3027
+ }
3028
+ }
3029
+
3030
+ const interruptedThinkingMessage =
3031
+ event.type === "message_end" && event.message.role === "assistant"
3032
+ ? this.#demoteInterruptedThinkingOnUserInterrupt(event.message as AssistantMessage)
3033
+ : undefined;
3034
+ // `message_end` handling is fire-and-forget from agent-core. Make the
3035
+ // hidden continuity turn visible to the next prompt before any awaited
3036
+ // extension delivery or persistence can stall this handler.
3037
+ if (interruptedThinkingMessage) {
3038
+ this.agent.appendMessage(interruptedThinkingMessage);
2793
3039
  }
2794
3040
 
2795
3041
  const messageEndPersistence =
@@ -2934,6 +3180,15 @@ export class AgentSession {
2934
3180
  } else {
2935
3181
  persistMessageEnd();
2936
3182
  }
3183
+ if (interruptedThinkingMessage) {
3184
+ this.sessionManager.appendCustomMessageEntry(
3185
+ interruptedThinkingMessage.customType,
3186
+ interruptedThinkingMessage.content,
3187
+ interruptedThinkingMessage.display,
3188
+ interruptedThinkingMessage.details,
3189
+ interruptedThinkingMessage.attribution,
3190
+ );
3191
+ }
2937
3192
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
2938
3193
 
2939
3194
  // Track assistant message for auto-compaction (checked on agent_end)
@@ -2990,9 +3245,10 @@ export class AgentSession {
2990
3245
  }
2991
3246
  }
2992
3247
  if (event.message.role === "toolResult") {
2993
- const { toolName, details, isError, content } = event.message as {
3248
+ const { toolName, toolCallId, details, isError, content } = event.message as {
3249
+ toolCallId?: string;
2994
3250
  toolName?: string;
2995
- details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
3251
+ details?: { op?: string; path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
2996
3252
  isError?: boolean;
2997
3253
  content?: Array<TextContent | ImageContent>;
2998
3254
  };
@@ -3006,6 +3262,9 @@ export class AgentSession {
3006
3262
  }
3007
3263
  if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
3008
3264
  this.setTodoPhases(details.phases);
3265
+ if (this.#isTodoInitResult(details, toolCallId)) {
3266
+ this.#scheduleReplanTitleRefresh();
3267
+ }
3009
3268
  }
3010
3269
  if (toolName === "todo" && isError) {
3011
3270
  const errorText = content?.find(part => part.type === "text")?.text;
@@ -3102,7 +3361,7 @@ export class AgentSession {
3102
3361
  if (
3103
3362
  msg.stopReason === "error" &&
3104
3363
  msg.provider === "github-copilot" &&
3105
- msg.errorMessage?.includes("GitHub Copilot authentication failed")
3364
+ AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed)
3106
3365
  ) {
3107
3366
  await this.#modelRegistry.authStorage.remove("github-copilot");
3108
3367
  }
@@ -3716,10 +3975,34 @@ export class AgentSession {
3716
3975
 
3717
3976
  context.toolName = toolCall.name;
3718
3977
  context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
3719
- context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
3978
+ context.filePaths = this.#extractTtsrToolFilePaths(toolCall);
3720
3979
  return context;
3721
3980
  }
3722
3981
 
3982
+ /**
3983
+ * Resolve the file paths a tool call would touch for TTSR path-glob matching.
3984
+ *
3985
+ * Prefer the tool's own `matcherPaths` hook — it understands the wire format
3986
+ * (hashline `[path#TAG]` section headers, apply_patch envelope markers) and
3987
+ * surfaces paths the generic top-level argument scan never sees. Fall back
3988
+ * to {@link #extractTtsrFilePathsFromArgs} for tools that pass paths as
3989
+ * `path`/`paths` arguments and for tool calls whose payload has not yet
3990
+ * streamed a header.
3991
+ */
3992
+ #extractTtsrToolFilePaths(toolCall: ToolCall): string[] | undefined {
3993
+ const args = toolCall.arguments ?? {};
3994
+ const tools = this.agent.state.tools;
3995
+ const tool =
3996
+ tools.find(t => t.name === toolCall.name) ??
3997
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
3998
+ const toolPaths = tool?.matcherPaths?.(args);
3999
+ if (toolPaths && toolPaths.length > 0) {
4000
+ const normalized = toolPaths.flatMap(p => this.#normalizeTtsrPathCandidates(p));
4001
+ if (normalized.length > 0) return Array.from(new Set(normalized));
4002
+ }
4003
+ return this.#extractTtsrFilePathsFromArgs(args);
4004
+ }
4005
+
3723
4006
  /**
3724
4007
  * Match a stream delta against TTSR rules.
3725
4008
  *
@@ -3733,6 +4016,14 @@ export class AgentSession {
3733
4016
  if (!manager) {
3734
4017
  return [];
3735
4018
  }
4019
+ const entries = this.#resolveTtsrMatcherEntries(toolCall);
4020
+ if (entries) {
4021
+ const matches: Rule[] = [];
4022
+ for (const entry of entries) {
4023
+ matches.push(...manager.checkSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path)));
4024
+ }
4025
+ return matches;
4026
+ }
3736
4027
  const digest = this.#resolveTtsrMatcherDigest(toolCall);
3737
4028
  if (digest !== undefined) {
3738
4029
  return manager.checkSnapshot(digest, matchContext);
@@ -3742,14 +4033,44 @@ export class AgentSession {
3742
4033
 
3743
4034
  /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
3744
4035
  #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
3745
- if (!toolCall) {
3746
- return undefined;
3747
- }
4036
+ const tool = this.#resolveTtsrTool(toolCall);
4037
+ return tool?.matcherDigest?.(toolCall?.arguments ?? {});
4038
+ }
4039
+
4040
+ /**
4041
+ * Per-file split of a streamed call (one entry per touched file paired with
4042
+ * the digest of only that file's added lines). Lets {@link #checkTtsrStream}
4043
+ * and {@link #checkTtsrAstStream} evaluate each file in isolation so a
4044
+ * path-scoped rule like `tool:edit(*.ts)` never fires on text that belongs
4045
+ * to a sibling Markdown hunk in a multi-file payload.
4046
+ */
4047
+ #resolveTtsrMatcherEntries(toolCall: ToolCall | undefined): readonly { path: string; digest: string }[] | undefined {
4048
+ const tool = this.#resolveTtsrTool(toolCall);
4049
+ const entries = tool?.matcherEntries?.(toolCall?.arguments ?? {});
4050
+ return entries && entries.length > 0 ? entries : undefined;
4051
+ }
4052
+
4053
+ #resolveTtsrTool(toolCall: ToolCall | undefined) {
4054
+ if (!toolCall) return undefined;
3748
4055
  const tools = this.agent.state.tools;
3749
- const tool =
4056
+ return (
3750
4057
  tools.find(t => t.name === toolCall.name) ??
3751
- tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
3752
- return tool?.matcherDigest?.(toolCall.arguments ?? {});
4058
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
4059
+ );
4060
+ }
4061
+
4062
+ /**
4063
+ * Replace `matchContext`'s `filePaths` + `streamKey` so a per-file entry
4064
+ * gets its own glob-eligible path and its own TTSR buffer/repeat tracking
4065
+ * (each file's stream is independent inside the same tool call).
4066
+ */
4067
+ #perFileTtsrContext(base: TtsrMatchContext, filePath: string): TtsrMatchContext {
4068
+ const filePaths = this.#normalizeTtsrPathCandidates(filePath);
4069
+ return {
4070
+ ...base,
4071
+ filePaths: filePaths.length > 0 ? filePaths : [filePath],
4072
+ streamKey: base.streamKey ? `${base.streamKey}#${filePath}` : undefined,
4073
+ };
3753
4074
  }
3754
4075
 
3755
4076
  /**
@@ -3764,6 +4085,16 @@ export class AgentSession {
3764
4085
  if (!manager) {
3765
4086
  return [];
3766
4087
  }
4088
+ const entries = this.#resolveTtsrMatcherEntries(toolCall);
4089
+ if (entries) {
4090
+ const matches: Rule[] = [];
4091
+ for (const entry of entries) {
4092
+ matches.push(
4093
+ ...(await manager.checkAstSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path))),
4094
+ );
4095
+ }
4096
+ return matches;
4097
+ }
3767
4098
  const digest = this.#resolveTtsrMatcherDigest(toolCall);
3768
4099
  if (digest === undefined) {
3769
4100
  return [];
@@ -4493,6 +4824,7 @@ export class AgentSession {
4493
4824
  maxAttempts: event.maxAttempts,
4494
4825
  delayMs: event.delayMs,
4495
4826
  errorMessage: event.errorMessage,
4827
+ errorId: event.errorId,
4496
4828
  });
4497
4829
  } else if (event.type === "auto_retry_end") {
4498
4830
  await this.#extensionRunner.emit({
@@ -6402,7 +6734,10 @@ export class AgentSession {
6402
6734
 
6403
6735
  async promptCustomMessage<T = unknown>(
6404
6736
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
6405
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & { queueChipText?: string },
6737
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & {
6738
+ queueChipText?: string;
6739
+ queueOnly?: boolean;
6740
+ },
6406
6741
  ): Promise<void> {
6407
6742
  const textContent =
6408
6743
  typeof message.content === "string"
@@ -6422,6 +6757,16 @@ export class AgentSession {
6422
6757
  keywordNotices = this.#createMagicKeywordNotices(skillArgs);
6423
6758
  }
6424
6759
 
6760
+ if (options?.queueOnly) {
6761
+ if (!options.streamingBehavior) {
6762
+ throw new AgentBusyError();
6763
+ }
6764
+ for (const notice of keywordNotices) {
6765
+ await this.#queueCustomMessage(notice, options.streamingBehavior);
6766
+ }
6767
+ await this.#queueCustomMessage(message, options.streamingBehavior, options.queueChipText);
6768
+ return;
6769
+ }
6425
6770
  if (this.isStreaming) {
6426
6771
  if (!options?.streamingBehavior) {
6427
6772
  throw new AgentBusyError();
@@ -6987,6 +7332,40 @@ export class AgentSession {
6987
7332
  }
6988
7333
  }
6989
7334
 
7335
+ /** Queue a custom message without starting a turn, matching steer/follow-up delivery. */
7336
+ async #queueCustomMessage<T = unknown>(
7337
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
7338
+ deliverAs: "steer" | "followUp",
7339
+ queueChipText?: string,
7340
+ ): Promise<void> {
7341
+ const details =
7342
+ queueChipText !== undefined
7343
+ ? ({
7344
+ ...((message.details && typeof message.details === "object" ? message.details : {}) as Record<
7345
+ string,
7346
+ unknown
7347
+ >),
7348
+ __queueChipText: queueChipText,
7349
+ } as T)
7350
+ : message.details;
7351
+ const appMessage: CustomMessage<T> = {
7352
+ role: "custom",
7353
+ customType: message.customType,
7354
+ content: message.content,
7355
+ display: message.display,
7356
+ details,
7357
+ attribution: message.attribution ?? "agent",
7358
+ timestamp: Date.now(),
7359
+ };
7360
+ const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
7361
+ if (deliverAs === "followUp") {
7362
+ this.agent.followUp(normalizedAppMessage);
7363
+ } else {
7364
+ this.agent.steer(normalizedAppMessage);
7365
+ }
7366
+ this.#scheduleIdleQueueDrain();
7367
+ }
7368
+
6990
7369
  /**
6991
7370
  * Send a custom message to the session. Creates a CustomMessageEntry.
6992
7371
  *
@@ -7229,6 +7608,74 @@ export class AgentSession {
7229
7608
  this.#todoPhases = this.#cloneTodoPhases(phases);
7230
7609
  }
7231
7610
 
7611
+ #isTodoInitResult(details: Record<string, unknown>, toolCallId: string | undefined): boolean {
7612
+ const detailOp = getStringProperty(details, "op");
7613
+ if (detailOp) return detailOp === "init";
7614
+ if (!toolCallId) return false;
7615
+ for (let i = this.agent.state.messages.length - 1; i >= 0; i--) {
7616
+ const message = this.agent.state.messages[i];
7617
+ if (!message) continue;
7618
+ const op = toolCallOpFromMessage(message, toolCallId);
7619
+ if (op) return op === "init";
7620
+ }
7621
+ return false;
7622
+ }
7623
+
7624
+ #buildReplanTitleContext(): string {
7625
+ const turns: TitleConversationTurn[] = [];
7626
+ for (
7627
+ let i = this.agent.state.messages.length - 1;
7628
+ i >= 0 && turns.length < REPLAN_TITLE_CONTEXT_TURN_LIMIT;
7629
+ i--
7630
+ ) {
7631
+ const message = this.agent.state.messages[i];
7632
+ if (!message) continue;
7633
+ const turn = titleConversationTurnFromMessage(message);
7634
+ if (turn) turns.push(turn);
7635
+ }
7636
+ turns.reverse();
7637
+ return formatTitleConversationContext(turns);
7638
+ }
7639
+
7640
+ #scheduleReplanTitleRefresh(): void {
7641
+ if (this.#replanTitleRefreshInFlight) return;
7642
+ if (!this.settings.get("title.refreshOnReplan")) return;
7643
+ if (this.sessionManager.titleSource === "user") return;
7644
+ const context = this.#buildReplanTitleContext();
7645
+ if (!context) return;
7646
+ const sessionId = this.sessionManager.getSessionId();
7647
+ const refresh = this.#refreshTitleAfterReplan(context, sessionId)
7648
+ .catch(err => {
7649
+ logger.warn("title-generator: replan refresh failed", {
7650
+ sessionId,
7651
+ error: err instanceof Error ? err.message : String(err),
7652
+ });
7653
+ })
7654
+ .finally(() => {
7655
+ if (this.#replanTitleRefreshInFlight === refresh) {
7656
+ this.#replanTitleRefreshInFlight = undefined;
7657
+ }
7658
+ });
7659
+ this.#replanTitleRefreshInFlight = refresh;
7660
+ }
7661
+
7662
+ async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
7663
+ const title = await generateSessionTitle(
7664
+ context,
7665
+ this.#modelRegistry,
7666
+ this.settings,
7667
+ sessionId,
7668
+ this.model,
7669
+ provider => this.agent.metadataForProvider(provider),
7670
+ );
7671
+ if (!title) return;
7672
+ if (this.sessionManager.getSessionId() !== sessionId) return;
7673
+ if (!this.settings.get("title.refreshOnReplan")) return;
7674
+ if (this.sessionManager.titleSource === "user") return;
7675
+ const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger;
7676
+ await setSessionName.call(this.sessionManager, title, "auto", "replan");
7677
+ }
7678
+
7232
7679
  #syncTodoPhasesFromBranch(): void {
7233
7680
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
7234
7681
  // Strip completed/abandoned tasks — they were done in a previous run,
@@ -7267,6 +7714,7 @@ export class AgentSession {
7267
7714
  preserveCompaction?: boolean;
7268
7715
  }): Promise<void> {
7269
7716
  const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
7717
+ this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined;
7270
7718
  if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
7271
7719
  // Pull advisor concerns out of the steer/follow-up queues before any await so
7272
7720
  // the post-abort stranded-message drain can't auto-resume the run on them.
@@ -7370,9 +7818,11 @@ export class AgentSession {
7370
7818
  // running advisor turn could otherwise finish, emit `message_end`, and recreate
7371
7819
  // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
7372
7820
  // the advisor and re-attaches the feed at the new session's path.
7373
- this.#advisorAgentUnsubscribe?.();
7374
- this.#advisorAgentUnsubscribe = undefined;
7375
- if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close();
7821
+ for (const a of this.#advisors) {
7822
+ a.agentUnsubscribe?.();
7823
+ a.agentUnsubscribe = undefined;
7824
+ await a.recorder.close();
7825
+ }
7376
7826
  try {
7377
7827
  await this.sessionManager.dropSession(previousSessionFile);
7378
7828
  } catch (err) {
@@ -7426,8 +7876,9 @@ export class AgentSession {
7426
7876
  /**
7427
7877
  * Set a display name for the current session.
7428
7878
  */
7429
- setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
7430
- return this.sessionManager.setSessionName(name, source);
7879
+ setSessionName(name: string, source: "auto" | "user" = "auto", trigger?: SessionNameTrigger): Promise<boolean> {
7880
+ const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger;
7881
+ return setSessionName.call(this.sessionManager, name, source, trigger);
7431
7882
  }
7432
7883
 
7433
7884
  /**
@@ -7505,16 +7956,24 @@ export class AgentSession {
7505
7956
  /**
7506
7957
  * Set model directly.
7507
7958
  * Validates that a credential source is configured (synchronously, without
7508
- * refreshing OAuth or running command-backed key programs) and saves to the
7509
- * active session. Persists settings only when requested. The concrete key is
7510
- * resolved lazily per request, so switching never blocks the event loop.
7959
+ * refreshing OAuth or running command-backed key programs). The active
7960
+ * session switches by default; when `currentContextTokens` is provided and
7961
+ * exceeds the refreshed candidate's context window, the live switch is
7962
+ * skipped while role persistence still runs. Returns whether the active
7963
+ * model actually switched, computed against the refreshed metadata so
7964
+ * dynamic providers (e.g. llama.cpp) honor their post-load contextWindow.
7511
7965
  * @throws Error if no API key available for the model
7512
7966
  */
7513
7967
  async setModel(
7514
7968
  model: Model,
7515
7969
  role: string = "default",
7516
- options?: { selector?: string; thinkingLevel?: ThinkingLevel; persist?: boolean },
7517
- ): Promise<void> {
7970
+ options?: {
7971
+ selector?: string;
7972
+ thinkingLevel?: ThinkingLevel;
7973
+ persist?: boolean;
7974
+ currentContextTokens?: number;
7975
+ },
7976
+ ): Promise<{ switched: boolean }> {
7518
7977
  const previousEditMode = this.#resolveActiveEditMode();
7519
7978
  if (!this.#modelRegistry.hasConfiguredAuth(model)) {
7520
7979
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -7522,21 +7981,35 @@ export class AgentSession {
7522
7981
 
7523
7982
  const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7524
7983
 
7525
- this.#clearActiveRetryFallback();
7526
- this.#setModelWithProviderSessionReset(targetModel);
7527
- this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
7984
+ const currentContextTokens = options?.currentContextTokens ?? 0;
7985
+ const targetContextWindow = targetModel.contextWindow ?? 0;
7986
+ const switched = !(
7987
+ currentContextTokens > 0 &&
7988
+ targetContextWindow > 0 &&
7989
+ currentContextTokens > targetContextWindow
7990
+ );
7991
+
7992
+ if (switched) {
7993
+ this.#clearActiveRetryFallback();
7994
+ this.#setModelWithProviderSessionReset(targetModel);
7995
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
7996
+ }
7528
7997
  if (options?.persist) {
7529
7998
  this.settings.setModelRole(
7530
7999
  role,
7531
8000
  this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
7532
8001
  );
7533
8002
  }
8003
+ if (!switched) {
8004
+ return { switched: false };
8005
+ }
7534
8006
  this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
7535
8007
 
7536
8008
  // Re-apply thinking for the newly selected model. Prefer the model's
7537
8009
  // configured defaultLevel; otherwise preserve the current level (or auto).
7538
8010
  this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
7539
8011
  await this.#syncAfterModelChange(previousEditMode);
8012
+ return { switched: true };
7540
8013
  }
7541
8014
 
7542
8015
  /**
@@ -8061,7 +8534,7 @@ export class AgentSession {
8061
8534
  await this.sessionManager.rewriteEntries();
8062
8535
  const sessionContext = this.buildDisplaySessionContext();
8063
8536
  this.agent.replaceMessages(sessionContext.messages);
8064
- this.#advisorRuntime?.reset();
8537
+ this.#resetAllAdvisorRuntimes();
8065
8538
  this.#syncTodoPhasesFromBranch();
8066
8539
  this.#closeCodexProviderSessionsForHistoryRewrite();
8067
8540
  return result;
@@ -8098,7 +8571,7 @@ export class AgentSession {
8098
8571
 
8099
8572
  const sessionContext = this.buildDisplaySessionContext();
8100
8573
  this.agent.replaceMessages(sessionContext.messages);
8101
- this.#advisorRuntime?.reset();
8574
+ this.#resetAllAdvisorRuntimes();
8102
8575
  this.#syncTodoPhasesFromBranch();
8103
8576
  this.#closeCodexProviderSessionsForHistoryRewrite();
8104
8577
  return result;
@@ -8149,7 +8622,7 @@ export class AgentSession {
8149
8622
  await this.sessionManager.rewriteEntries();
8150
8623
  const sessionContext = this.buildDisplaySessionContext();
8151
8624
  this.agent.replaceMessages(sessionContext.messages);
8152
- this.#advisorRuntime?.reset();
8625
+ this.#resetAllAdvisorRuntimes();
8153
8626
  this.#closeCodexProviderSessionsForHistoryRewrite();
8154
8627
  return { removed };
8155
8628
  }
@@ -8206,7 +8679,7 @@ export class AgentSession {
8206
8679
  await this.sessionManager.rewriteEntries();
8207
8680
  const sessionContext = this.buildDisplaySessionContext();
8208
8681
  this.agent.replaceMessages(sessionContext.messages);
8209
- this.#advisorRuntime?.reset();
8682
+ this.#resetAllAdvisorRuntimes();
8210
8683
  this.#closeCodexProviderSessionsForHistoryRewrite();
8211
8684
 
8212
8685
  return {
@@ -8302,7 +8775,11 @@ export class AgentSession {
8302
8775
  compactionCandidates = this.#getCompactionModelCandidates(availableModels);
8303
8776
  }
8304
8777
  const pathEntries = this.sessionManager.getBranch();
8305
- const preparation = prepareCompaction(pathEntries, effectiveSettings);
8778
+ const preparation = prepareCompaction(
8779
+ pathEntries,
8780
+ effectiveSettings,
8781
+ await this.#runnableCompactionCandidates(compactionCandidates, this.sessionId),
8782
+ );
8306
8783
  if (!preparation) {
8307
8784
  // Check why we can't compact
8308
8785
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -8492,7 +8969,7 @@ export class AgentSession {
8492
8969
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
8493
8970
  // the plan from disk and re-injects it on the next turn (issue #1246).
8494
8971
  this.#planReferenceSent = false;
8495
- this.#advisorRuntime?.reset();
8972
+ this.#resetAllAdvisorRuntimes();
8496
8973
  this.#syncTodoPhasesFromBranch();
8497
8974
  this.#closeCodexProviderSessionsForHistoryRewrite();
8498
8975
 
@@ -8756,7 +9233,7 @@ export class AgentSession {
8756
9233
  // Rebuild agent messages from session
8757
9234
  const sessionContext = this.buildDisplaySessionContext();
8758
9235
  this.agent.replaceMessages(sessionContext.messages);
8759
- this.#advisorRuntime?.reset();
9236
+ this.#resetAllAdvisorRuntimes();
8760
9237
  this.#syncTodoPhasesFromBranch();
8761
9238
 
8762
9239
  return { document: handoffText, savedPath };
@@ -8973,7 +9450,7 @@ export class AgentSession {
8973
9450
  const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
8974
9451
  const errorIsFromBeforeCompaction =
8975
9452
  compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
8976
- if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
9453
+ if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
8977
9454
  // Remove the error message from agent state (it IS saved to session for history,
8978
9455
  // but we don't want it in context for the retry)
8979
9456
  const messages = this.agent.state.messages;
@@ -10076,6 +10553,17 @@ export class AgentSession {
10076
10553
  return this.#resolveCompactionModelCandidates(this.model, availableModels, filter);
10077
10554
  }
10078
10555
 
10556
+ /**
10557
+ * Compaction candidates that can actually run — those with a resolvable API
10558
+ * key, matching the per-candidate getApiKey gate the execution loop applies.
10559
+ * Re-expansion reusability (prepareCompaction) must judge remote-preserve
10560
+ * reuse against these, not against candidates the loop would skip at runtime.
10561
+ */
10562
+ async #runnableCompactionCandidates(candidates: readonly Model[], sessionId: string | undefined): Promise<Model[]> {
10563
+ const keys = await Promise.all(candidates.map(model => this.#modelRegistry.getApiKey(model, sessionId)));
10564
+ return candidates.filter((_, index) => keys[index] !== undefined);
10565
+ }
10566
+
10079
10567
  #resolveCompactionModelCandidates(
10080
10568
  preferredModel: Model | null | undefined,
10081
10569
  availableModels: Model[],
@@ -10172,6 +10660,9 @@ export class AgentSession {
10172
10660
  // via resolveCompactionEffort so unsupported-effort models
10173
10661
  // (xai-oauth/grok-build) don't trip requireSupportedEffort.
10174
10662
  thinkingLevel: this.thinkingLevel,
10663
+ tools: this.agent.state.tools,
10664
+ sessionId: this.sessionId,
10665
+ promptCacheKey: this.sessionId,
10175
10666
  },
10176
10667
  );
10177
10668
  } catch (error) {
@@ -10491,15 +10982,21 @@ export class AgentSession {
10491
10982
 
10492
10983
  // "overflow" forces context-full because the input itself is broken — a handoff
10493
10984
  // LLM call would hit the same overflow. "incomplete" is an output-side problem,
10494
- // so a handoff request on the existing context is still viable. Snapcompact is
10495
- // a local-only strategy: if it cannot run, report the local blocker instead of
10496
- // silently swapping in a provider-backed summary.
10985
+ // so a handoff request on the existing context is still viable.
10497
10986
  let action: "context-full" | "handoff" | "snapcompact" =
10498
10987
  compactionSettings.strategy === "snapcompact"
10499
10988
  ? "snapcompact"
10500
10989
  : compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
10501
10990
  ? "handoff"
10502
10991
  : "context-full";
10992
+ if (action === "snapcompact" && this.model && !this.model.input.includes("image")) {
10993
+ this.emitNotice(
10994
+ "warning",
10995
+ `snapcompact needs a vision-capable active model (${this.model.id} is text-only); using context-full auto-compaction instead.`,
10996
+ "compaction",
10997
+ );
10998
+ action = "context-full";
10999
+ }
10503
11000
  // Abort any older auto-compaction before installing this run's controller.
10504
11001
  this.#autoCompactionAbortController?.abort();
10505
11002
  const autoCompactionAbortController = new AbortController();
@@ -10578,7 +11075,11 @@ export class AgentSession {
10578
11075
 
10579
11076
  const pathEntries = this.sessionManager.getBranch();
10580
11077
 
10581
- const preparation = prepareCompaction(pathEntries, compactionSettings);
11078
+ const autoCompactionCandidates = await this.#runnableCompactionCandidates(
11079
+ this.#getCompactionModelCandidates(availableModels),
11080
+ this.sessionId,
11081
+ );
11082
+ const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates);
10582
11083
  if (!preparation) {
10583
11084
  await this.#emitSessionEvent({
10584
11085
  type: "auto_compaction_end",
@@ -10641,21 +11142,15 @@ export class AgentSession {
10641
11142
  // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
10642
11143
  // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
10643
11144
  // live window so we don't run snapcompact just to overflow every threshold
10644
- // tick. Any local blocker fails the snapcompact maintenance pass rather
10645
- // than falling back to a provider-backed LLM summary.
11145
+ // tick. Any local blocker (non-ASCII transcript, kept-history too large,
11146
+ // post-render overflow) downgrades auto maintenance to a context-full LLM
11147
+ // summary instead of wedging the session (#3659) — auto runs the default
11148
+ // strategy on the user's behalf, so a fallback that lets the session keep
11149
+ // running is the right behavior. Manual `/compact snapcompact` keeps the
11150
+ // local-only contract (#3599): the user explicitly picked it.
10646
11151
  let snapcompactResult: snapcompact.CompactionResult | undefined;
11152
+ let snapcompactBlocker: string | undefined;
10647
11153
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
10648
- if (!this.model?.input.includes("image")) {
10649
- logger.warn("Snapcompact compaction requires a vision-capable model", {
10650
- model: this.model?.id,
10651
- });
10652
- this.emitNotice(
10653
- "warning",
10654
- `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only). No LLM fallback was attempted.`,
10655
- "compaction",
10656
- );
10657
- throw new Error(`snapcompact cannot run locally: ${this.model?.id ?? "unknown"} is text-only.`);
10658
- }
10659
11154
  const text = snapcompact.serializeConversation(
10660
11155
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
10661
11156
  );
@@ -10666,55 +11161,46 @@ export class AgentSession {
10666
11161
  model: this.model?.id,
10667
11162
  unrenderableRatio: renderScan.unrenderableRatio,
10668
11163
  });
10669
- this.emitNotice(
10670
- "warning",
10671
- `snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`,
10672
- "compaction",
10673
- );
10674
- throw new Error(
10675
- `snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`,
10676
- );
10677
- }
10678
- const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
10679
- if (maxFrames < 1) {
10680
- logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10681
- model: this.model?.id,
10682
- });
10683
- this.emitNotice(
10684
- "warning",
10685
- "snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
10686
- "compaction",
10687
- );
10688
- throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
10689
- }
10690
- snapcompactResult = await snapcompact.compact(preparation, {
10691
- convertToLlm,
10692
- model: this.model,
10693
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
10694
- maxFrames,
10695
- });
10696
-
10697
- if (snapcompactResult) {
10698
- const ctxWindow = this.model?.contextWindow ?? 0;
10699
- const budget =
10700
- ctxWindow > 0
10701
- ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
10702
- : Number.POSITIVE_INFINITY;
10703
- const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
10704
- if (projected > budget) {
10705
- logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
11164
+ snapcompactBlocker = `snapcompact disabled: high non-ASCII rate detected (${percent}%); using context-full auto-compaction instead.`;
11165
+ } else {
11166
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
11167
+ if (maxFrames < 1) {
11168
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10706
11169
  model: this.model?.id,
10707
- projected,
10708
- budget,
10709
11170
  });
10710
- this.emitNotice(
10711
- "warning",
10712
- "snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
10713
- "compaction",
10714
- );
10715
- throw new Error("snapcompact could not bring the context under the limit locally.");
11171
+ snapcompactBlocker =
11172
+ "snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead.";
11173
+ } else {
11174
+ snapcompactResult = await snapcompact.compact(preparation, {
11175
+ convertToLlm,
11176
+ model: this.model,
11177
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
11178
+ maxFrames,
11179
+ });
11180
+ if (snapcompactResult) {
11181
+ const ctxWindow = this.model?.contextWindow ?? 0;
11182
+ const budget =
11183
+ ctxWindow > 0
11184
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
11185
+ : Number.POSITIVE_INFINITY;
11186
+ const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
11187
+ if (projected > budget) {
11188
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
11189
+ model: this.model?.id,
11190
+ projected,
11191
+ budget,
11192
+ });
11193
+ snapcompactBlocker =
11194
+ "snapcompact could not bring the context under the limit; using context-full auto-compaction instead.";
11195
+ snapcompactResult = undefined;
11196
+ }
11197
+ }
10716
11198
  }
10717
11199
  }
11200
+ if (snapcompactBlocker) {
11201
+ this.emitNotice("warning", snapcompactBlocker, "compaction");
11202
+ action = "context-full";
11203
+ }
10718
11204
  }
10719
11205
 
10720
11206
  if (compactionPrep.kind === "fromHook") {
@@ -10766,6 +11252,9 @@ export class AgentSession {
10766
11252
  // site. Clamped per-model inside compact() via
10767
11253
  // resolveCompactionEffort.
10768
11254
  thinkingLevel: this.thinkingLevel,
11255
+ tools: this.agent.state.tools,
11256
+ sessionId: this.sessionId,
11257
+ promptCacheKey: this.sessionId,
10769
11258
  },
10770
11259
  );
10771
11260
  break;
@@ -10775,11 +11264,12 @@ export class AgentSession {
10775
11264
  }
10776
11265
 
10777
11266
  const message = error instanceof Error ? error.message : String(error);
11267
+ const id = AIError.classify(error, candidate.api);
10778
11268
  if (this.#isCompactionAuthFailure(error)) {
10779
11269
  lastError = this.#buildCompactionAuthError();
10780
11270
  break;
10781
11271
  }
10782
- if (this.#isCompactionSummarizationTimeoutMessage(message)) {
11272
+ if (AIError.is(id, AIError.Flag.Timeout)) {
10783
11273
  logger.warn(
10784
11274
  hasMoreCandidates
10785
11275
  ? "Auto-compaction summarization timed out, trying next model"
@@ -10798,8 +11288,8 @@ export class AgentSession {
10798
11288
  retrySettings.enabled &&
10799
11289
  attempt < retrySettings.maxRetries &&
10800
11290
  (retryAfterMs !== undefined ||
10801
- this.#isTransientErrorMessage(message) ||
10802
- isUsageLimitError(message));
11291
+ AIError.is(id, AIError.Flag.Transient) ||
11292
+ AIError.is(id, AIError.Flag.UsageLimit));
10803
11293
  if (!shouldRetry) {
10804
11294
  lastError = error;
10805
11295
  break;
@@ -10881,7 +11371,7 @@ export class AgentSession {
10881
11371
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
10882
11372
  // the plan from disk and re-injects it on the next turn (issue #1246).
10883
11373
  this.#planReferenceSent = false;
10884
- this.#advisorRuntime?.reset();
11374
+ this.#resetAllAdvisorRuntimes();
10885
11375
  this.#syncTodoPhasesFromBranch();
10886
11376
  this.#closeCodexProviderSessionsForHistoryRewrite();
10887
11377
 
@@ -11198,6 +11688,31 @@ export class AgentSession {
11198
11688
  // Auto-Retry
11199
11689
  // =========================================================================
11200
11690
 
11691
+ /**
11692
+ * Classify retry decisions against the active session model. Test stream
11693
+ * shims and provider adapters can emit generic assistant metadata, but retry
11694
+ * policy belongs to the model that was actually requested for this turn.
11695
+ */
11696
+ #classifyRetryMessage(message: AssistantMessage): number {
11697
+ const activeModel = this.model;
11698
+ if (!activeModel || message.api === activeModel.api) {
11699
+ return AIError.classifyMessage(message);
11700
+ }
11701
+
11702
+ const id = AIError.classifyMessage({
11703
+ api: activeModel.api,
11704
+ errorId: message.errorId,
11705
+ errorMessage: message.errorMessage,
11706
+ errorStatus: message.errorStatus,
11707
+ });
11708
+ message.errorId = id;
11709
+ return id;
11710
+ }
11711
+
11712
+ #isGenericAbortSentinel(message: AssistantMessage): boolean {
11713
+ return message.errorMessage === "Request was aborted" || message.errorMessage === "Request was aborted.";
11714
+ }
11715
+
11201
11716
  /**
11202
11717
  * Retry an empty, reason-less provider abort: a turn that ended `aborted`
11203
11718
  * with no content and the generic sentinel (bare `abort()`), but only while
@@ -11210,14 +11725,22 @@ export class AgentSession {
11210
11725
  * `prompt()`) or silently undo the guard's intended abort.
11211
11726
  */
11212
11727
  #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
- );
11728
+ if (
11729
+ message.stopReason !== "aborted" ||
11730
+ message.content.length !== 0 ||
11731
+ this.#abortInProgress ||
11732
+ this.#isDisposed ||
11733
+ this.#streamingEditAbortTriggered
11734
+ ) {
11735
+ return false;
11736
+ }
11737
+
11738
+ const id = this.#classifyRetryMessage(message);
11739
+ if (AIError.is(id, AIError.Flag.Abort)) return true;
11740
+ if (!this.#isGenericAbortSentinel(message)) return false;
11741
+
11742
+ message.errorId = AIError.create(AIError.Flag.Abort);
11743
+ return true;
11221
11744
  }
11222
11745
 
11223
11746
  /**
@@ -11226,21 +11749,15 @@ export class AgentSession {
11226
11749
  * Usage-limit errors are retryable because the retry handler performs credential switching.
11227
11750
  */
11228
11751
  #isRetryableError(message: AssistantMessage): boolean {
11229
- if (message.stopReason !== "error" || !message.errorMessage) return false;
11752
+ if (message.stopReason !== "error") return false;
11230
11753
 
11754
+ const id = this.#classifyRetryMessage(message);
11231
11755
  // Context overflow is handled by compaction, not retry
11232
11756
  const contextWindow = this.model?.contextWindow ?? 0;
11233
- if (isContextOverflow(message, contextWindow)) return false;
11757
+ if (AIError.isContextOverflow(message, contextWindow)) return false;
11234
11758
 
11235
11759
  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);
11760
+ return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
11244
11761
  }
11245
11762
  /**
11246
11763
  * Retried turns remove the failed assistant message from active context.
@@ -11252,73 +11769,12 @@ export class AgentSession {
11252
11769
  return message.content.some(block => block.type === "toolCall");
11253
11770
  }
11254
11771
 
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
11772
  #isClassifierRefusal(message: AssistantMessage): boolean {
11277
11773
  if (message.stopReason !== "error") return false;
11278
11774
  const stopType = message.stopDetails?.type;
11279
11775
  return stopType === "refusal" || stopType === "sensitive";
11280
11776
  }
11281
11777
 
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
11778
  #getRetryFallbackChains(): RetryFallbackChains {
11323
11779
  const configuredChains = this.settings.get("retry.fallbackChains");
11324
11780
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -11551,20 +12007,15 @@ export class AgentSession {
11551
12007
  #isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
11552
12008
  const model = this.#activeFireworksFastModel();
11553
12009
  if (!model) return false;
11554
- if (message.stopReason !== "error" || !message.errorMessage) return false;
12010
+ if (message.stopReason !== "error") return false;
11555
12011
  if (message.content.some(block => block.type === "toolCall")) return false;
11556
12012
  // A content refusal/sensitivity stop is the model's decision, not a route
11557
12013
  // failure — switching to the base model would just re-trigger it.
11558
12014
  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;
12015
+ const id = this.#classifyRetryMessage(message);
12016
+ if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
12017
+ if (AIError.is(id, AIError.Flag.UsageLimit)) return false;
12018
+ if (AIError.is(id, AIError.Flag.AuthFailed)) return false;
11568
12019
  return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
11569
12020
  }
11570
12021
 
@@ -11730,7 +12181,8 @@ export class AgentSession {
11730
12181
  }
11731
12182
 
11732
12183
  const errorMessage = message.errorMessage || "Unknown error";
11733
- const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
12184
+ const id = this.#classifyRetryMessage(message);
12185
+ const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
11734
12186
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
11735
12187
  let delayMs = staleOpenAIResponsesReplayError
11736
12188
  ? 0
@@ -11745,7 +12197,7 @@ export class AgentSession {
11745
12197
  this.#resetCurrentResponsesProviderSession("stale replay error");
11746
12198
  }
11747
12199
 
11748
- if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
12200
+ if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
11749
12201
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
11750
12202
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
11751
12203
  this.model.provider,
@@ -11852,6 +12304,7 @@ export class AgentSession {
11852
12304
  maxAttempts: retrySettings.maxRetries,
11853
12305
  delayMs,
11854
12306
  errorMessage,
12307
+ errorId: message.errorId,
11855
12308
  });
11856
12309
 
11857
12310
  // Remove the failed assistant message from active context before retrying.
@@ -12764,7 +13217,7 @@ export class AgentSession {
12764
13217
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
12765
13218
  this.agent.serviceTier = previousServiceTier;
12766
13219
  this.#syncTodoPhasesFromBranch();
12767
- this.#advisorRuntime?.reset();
13220
+ this.#resetAllAdvisorRuntimes();
12768
13221
  this.#reconnectToAgent();
12769
13222
  if (restoreMcpError) {
12770
13223
  throw restoreMcpError;
@@ -13166,10 +13619,11 @@ export class AgentSession {
13166
13619
  let totalInput = 0;
13167
13620
  let totalOutput = 0;
13168
13621
  let totalCacheRead = 0;
13622
+ let totalReasoning = 0;
13169
13623
  let totalCacheWrite = 0;
13170
13624
  let totalCost = 0;
13171
-
13172
13625
  let totalPremiumRequests = 0;
13626
+
13173
13627
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
13174
13628
  if (!details || typeof details !== "object") return undefined;
13175
13629
  const record = details as Record<string, unknown>;
@@ -13184,6 +13638,7 @@ export class AgentSession {
13184
13638
  toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
13185
13639
  totalInput += assistantMsg.usage.input;
13186
13640
  totalOutput += assistantMsg.usage.output;
13641
+ totalReasoning += assistantMsg.usage.reasoningTokens ?? 0;
13187
13642
  totalCacheRead += assistantMsg.usage.cacheRead;
13188
13643
  totalCacheWrite += assistantMsg.usage.cacheWrite;
13189
13644
  totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
@@ -13195,6 +13650,7 @@ export class AgentSession {
13195
13650
  if (usage) {
13196
13651
  totalInput += usage.input;
13197
13652
  totalOutput += usage.output;
13653
+ totalReasoning += usage.reasoningTokens ?? 0;
13198
13654
  totalCacheRead += usage.cacheRead;
13199
13655
  totalCacheWrite += usage.cacheWrite;
13200
13656
  totalPremiumRequests += usage.premiumRequests ?? 0;
@@ -13214,6 +13670,7 @@ export class AgentSession {
13214
13670
  tokens: {
13215
13671
  input: totalInput,
13216
13672
  output: totalOutput,
13673
+ reasoning: totalReasoning,
13217
13674
  cacheRead: totalCacheRead,
13218
13675
  cacheWrite: totalCacheWrite,
13219
13676
  total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
@@ -13746,6 +14203,23 @@ export class AgentSession {
13746
14203
  return this.setAdvisorEnabled(!this.#advisorEnabled);
13747
14204
  }
13748
14205
 
14206
+ /**
14207
+ * Replace the live advisor roster from an edited `WATCHDOG.yml` (the `/advisor
14208
+ * configure` save path). Swaps the configs + shared baseline, then rebuilds the
14209
+ * runtimes in place so the change applies without a restart. When the advisor is
14210
+ * disabled the new configs are simply stored for the next enable.
14211
+ *
14212
+ * @returns the number of advisors active after the rebuild.
14213
+ */
14214
+ applyAdvisorConfigs(advisors: AdvisorConfig[], sharedInstructions: string | undefined): number {
14215
+ this.#advisorConfigs = advisors;
14216
+ this.#advisorSharedInstructions = sharedInstructions;
14217
+ if (!this.#advisorEnabled) return 0;
14218
+ this.#stopAdvisorRuntime();
14219
+ this.#buildAdvisorRuntime(true);
14220
+ return this.#advisors.length;
14221
+ }
14222
+
13749
14223
  /**
13750
14224
  * Whether the advisor setting is enabled for this session.
13751
14225
  */
@@ -13760,7 +14234,28 @@ export class AgentSession {
13760
14234
  * not merely the setting. Drives the status-line badge and `/dump advisor`.
13761
14235
  */
13762
14236
  isAdvisorActive(): boolean {
13763
- return this.#advisorAgent !== undefined;
14237
+ return this.#advisors.length > 0;
14238
+ }
14239
+
14240
+ /**
14241
+ * The names of the tools available to advisors this session (the pool a
14242
+ * `/advisor configure` editor lists). The advisor is a full agent, so this is the
14243
+ * full built tool set; a tool whose optional factory returns null (e.g. lsp with
14244
+ * no servers) is absent.
14245
+ */
14246
+ getAdvisorAvailableToolNames(): string[] {
14247
+ return (this.#advisorTools ?? []).map(tool => tool.name);
14248
+ }
14249
+
14250
+ /**
14251
+ * The live advisor `Agent`, or `undefined` when no advisor runtime is
14252
+ * attached. Surfaced for diagnostics (`/dump advisor` already serializes
14253
+ * its transcript via {@link formatAdvisorHistoryAsText}) and so callers can
14254
+ * verify the advisor inherits the session's provider-shaping options
14255
+ * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...).
14256
+ */
14257
+ getAdvisorAgent(): Agent | undefined {
14258
+ return this.#advisors[0]?.agent;
13764
14259
  }
13765
14260
 
13766
14261
  /**
@@ -13768,23 +14263,59 @@ export class AgentSession {
13768
14263
  */
13769
14264
  getAdvisorStats(): AdvisorStats {
13770
14265
  const configured = this.#advisorEnabled;
13771
- const advisor = this.#advisorAgent;
13772
- if (!advisor) {
14266
+ const advisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
14267
+ if (advisors.length === 0) {
13773
14268
  return {
13774
14269
  configured,
13775
14270
  active: false,
13776
14271
  contextWindow: 0,
13777
14272
  contextTokens: 0,
13778
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14273
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
13779
14274
  cost: 0,
13780
14275
  messages: { user: 0, assistant: 0, total: 0 },
14276
+ advisors: [],
13781
14277
  };
13782
14278
  }
13783
- const model = advisor.state.model;
13784
- const messages = advisor.state.messages;
14279
+ const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
14280
+ const messages = { user: 0, assistant: 0, total: 0 };
14281
+ let cost = 0;
14282
+ let contextTokens = 0;
14283
+ for (const a of advisors) {
14284
+ tokens.input += a.tokens.input;
14285
+ tokens.output += a.tokens.output;
14286
+ tokens.reasoning += a.tokens.reasoning;
14287
+ tokens.cacheRead += a.tokens.cacheRead;
14288
+ tokens.cacheWrite += a.tokens.cacheWrite;
14289
+ tokens.total += a.tokens.total;
14290
+ messages.user += a.messages.user;
14291
+ messages.assistant += a.messages.assistant;
14292
+ messages.total += a.messages.total;
14293
+ cost += a.cost;
14294
+ contextTokens += a.contextTokens;
14295
+ }
14296
+ // Single-advisor displays read the top-level model/window directly; surface the
14297
+ // first advisor's so the legacy status line stays byte-identical.
14298
+ return {
14299
+ configured,
14300
+ active: true,
14301
+ model: advisors[0].model,
14302
+ contextWindow: advisors[0].contextWindow,
14303
+ contextTokens,
14304
+ tokens,
14305
+ cost,
14306
+ messages,
14307
+ advisors,
14308
+ };
14309
+ }
14310
+
14311
+ /** Compute one advisor's stats slice (tokens, cost, context, message counts). */
14312
+ #computeAdvisorStat(advisor: ActiveAdvisor): PerAdvisorStat {
14313
+ const model = advisor.agent.state.model;
14314
+ const messages = advisor.agent.state.messages;
13785
14315
  const contextTokens = this.#estimateAdvisorContextTokens(messages);
13786
14316
  let input = 0;
13787
14317
  let output = 0;
14318
+ let reasoning = 0;
13788
14319
  let cacheRead = 0;
13789
14320
  let cacheWrite = 0;
13790
14321
  let cost = 0;
@@ -13797,24 +14328,18 @@ export class AgentSession {
13797
14328
  const assistantMsg = message as AssistantMessage;
13798
14329
  input += assistantMsg.usage.input;
13799
14330
  output += assistantMsg.usage.output;
14331
+ reasoning += assistantMsg.usage.reasoningTokens ?? 0;
13800
14332
  cacheRead += assistantMsg.usage.cacheRead;
13801
14333
  cacheWrite += assistantMsg.usage.cacheWrite;
13802
14334
  cost += assistantMsg.usage.cost.total;
13803
14335
  }
13804
14336
  }
13805
14337
  return {
13806
- configured,
13807
- active: true,
14338
+ name: advisor.name,
13808
14339
  model,
13809
14340
  contextWindow: model.contextWindow ?? 0,
13810
14341
  contextTokens,
13811
- tokens: {
13812
- input,
13813
- output,
13814
- cacheRead,
13815
- cacheWrite,
13816
- total: input + output + cacheRead + cacheWrite,
13817
- },
14342
+ tokens: { input, output, reasoning, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite },
13818
14343
  cost,
13819
14344
  messages: { user, assistant, total: messages.length },
13820
14345
  };
@@ -13830,19 +14355,30 @@ export class AgentSession {
13830
14355
  ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
13831
14356
  : "Advisor is disabled.";
13832
14357
  }
13833
- const model = stats.model!;
13834
- const contextLine =
13835
- stats.contextWindow > 0
13836
- ? `Context: ${stats.contextTokens.toLocaleString()} / ${stats.contextWindow.toLocaleString()} tokens (${Math.round((stats.contextTokens / stats.contextWindow) * 100)}%)`
13837
- : `Context: ${stats.contextTokens.toLocaleString()} tokens`;
13838
- const spendParts = [
13839
- `${stats.tokens.input.toLocaleString()} input`,
13840
- `${stats.tokens.output.toLocaleString()} output`,
13841
- ];
13842
- if (stats.tokens.cacheRead > 0) spendParts.push(`${stats.tokens.cacheRead.toLocaleString()} cache read`);
13843
- if (stats.tokens.cacheWrite > 0) spendParts.push(`${stats.tokens.cacheWrite.toLocaleString()} cache write`);
13844
- const spendLine = `Spend: ${spendParts.join(", ")}, $${stats.cost.toFixed(4)}`;
13845
- return `Advisor is enabled (${model.provider}/${model.id}). ${contextLine}. ${spendLine}.`;
14358
+ if (stats.advisors.length <= 1) {
14359
+ const s = stats.advisors[0];
14360
+ const contextLine =
14361
+ s.contextWindow > 0
14362
+ ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
14363
+ : `Context: ${s.contextTokens.toLocaleString()} tokens`;
14364
+ const spendParts = [`${s.tokens.input.toLocaleString()} input`, `${s.tokens.output.toLocaleString()} output`];
14365
+ if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
14366
+ if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
14367
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
14368
+ return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
14369
+ }
14370
+ const lines = [`Advisors enabled (${stats.advisors.length}):`];
14371
+ for (const s of stats.advisors) {
14372
+ const ctx =
14373
+ s.contextWindow > 0
14374
+ ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
14375
+ : `${s.contextTokens.toLocaleString()}`;
14376
+ lines.push(` • ${s.name} (${s.model.provider}/${s.model.id}) — context ${ctx} tokens, $${s.cost.toFixed(4)}`);
14377
+ }
14378
+ lines.push(
14379
+ `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
14380
+ );
14381
+ return lines.join("\n");
13846
14382
  }
13847
14383
 
13848
14384
  /**
@@ -13886,18 +14422,21 @@ export class AgentSession {
13886
14422
  * {@link formatSessionAsText}. Returns null when no advisor is active.
13887
14423
  */
13888
14424
  formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null {
13889
- const advisor = this.#advisorAgent;
13890
- if (!advisor) return null;
13891
- if (options?.compact) {
13892
- return formatSessionHistoryMarkdown(advisor.state.messages);
13893
- }
13894
- return formatSessionDumpText({
13895
- messages: advisor.state.messages,
13896
- systemPrompt: advisor.state.systemPrompt,
13897
- model: advisor.state.model,
13898
- thinkingLevel: advisor.state.thinkingLevel,
13899
- tools: advisor.state.tools,
13900
- });
14425
+ if (this.#advisors.length === 0) return null;
14426
+ const dump = (a: ActiveAdvisor): string =>
14427
+ options?.compact
14428
+ ? formatSessionHistoryMarkdown(a.agent.state.messages)
14429
+ : formatSessionDumpText({
14430
+ messages: a.agent.state.messages,
14431
+ systemPrompt: a.agent.state.systemPrompt,
14432
+ model: a.agent.state.model,
14433
+ thinkingLevel: a.agent.state.thinkingLevel,
14434
+ tools: a.agent.state.tools,
14435
+ });
14436
+ if (this.#advisors.length === 1) return dump(this.#advisors[0]);
14437
+ return this.#advisors
14438
+ .map(a => `### Advisor: ${a.name} (${a.agent.state.model.provider}/${a.agent.state.model.id})\n\n${dump(a)}`)
14439
+ .join("\n\n");
13901
14440
  }
13902
14441
 
13903
14442
  // =========================================================================