wave-agent-sdk 1.0.4 → 1.0.6

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 (194) hide show
  1. package/dist/agent.d.ts +23 -1
  2. package/dist/agent.js +34 -4
  3. package/dist/managers/aiManager.d.ts +36 -0
  4. package/dist/managers/aiManager.js +240 -8
  5. package/dist/managers/messageManager.d.ts +1 -3
  6. package/dist/managers/messageManager.js +19 -9
  7. package/dist/managers/slashCommandManager.js +28 -0
  8. package/dist/managers/subagentManager.d.ts +0 -2
  9. package/dist/services/session.js +2 -9
  10. package/dist/telemetry/instrumentation.d.ts +1 -1
  11. package/dist/telemetry/instrumentation.js +57 -28
  12. package/dist/tools/bashTool.js +16 -6
  13. package/dist/tools/grepTool.js +11 -3
  14. package/dist/types/index.d.ts +3 -0
  15. package/dist/types/messaging.d.ts +2 -0
  16. package/dist/utils/containerSetup.js +0 -3
  17. package/dist/utils/messageOperations.d.ts +10 -1
  18. package/dist/utils/messageOperations.js +19 -1
  19. package/dist/utils/notificationXml.js +5 -0
  20. package/dist/utils/ripgrep.d.ts +4 -4
  21. package/dist/utils/ripgrep.js +4 -27
  22. package/package.json +20 -8
  23. package/bin/rg +0 -79
  24. package/dist/managers/forkedAgentManager.d.ts +0 -50
  25. package/dist/managers/forkedAgentManager.js +0 -130
  26. package/scripts/install_ripgrep.js +0 -111
  27. package/scripts/postinstall.js +0 -38
  28. package/src/agent.ts +0 -1271
  29. package/src/constants/subagents.ts +0 -4
  30. package/src/constants/toolLimits.ts +0 -15
  31. package/src/constants/tools.ts +0 -24
  32. package/src/core/plugin.ts +0 -237
  33. package/src/core/session.ts +0 -9
  34. package/src/index.ts +0 -38
  35. package/src/managers/MemoryRuleManager.ts +0 -198
  36. package/src/managers/aiManager.ts +0 -2429
  37. package/src/managers/backgroundTaskManager.ts +0 -480
  38. package/src/managers/bangManager.ts +0 -119
  39. package/src/managers/cronManager.ts +0 -401
  40. package/src/managers/foregroundTaskManager.ts +0 -30
  41. package/src/managers/forkedAgentManager.ts +0 -216
  42. package/src/managers/hookManager.ts +0 -1089
  43. package/src/managers/liveConfigManager.ts +0 -445
  44. package/src/managers/lspManager.ts +0 -470
  45. package/src/managers/mcpManager.ts +0 -1010
  46. package/src/managers/messageManager.ts +0 -1180
  47. package/src/managers/messageQueue.ts +0 -157
  48. package/src/managers/permissionManager.ts +0 -1140
  49. package/src/managers/planManager.ts +0 -112
  50. package/src/managers/pluginManager.ts +0 -330
  51. package/src/managers/pluginScopeManager.ts +0 -124
  52. package/src/managers/reversionManager.ts +0 -178
  53. package/src/managers/skillManager.ts +0 -578
  54. package/src/managers/slashCommandManager.ts +0 -564
  55. package/src/managers/subagentManager.ts +0 -915
  56. package/src/managers/toolManager.ts +0 -489
  57. package/src/managers/workflowManager.ts +0 -493
  58. package/src/prompts/autoMemory.ts +0 -33
  59. package/src/prompts/autoMemoryExtraction.ts +0 -146
  60. package/src/prompts/index.ts +0 -506
  61. package/src/prompts/planModeReminders.ts +0 -126
  62. package/src/services/GitService.ts +0 -131
  63. package/src/services/MarketplaceService.ts +0 -1052
  64. package/src/services/MemoryRuleService.ts +0 -71
  65. package/src/services/aiService.ts +0 -919
  66. package/src/services/authService.ts +0 -565
  67. package/src/services/autoMemoryService.ts +0 -280
  68. package/src/services/configurationService.ts +0 -1520
  69. package/src/services/fileWatcher.ts +0 -319
  70. package/src/services/hook.ts +0 -348
  71. package/src/services/initializationService.ts +0 -357
  72. package/src/services/interactionService.ts +0 -229
  73. package/src/services/jsonlHandler.ts +0 -295
  74. package/src/services/memory.ts +0 -234
  75. package/src/services/pluginLoader.ts +0 -252
  76. package/src/services/remoteSettingsService.ts +0 -366
  77. package/src/services/reversionService.ts +0 -122
  78. package/src/services/session.ts +0 -922
  79. package/src/services/taskManager.ts +0 -321
  80. package/src/telemetry/events.ts +0 -65
  81. package/src/telemetry/instrumentation.ts +0 -499
  82. package/src/telemetry/sessionTracing.ts +0 -348
  83. package/src/tools/agentTool.ts +0 -322
  84. package/src/tools/askUserQuestion.ts +0 -160
  85. package/src/tools/bashTool.ts +0 -621
  86. package/src/tools/buildTool.ts +0 -61
  87. package/src/tools/cronCreateTool.ts +0 -161
  88. package/src/tools/cronDeleteTool.ts +0 -51
  89. package/src/tools/cronListTool.ts +0 -47
  90. package/src/tools/editTool.ts +0 -313
  91. package/src/tools/enterPlanMode.ts +0 -124
  92. package/src/tools/enterWorktreeTool.ts +0 -190
  93. package/src/tools/exitPlanMode.ts +0 -139
  94. package/src/tools/exitWorktreeTool.ts +0 -236
  95. package/src/tools/globTool.ts +0 -167
  96. package/src/tools/grepTool.ts +0 -399
  97. package/src/tools/lspTool.ts +0 -883
  98. package/src/tools/readTool.ts +0 -426
  99. package/src/tools/skillTool.ts +0 -248
  100. package/src/tools/taskManagementTools.ts +0 -574
  101. package/src/tools/taskStopTool.ts +0 -78
  102. package/src/tools/types.ts +0 -137
  103. package/src/tools/webFetchTool.ts +0 -373
  104. package/src/tools/workflowTool.ts +0 -205
  105. package/src/tools/writeTool.ts +0 -267
  106. package/src/types/agent.ts +0 -128
  107. package/src/types/auth.ts +0 -19
  108. package/src/types/commands.ts +0 -30
  109. package/src/types/config.ts +0 -43
  110. package/src/types/configuration.ts +0 -168
  111. package/src/types/core.ts +0 -105
  112. package/src/types/cron.ts +0 -13
  113. package/src/types/environment.ts +0 -106
  114. package/src/types/fileSearch.ts +0 -4
  115. package/src/types/history.ts +0 -7
  116. package/src/types/hooks.ts +0 -256
  117. package/src/types/index.ts +0 -41
  118. package/src/types/lsp.ts +0 -98
  119. package/src/types/marketplace.ts +0 -71
  120. package/src/types/mcp.ts +0 -43
  121. package/src/types/memoryRule.ts +0 -31
  122. package/src/types/messaging.ts +0 -114
  123. package/src/types/permissions.ts +0 -80
  124. package/src/types/plugins.ts +0 -39
  125. package/src/types/processes.ts +0 -99
  126. package/src/types/reversion.ts +0 -29
  127. package/src/types/session.ts +0 -12
  128. package/src/types/skills.ts +0 -108
  129. package/src/types/tasks.ts +0 -13
  130. package/src/types/telemetry.ts +0 -98
  131. package/src/types/tools.ts +0 -44
  132. package/src/types/workflow.ts +0 -6
  133. package/src/utils/abortUtils.ts +0 -118
  134. package/src/utils/atomicWrite.ts +0 -61
  135. package/src/utils/bashParser.ts +0 -899
  136. package/src/utils/cacheControlUtils.ts +0 -426
  137. package/src/utils/commandArgumentParser.ts +0 -161
  138. package/src/utils/commandPathResolver.ts +0 -88
  139. package/src/utils/configPaths.ts +0 -220
  140. package/src/utils/configValidator.ts +0 -100
  141. package/src/utils/constants.ts +0 -39
  142. package/src/utils/container.ts +0 -92
  143. package/src/utils/containerSetup.ts +0 -371
  144. package/src/utils/convertMessagesForAPI.ts +0 -326
  145. package/src/utils/cronTasks.ts +0 -128
  146. package/src/utils/cronTasksLock.ts +0 -182
  147. package/src/utils/cronToHuman.ts +0 -99
  148. package/src/utils/customCommands.ts +0 -95
  149. package/src/utils/editUtils.ts +0 -20
  150. package/src/utils/fileFormat.ts +0 -40
  151. package/src/utils/fileSearch.ts +0 -151
  152. package/src/utils/fileUtils.ts +0 -223
  153. package/src/utils/gitUtils.ts +0 -315
  154. package/src/utils/globalLogger.ts +0 -128
  155. package/src/utils/groupMessagesByApiRound.ts +0 -121
  156. package/src/utils/hookMatcher.ts +0 -176
  157. package/src/utils/markdownParser.ts +0 -317
  158. package/src/utils/mcpUtils.ts +0 -138
  159. package/src/utils/messageOperations.ts +0 -641
  160. package/src/utils/modelCapabilities.ts +0 -30
  161. package/src/utils/nameGenerator.ts +0 -99
  162. package/src/utils/notificationXml.ts +0 -57
  163. package/src/utils/openaiClient.ts +0 -251
  164. package/src/utils/parseCronExpression.ts +0 -78
  165. package/src/utils/path.ts +0 -72
  166. package/src/utils/pathEncoder.ts +0 -400
  167. package/src/utils/pathSafety.ts +0 -40
  168. package/src/utils/promptHistory.ts +0 -168
  169. package/src/utils/ripgrep.ts +0 -35
  170. package/src/utils/shellResolver.ts +0 -190
  171. package/src/utils/skillParser.ts +0 -246
  172. package/src/utils/stringUtils.ts +0 -156
  173. package/src/utils/subagentParser.ts +0 -279
  174. package/src/utils/taskReminder.ts +0 -96
  175. package/src/utils/tokenCalculation.ts +0 -43
  176. package/src/utils/tokenEstimate.ts +0 -34
  177. package/src/utils/toolResultStorage.ts +0 -117
  178. package/src/utils/worktreeSession.ts +0 -26
  179. package/src/utils/worktreeUtils.ts +0 -786
  180. package/src/workflow/budgetTracker.ts +0 -34
  181. package/src/workflow/concurrencyLimiter.ts +0 -47
  182. package/src/workflow/journal.ts +0 -95
  183. package/src/workflow/progressReporter.ts +0 -141
  184. package/src/workflow/runState.ts +0 -65
  185. package/src/workflow/scriptRuntime.ts +0 -274
  186. package/src/workflow/structuredOutput.ts +0 -123
  187. package/src/workflow/types.ts +0 -101
  188. package/src/workflow/workflowApis.ts +0 -410
  189. package/vendor/ripgrep/linux-aarch64/rg +0 -0
  190. package/vendor/ripgrep/linux-x86_64/rg +0 -0
  191. package/vendor/ripgrep/macos-aarch64/rg +0 -0
  192. package/vendor/ripgrep/macos-x86_64/rg +0 -0
  193. package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
  194. package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
@@ -1,2429 +0,0 @@
1
- import { type CallAgentOptions } from "../services/aiService.js";
2
- import * as aiService from "../services/aiService.js";
3
- import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
4
- import { supportsVision } from "../utils/modelCapabilities.js";
5
- import { parseTaskNotificationXml } from "../utils/notificationXml.js";
6
- import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
7
- import { estimateTokens } from "../utils/tokenEstimate.js";
8
- import {
9
- getTaskReminderTurnCounts,
10
- maybeInjectTaskReminder,
11
- TASK_REMINDER_CONFIG,
12
- } from "../utils/taskReminder.js";
13
- import { existsSync } from "node:fs";
14
- import type {
15
- GatewayConfig,
16
- ModelConfig,
17
- Usage,
18
- PermissionMode,
19
- Message,
20
- } from "../types/index.js";
21
- import type { ToolManager } from "./toolManager.js";
22
- import type { ToolContext, ToolResult } from "../tools/types.js";
23
- import type { MessageManager } from "./messageManager.js";
24
- import type { BackgroundTaskManager } from "./backgroundTaskManager.js";
25
- import {
26
- ChatCompletionMessageFunctionToolCall,
27
- type ChatCompletionMessageParam,
28
- } from "openai/resources.js";
29
-
30
- import type { HookManager } from "./hookManager.js";
31
- import type { ExtendedHookExecutionContext } from "../types/hooks.js";
32
- import type { BackgroundTaskInfo, SessionCronInfo } from "../types/hooks.js";
33
- import type { PermissionManager } from "./permissionManager.js";
34
- import type { SubagentManager } from "./subagentManager.js";
35
- import type { CronManager } from "./cronManager.js";
36
- import type { SkillManager } from "./skillManager.js";
37
- import {
38
- buildSystemPrompt,
39
- formatCompactSummary,
40
- getCompactPrompt,
41
- } from "../prompts/index.js";
42
- import {
43
- buildPlanModeReminder,
44
- buildPlanModeReEntryReminder,
45
- buildExitedPlanModeReminder,
46
- wrapInSystemReminder,
47
- } from "../prompts/planModeReminders.js";
48
- import { Container } from "../utils/container.js";
49
- import type { WorktreeSession } from "../utils/worktreeSession.js";
50
- import { recoverTruncatedJson } from "../utils/stringUtils.js";
51
- import { ConfigurationService } from "../services/configurationService.js";
52
- import type { MessageQueue } from "./messageQueue.js";
53
-
54
- import { logger } from "../utils/globalLogger.js";
55
- import {
56
- startInteractionSpan,
57
- endInteractionSpan,
58
- startLLMRequestSpan,
59
- endLLMRequestSpan,
60
- resetTracingState,
61
- } from "../telemetry/sessionTracing.js";
62
- import { logOTelEvent } from "../telemetry/events.js";
63
- import type { BackgroundTask } from "../types/processes.js";
64
-
65
- /** Result of a fork-path agent loop (compaction or auto-memory extraction). */
66
- interface ForkLoopResult {
67
- content?: string;
68
- usage?: {
69
- prompt_tokens: number;
70
- completion_tokens: number;
71
- total_tokens: number;
72
- };
73
- }
74
-
75
- /** Max turns for the compaction fork: the model should summarize, not act. */
76
- const MAX_FORK_TURNS = 3;
77
-
78
- /** Max turns for the auto-memory extraction fork. */
79
- const MAX_AUTO_MEMORY_FORK_TURNS = 5;
80
-
81
- // Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
82
- // Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
83
- function truncateWithMarker(text: string, max: number): string {
84
- if (text.length <= max) return text;
85
- return text.slice(0, max) + `… [+${text.length - max} chars]`;
86
- }
87
-
88
- // Map a BackgroundTask (from BackgroundTaskManager) to the Stop hook's
89
- // background_tasks array element shape (aligned with Claude Code v2.1.145+).
90
- // Conditional fields are populated based on task type:
91
- // - shell: `command`
92
- // - subagent: `agent_type` (looked up via SubagentManager by subagentId)
93
- // - workflow: `name` (extracted from "Workflow: <name>" description)
94
- function mapBackgroundTask(
95
- task: BackgroundTask,
96
- subagentManager: SubagentManager | undefined,
97
- ): BackgroundTaskInfo {
98
- const base: BackgroundTaskInfo = {
99
- id: task.id,
100
- type: task.type,
101
- status: task.status,
102
- description: truncateWithMarker(task.description ?? "", 1000),
103
- };
104
- if (task.type === "shell") {
105
- return { ...base, command: truncateWithMarker(task.command ?? "", 1000) };
106
- }
107
- if (task.type === "subagent") {
108
- const instance = subagentManager
109
- ?.getActiveInstances()
110
- .find((i) => i.subagentId === task.subagentId);
111
- return { ...base, agent_type: instance?.subagentType ?? "" };
112
- }
113
- // workflow
114
- const prefix = "Workflow: ";
115
- const name = task.description?.startsWith(prefix)
116
- ? task.description.slice(prefix.length)
117
- : (task.description ?? "");
118
- return { ...base, name };
119
- }
120
-
121
- export interface AIManagerCallbacks {
122
- onCompactionStateChange?: (isCompacting: boolean) => void;
123
- onUsageAdded?: (usage: Usage) => void;
124
- onCwdChange?: (newCwd: string) => void;
125
- }
126
-
127
- export interface AIManagerOptions {
128
- callbacks?: AIManagerCallbacks;
129
- workdir: string;
130
- systemPrompt?: string;
131
- subagentType?: string; // Optional subagent type for hook context
132
- /**Whether to use streaming mode for AI responses - defaults to true */
133
- stream?: boolean;
134
- /**Optional model override (e.g. for subagents) */
135
- modelOverride?: string;
136
- /**Optional max turns limit to prevent runaway recursion (e.g. for auto-memory extraction) */
137
- maxTurns?: number;
138
- }
139
-
140
- export class AIManager {
141
- public isLoading: boolean = false;
142
- private turnGeneration = 0; // Guards against concurrent sendAIMessage turns
143
- private abortController: AbortController | null = null;
144
- onLoadingChange?: (loading: boolean) => void;
145
- private toolAbortController: AbortController | null = null;
146
- private systemPrompt?: string;
147
- private subagentType?: string; // Store subagent type for hook context
148
- private stream: boolean; // Streaming mode flag
149
- private modelOverride?: string;
150
- private _onCwdChange?: (newCwd: string) => void; // Store callback for CWD changes
151
- private originalWorkdir: string;
152
- private consecutiveCompactionFailures: number = 0;
153
- private readonly maxTurns?: number;
154
- /** Tracks file mtime/hash at read time for staleness detection on Edit/Write */
155
- private readFileState = new Map<
156
- string,
157
- { mtime: number; hash: string; offset?: number; limit?: number }
158
- >();
159
- /** Override tool_choice for this AI manager (e.g. for structured output) */
160
- public toolChoiceOverride?:
161
- | "auto"
162
- | "none"
163
- | "required"
164
- | { type: "function"; function: { name: string } };
165
-
166
- // Service overrides
167
- constructor(
168
- private container: Container,
169
- options: AIManagerOptions,
170
- ) {
171
- this.systemPrompt = options.systemPrompt;
172
- this.subagentType = options.subagentType; // Store subagent type
173
- this.stream = options.stream ?? true; // Default to true if not specified
174
- this.callbacks = options.callbacks ?? {};
175
- this.modelOverride = options.modelOverride;
176
- this._onCwdChange = options.callbacks?.onCwdChange; // Initialize onCwdChange
177
- this.originalWorkdir = options.workdir;
178
- this.maxTurns = options.maxTurns;
179
- }
180
-
181
- private get toolManager(): ToolManager {
182
- return this.container.get<ToolManager>("ToolManager")!;
183
- }
184
-
185
- private get messageManager(): MessageManager {
186
- return this.container.get<MessageManager>("MessageManager")!;
187
- }
188
-
189
- private get memoryService(): import("../services/memory.js").MemoryService {
190
- return this.container.get<import("../services/memory.js").MemoryService>(
191
- "MemoryService",
192
- )!;
193
- }
194
-
195
- private get taskManager(): import("../services/taskManager.js").TaskManager {
196
- return this.container.get<import("../services/taskManager.js").TaskManager>(
197
- "TaskManager",
198
- )!;
199
- }
200
-
201
- private get backgroundTaskManager(): BackgroundTaskManager | undefined {
202
- return this.container.get<BackgroundTaskManager>("BackgroundTaskManager");
203
- }
204
-
205
- private get hookManager(): HookManager | undefined {
206
- return this.container.get<HookManager>("HookManager");
207
- }
208
-
209
- private get reversionManager():
210
- | import("./reversionManager.js").ReversionManager
211
- | undefined {
212
- return this.container.get<import("./reversionManager.js").ReversionManager>(
213
- "ReversionManager",
214
- );
215
- }
216
-
217
- private get permissionManager(): PermissionManager | undefined {
218
- return this.container.get<PermissionManager>("PermissionManager");
219
- }
220
-
221
- private get planManager():
222
- | import("./planManager.js").PlanManager
223
- | undefined {
224
- return this.container.get<import("./planManager.js").PlanManager>(
225
- "PlanManager",
226
- );
227
- }
228
-
229
- private get configurationService(): ConfigurationService {
230
- return this.container.get<ConfigurationService>("ConfigurationService")!;
231
- }
232
-
233
- /**
234
- * OS env merged with the per-session env snapshot. Falls back to process.env
235
- * when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
236
- * unit tests with partial mocks), so hook-context env construction never
237
- * throws. Use this (not the non-null `configurationService` getter) when
238
- * building hook context env.
239
- */
240
- private get mergedEnv(): Record<string, string> {
241
- return (
242
- this.container
243
- .get<ConfigurationService>("ConfigurationService")
244
- ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
245
- );
246
- }
247
-
248
- // Getter methods for accessing dynamic configuration
249
- public getGatewayConfig(): GatewayConfig {
250
- return {
251
- ...this.configurationService.resolveGatewayConfig(),
252
- sessionId: this.messageManager.getSessionId(),
253
- };
254
- }
255
-
256
- public getModelConfig(): ModelConfig {
257
- const permissionMode = this.container.has("PermissionMode")
258
- ? this.container.get<PermissionMode>("PermissionMode")
259
- : undefined;
260
-
261
- const parentModelConfig = this.configurationService.resolveModelConfig(
262
- undefined,
263
- undefined,
264
- undefined,
265
- permissionMode,
266
- );
267
- let modelToUse: string | undefined;
268
-
269
- if (this.modelOverride) {
270
- if (this.modelOverride === "fastModel") {
271
- modelToUse = parentModelConfig.fastModel;
272
- } else if (this.modelOverride !== "inherit") {
273
- modelToUse = this.modelOverride;
274
- }
275
- }
276
-
277
- return this.configurationService.resolveModelConfig(
278
- modelToUse,
279
- undefined,
280
- undefined,
281
- permissionMode,
282
- );
283
- }
284
-
285
- public getMaxInputTokens(): number {
286
- return this.configurationService.resolveMaxInputTokens();
287
- }
288
-
289
- public getLanguage(): string | undefined {
290
- return this.configurationService.resolveLanguage();
291
- }
292
-
293
- public getAutoMemoryEnabled(): boolean {
294
- return this.configurationService.resolveAutoMemoryEnabled();
295
- }
296
-
297
- public getWorktreeBaseRef(): "fresh" | "head" {
298
- return this.configurationService.resolveWorktreeBaseRef();
299
- }
300
-
301
- public getWorkdir(): string {
302
- return this.container.get<string>("Workdir") ?? process.cwd();
303
- }
304
-
305
- public getOriginalWorkdir(): string {
306
- return this.originalWorkdir;
307
- }
308
-
309
- /**
310
- * Update the working directory mid-session (e.g., when entering/exiting a worktree).
311
- * Only updates this session's DI container; it does NOT change the process-level
312
- * process.cwd(), so concurrent sessions in the same stdio process are unaffected.
313
- * Triggers `_onCwdChange` (wired by Agent to `onWorkdirChange`) so the host is
314
- * notified of worktree switches. Unlike bash `cd`, this does NOT run CwdChanged
315
- * hooks (worktree has its own WorktreeCreate/WorktreeRemove hooks).
316
- */
317
- public setWorkdir(newWorkdir: string): void {
318
- this.container.register("Workdir", newWorkdir);
319
- this._onCwdChange?.(newWorkdir);
320
- }
321
-
322
- /**
323
- * Get this session's worktree session state (null if not in a worktree).
324
- */
325
- public getWorktreeSession(): WorktreeSession | null {
326
- return (
327
- this.container.get<WorktreeSession | null>("WorktreeSession") ?? null
328
- );
329
- }
330
-
331
- /**
332
- * Set this session's worktree session state.
333
- */
334
- public setWorktreeSession(session: WorktreeSession | null): void {
335
- this.container.register("WorktreeSession", session);
336
- }
337
-
338
- public setOnCwdChange(callback: (newCwd: string) => void): void {
339
- this._onCwdChange = callback;
340
- }
341
-
342
- private isCompacting: boolean = false;
343
- private callbacks: AIManagerCallbacks;
344
-
345
- /**
346
- * Get filtered tool configuration based on tools list
347
- */
348
- private getFilteredToolsConfig() {
349
- // Get available subagents and skills for dynamic prompts
350
- const availableSubagents = this.subagentManager?.getConfigurations();
351
- const availableSkills = this.skillManager
352
- ?.getAvailableSkills()
353
- .filter((skill) => !skill.disableModelInvocation);
354
-
355
- return this.toolManager.getToolsConfig({
356
- availableSubagents,
357
- availableSkills,
358
- workdir: this.getWorkdir(),
359
- isSubagent: !!this.subagentType,
360
- });
361
- }
362
-
363
- /**
364
- * Add plan mode reminder as a persistent meta message to the message manager.
365
- * Called before getMessages() so the stored message is included in the API call.
366
- */
367
- private maybeAddPlanModeMessage(
368
- currentMode: PermissionMode | undefined,
369
- ): void {
370
- if (!this.permissionManager) return;
371
-
372
- // Handle exit notification (one-time after leaving plan mode)
373
- if (this.permissionManager.getNeedsPlanModeExitAttachment()) {
374
- const planFilePath = this.permissionManager.getPlanFilePath();
375
- const planExists = planFilePath ? existsSync(planFilePath) : false;
376
- this.messageManager.addUserMessage({
377
- content: buildExitedPlanModeReminder(planFilePath, planExists),
378
- isMeta: true,
379
- });
380
- this.permissionManager.setNeedsPlanModeExitAttachment(false);
381
- }
382
-
383
- if (currentMode !== "plan") return;
384
-
385
- const planFilePath = this.permissionManager.getPlanFilePath();
386
- if (!planFilePath) return;
387
-
388
- const planExists = existsSync(planFilePath);
389
-
390
- // One-time plan entry reminder
391
- const planMgr = this.planManager;
392
- if (planMgr?.isPlanEntryReminderPending()) {
393
- if (this.permissionManager.hasExitedPlanModeInSession() && planExists) {
394
- // Re-entry: use small reminder
395
- this.messageManager.addUserMessage({
396
- content: buildPlanModeReEntryReminder(planFilePath),
397
- isMeta: true,
398
- });
399
- } else {
400
- // First entry: use full reminder
401
- this.messageManager.addUserMessage({
402
- content: buildPlanModeReminder(
403
- planFilePath,
404
- planExists,
405
- !!this.subagentType,
406
- ),
407
- isMeta: true,
408
- });
409
- }
410
- planMgr.consumePlanEntryReminder();
411
- }
412
- }
413
-
414
- private async maybeGetTaskReminderText(
415
- toolNames: Set<string>,
416
- ): Promise<string | null> {
417
- // Guard: no task tools available
418
- if (!toolNames.has("TaskUpdate")) return null;
419
-
420
- const internalMessages = this.messageManager.getMessages();
421
- const turnCounts = getTaskReminderTurnCounts(internalMessages);
422
-
423
- if (
424
- turnCounts.turnsSinceLastTaskManagement <
425
- TASK_REMINDER_CONFIG.TURNS_SINCE_WRITE ||
426
- turnCounts.turnsSinceLastReminder <
427
- TASK_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
428
- ) {
429
- return null;
430
- }
431
-
432
- const tasks = await this.taskManager.listTasks();
433
- return maybeInjectTaskReminder(turnCounts, tasks);
434
- }
435
-
436
- public setIsLoading(isLoading: boolean): void {
437
- this.isLoading = isLoading;
438
- this.onLoadingChange?.(isLoading);
439
- const options =
440
- this.container.get<import("../types/agent.js").AgentOptions>(
441
- "AgentOptions",
442
- );
443
- options?.callbacks?.onLoadingChange?.(isLoading);
444
- }
445
-
446
- public abortAIMessage(): void {
447
- // Bump generation so the in-flight turn's end-of-turn setIsLoading(false)
448
- // is skipped (generation mismatch), preventing it from racing with this abort.
449
- this.turnGeneration++;
450
-
451
- // Interrupt AI service
452
- if (this.abortController) {
453
- try {
454
- this.abortController.abort();
455
- } catch (error) {
456
- logger?.error("Failed to abort AI service:", error);
457
- }
458
- }
459
-
460
- // Interrupt tool execution
461
- if (this.toolAbortController) {
462
- try {
463
- this.toolAbortController.abort();
464
- } catch (error) {
465
- logger?.error("Failed to abort tool execution:", error);
466
- }
467
- }
468
-
469
- this.setIsLoading(false);
470
- }
471
-
472
- // Helper method to generate compactParams
473
- private generateCompactParams(
474
- toolName: string,
475
- toolArgs: Record<string, unknown>,
476
- ): string {
477
- try {
478
- const toolPlugin = this.toolManager
479
- .list()
480
- .find((plugin) => plugin.name === toolName);
481
- if (toolPlugin?.formatCompactParams) {
482
- const context: ToolContext = {
483
- workdir: this.getWorkdir(),
484
- originalWorkdir: this.originalWorkdir,
485
- taskManager: this.taskManager,
486
- };
487
- return toolPlugin.formatCompactParams(toolArgs, context);
488
- }
489
- } catch (error) {
490
- logger?.warn("Failed to generate compactParams", error);
491
- }
492
- return "";
493
- }
494
-
495
- // Private method to handle token statistics and message compaction
496
- private async handleTokenUsageAndCompaction(
497
- usage: Usage | undefined,
498
- abortController: AbortController,
499
- ): Promise<void> {
500
- if (!usage) return;
501
-
502
- // Update token statistics - display comprehensive token usage including cache tokens
503
- const comprehensiveTotalTokens = calculateComprehensiveTotalTokens(usage);
504
- this.messageManager.setlatestTotalTokens(comprehensiveTotalTokens);
505
-
506
- // Check if token limit exceeded - use injected configuration
507
- if (
508
- usage.total_tokens +
509
- (usage.cache_read_input_tokens || 0) +
510
- (usage.cache_creation_input_tokens || 0) >
511
- this.getMaxInputTokens()
512
- ) {
513
- logger?.debug(
514
- `Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`,
515
- );
516
-
517
- const messagesToCompact = this.messageManager.getMessages();
518
- if (messagesToCompact.length === 0) return;
519
-
520
- // Circuit breaker: skip compaction after 3 consecutive failures
521
- if (this.consecutiveCompactionFailures >= 3) {
522
- logger?.warn(
523
- `Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`,
524
- );
525
- return;
526
- }
527
-
528
- await this.compactConversation({
529
- abortSignal: abortController.signal,
530
- });
531
- }
532
- }
533
-
534
- /**
535
- * Manually compact the conversation history.
536
- * Called by /compact slash command or auto-compaction trigger.
537
- */
538
- public async compactConversation(
539
- options: {
540
- customInstructions?: string;
541
- abortSignal?: AbortSignal;
542
- } = {},
543
- ): Promise<void> {
544
- const messagesToCompact = this.messageManager.getMessages();
545
- if (messagesToCompact.length === 0) {
546
- logger?.debug("No messages to compact");
547
- return;
548
- }
549
-
550
- // Circuit breaker: skip if already compacting
551
- if (this.isCompacting) {
552
- logger?.warn("Compaction already in progress");
553
- return;
554
- }
555
-
556
- // 1. Run PreCompact hooks
557
- let hookInstructions: string | undefined;
558
- if (this.hookManager) {
559
- try {
560
- const preResult = await this.hookManager.executePreCompactHooks(
561
- this.messageManager.getSessionId(),
562
- this.messageManager.getTranscriptPath(),
563
- options.customInstructions,
564
- );
565
- hookInstructions = preResult.additionalInstructions;
566
- } catch (error) {
567
- logger?.warn(`PreCompact hooks failed: ${(error as Error).message}`);
568
- }
569
- }
570
-
571
- // 2. Merge custom instructions
572
- const mergedInstructions =
573
- [options.customInstructions, hookInstructions]
574
- .filter(Boolean)
575
- .join("\n") || undefined;
576
-
577
- // 3. Save session before compaction
578
- await this.messageManager.saveSession();
579
-
580
- this.setIsCompacting(true);
581
- try {
582
- const modelConfig = this.getModelConfig();
583
- const recentChatMessages = convertMessagesForAPI(messagesToCompact, {
584
- supportsVision: supportsVision(modelConfig.capabilities),
585
- });
586
- const compactPrompt = getCompactPrompt(mergedInstructions);
587
-
588
- // 4. Fork path: fork the conversation with the same system prompt,
589
- // tools, model, and generation params as the main loop so the forked
590
- // request prefix matches exactly and the prompt cache is reused.
591
- const forkResult = await this.runCompactFork(
592
- recentChatMessages,
593
- compactPrompt,
594
- options.abortSignal,
595
- );
596
- const summaryContent = forkResult.content;
597
- const compactTokens = forkResult.usage;
598
- if (!summaryContent) {
599
- throw new Error(
600
- "Compaction failed: the model produced no summary output",
601
- );
602
- }
603
- const compactModel = modelConfig.model;
604
-
605
- // 5. Handle usage tracking
606
- let compactUsage: Usage | undefined;
607
- if (compactTokens) {
608
- compactUsage = {
609
- ...compactTokens,
610
- model: compactModel,
611
- operation_type: "compact",
612
- };
613
- }
614
-
615
- // 6. Strip the <analysis> scratchpad and extract the <summary> body
616
- const formattedSummary = formatCompactSummary(summaryContent);
617
-
618
- // 7. Build post-compact context restoration
619
- const enhancedSummary =
620
- await this.buildPostCompactContext(formattedSummary);
621
-
622
- // 8. Execute message reconstruction
623
- await this.messageManager.compactMessagesAndUpdateSession(
624
- enhancedSummary,
625
- compactUsage,
626
- );
627
-
628
- // Re-add plan mode reminder as persistent meta message after compaction
629
- const postCompactMode = this.permissionManager?.getCurrentEffectiveMode(
630
- this.getModelConfig().permissionMode,
631
- );
632
- if (postCompactMode === "plan") {
633
- const planFilePath = this.permissionManager?.getPlanFilePath();
634
- if (planFilePath) {
635
- const planExists = existsSync(planFilePath);
636
- this.messageManager.addUserMessage({
637
- content: buildPlanModeReminder(
638
- planFilePath,
639
- planExists,
640
- !!this.subagentType,
641
- ),
642
- isMeta: true,
643
- });
644
- }
645
- }
646
-
647
- // 9. Track usage
648
- if (compactUsage && this.callbacks?.onUsageAdded) {
649
- this.callbacks.onUsageAdded(compactUsage);
650
- }
651
-
652
- this.consecutiveCompactionFailures = 0;
653
-
654
- // Reset incremental tracing state after compaction
655
- resetTracingState();
656
-
657
- // 10. Log OTEL event
658
- logOTelEvent("compaction", {
659
- beforeTokens: String(messagesToCompact.length),
660
- afterTokens: "1",
661
- model: compactModel,
662
- }).catch(() => {});
663
-
664
- // 11. Run SessionStart hooks (existing behavior)
665
- if (this.hookManager) {
666
- try {
667
- const newSessionId = this.messageManager.getSessionId();
668
- const sessionStartResult =
669
- await this.hookManager.executeSessionStartHooks(
670
- "compact",
671
- newSessionId,
672
- this.messageManager.getTranscriptPath(),
673
- this.subagentType,
674
- );
675
- if (sessionStartResult.additionalContext) {
676
- this.messageManager.addUserMessage({
677
- content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
678
- isMeta: true,
679
- });
680
- }
681
- if (sessionStartResult.initialUserMessage) {
682
- this.messageManager.addUserMessage({
683
- content: sessionStartResult.initialUserMessage,
684
- isMeta: true,
685
- });
686
- }
687
- } catch (error) {
688
- logger?.warn(
689
- `SessionStart hooks on compact failed: ${(error as Error).message}`,
690
- );
691
- }
692
- }
693
-
694
- // 12. Run PostCompact hooks
695
- if (this.hookManager) {
696
- try {
697
- await this.hookManager.executePostCompactHooks(
698
- this.messageManager.getSessionId(),
699
- this.messageManager.getTranscriptPath(),
700
- formattedSummary,
701
- );
702
- } catch (error) {
703
- logger?.warn(`PostCompact hooks failed: ${(error as Error).message}`);
704
- }
705
- }
706
-
707
- logger?.debug(
708
- `Successfully compacted ${messagesToCompact.length} messages`,
709
- );
710
- } catch (compactError) {
711
- this.consecutiveCompactionFailures++;
712
- logger?.error(
713
- `Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`,
714
- );
715
- this.messageManager.addErrorBlock(
716
- `Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
717
- );
718
- } finally {
719
- this.setIsCompacting(false);
720
- }
721
- }
722
-
723
- /**
724
- * Build the system prompt used by the main agent loop. Extracted so the
725
- * compaction fork can mirror it exactly — the forked request prefix must
726
- * match the main conversation's for the prompt cache to be reused.
727
- */
728
- private async buildMainSystemPrompt(
729
- filteredToolPlugins: ReturnType<ToolManager["getTools"]>,
730
- ) {
731
- let autoMemoryOptions: { directory: string; content: string } | undefined;
732
-
733
- if (this.getAutoMemoryEnabled()) {
734
- const directory = this.memoryService.getAutoMemoryDirectory(
735
- this.getWorkdir(),
736
- );
737
- const content = await this.memoryService.getAutoMemoryContent(
738
- this.getWorkdir(),
739
- );
740
- autoMemoryOptions = { directory, content };
741
- }
742
-
743
- return buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
744
- workdir: this.getWorkdir(),
745
- originalWorkdir: this.getOriginalWorkdir(),
746
- language: this.getLanguage(),
747
- isSubagent: !!this.subagentType,
748
- worktreeSession: this.getWorktreeSession(),
749
- additionalWorkingDirectories:
750
- this.permissionManager?.getEffectiveAdditionalDirectories?.() ?? [],
751
- autoMemory: autoMemoryOptions,
752
- });
753
- }
754
-
755
- private resolveFilteredTools() {
756
- const toolsConfig = this.getFilteredToolsConfig();
757
- const toolNames = new Set(toolsConfig.map((t) => t.function.name));
758
- const filteredToolPlugins = this.toolManager
759
- .getTools()
760
- .filter((t) => toolNames.has(t.name));
761
- return { toolsConfig, toolNames, filteredToolPlugins };
762
- }
763
-
764
- /**
765
- * Fork-path loop: run a bounded agent loop over a copy of the conversation
766
- * using the same system prompt, tools, model, and generation params as the
767
- * main loop, so the forked request prefix matches exactly and the prompt
768
- * cache is reused. A `canUseTool` gate decides whether each tool call
769
- * executes locally (with a stripped context) or is denied and fed back to
770
- * the model for another turn. Returns undefined content when the model never
771
- * produces text; the caller treats that as a failure.
772
- */
773
- private async runForkLoop(
774
- historyMessages: ChatCompletionMessageParam[],
775
- prompt: string,
776
- options: {
777
- maxTurns: number;
778
- /** Gate deciding which tool calls execute locally. When undefined, every tool call is denied. */
779
- canUseTool?: (name: string, args: Record<string, unknown>) => boolean;
780
- /** Message fed back to the model when a tool call is denied. */
781
- deniedToolMessage?: string;
782
- },
783
- abortSignal?: AbortSignal,
784
- ): Promise<ForkLoopResult> {
785
- const modelConfig = this.getModelConfig();
786
- const gatewayConfig = this.getGatewayConfig();
787
- const sessionId = this.messageManager.getSessionId();
788
- const workdir = this.getWorkdir();
789
-
790
- const forkMessages: ChatCompletionMessageParam[] = [...historyMessages];
791
-
792
- // Mirror the main loop's memory injection so the request prefix matches.
793
- const { prependContent } =
794
- await this.messageManager.getMemoryForInjection();
795
- if (prependContent.trim()) {
796
- forkMessages.unshift({
797
- role: "user",
798
- content: wrapInSystemReminder(prependContent),
799
- });
800
- }
801
-
802
- forkMessages.push({ role: "user", content: prompt });
803
-
804
- const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
805
- const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
806
-
807
- // Fresh read-state map so Read/Edit state built up inside the fork never
808
- // leaks into the main session's dedup and staleness tracking.
809
- const forkReadFileState = new Map<
810
- string,
811
- { mtime: number; hash: string; offset?: number; limit?: number }
812
- >();
813
-
814
- let totalUsage: ForkLoopResult["usage"];
815
- let content: string | undefined;
816
-
817
- for (let turn = 0; turn < options.maxTurns; turn++) {
818
- const result = await aiService.callAgent({
819
- gatewayConfig,
820
- modelConfig,
821
- messages: forkMessages,
822
- sessionId,
823
- abortSignal,
824
- workdir,
825
- tools: toolsConfig,
826
- systemPrompt,
827
- toolChoice: this.toolChoiceOverride,
828
- // Stream so a slow reasoning model emits first bytes before the
829
- // gateway's idle timeout fires (non-streaming waits for the full
830
- // response, which exceeds the timeout on large contexts).
831
- stream: true,
832
- });
833
-
834
- if (result.usage) {
835
- totalUsage = {
836
- prompt_tokens:
837
- (totalUsage?.prompt_tokens ?? 0) + result.usage.prompt_tokens,
838
- completion_tokens:
839
- (totalUsage?.completion_tokens ?? 0) +
840
- result.usage.completion_tokens,
841
- total_tokens:
842
- (totalUsage?.total_tokens ?? 0) + result.usage.total_tokens,
843
- };
844
- }
845
-
846
- if (result.content?.trim()) {
847
- content = result.content;
848
- break;
849
- }
850
-
851
- if (result.tool_calls && result.tool_calls.length > 0) {
852
- const functionCalls = result.tool_calls.filter(
853
- (tc) => tc.type === "function",
854
- );
855
- if (functionCalls.length === 0) break;
856
-
857
- forkMessages.push({
858
- role: "assistant",
859
- content: result.content ?? null,
860
- tool_calls: functionCalls,
861
- });
862
-
863
- for (const toolCall of functionCalls) {
864
- const name = toolCall.function?.name || "";
865
- const args = this.parseForkToolArgs(toolCall.function?.arguments);
866
- let toolContent: string;
867
- if (options.canUseTool && options.canUseTool(name, args)) {
868
- toolContent = await this.executeForkTool(
869
- name,
870
- args,
871
- workdir,
872
- sessionId,
873
- abortSignal,
874
- forkReadFileState,
875
- );
876
- } else {
877
- toolContent =
878
- options.deniedToolMessage ??
879
- "Tool use is not allowed in this context";
880
- }
881
- forkMessages.push({
882
- role: "tool",
883
- tool_call_id: toolCall.id,
884
- content: toolContent,
885
- });
886
- }
887
- continue;
888
- }
889
-
890
- // Neither text nor tool calls: retrying the identical request is
891
- // pointless, bail out and let the caller fail.
892
- break;
893
- }
894
-
895
- return { content, usage: totalUsage };
896
- }
897
-
898
- /**
899
- * Fork-path compaction: deny all tool calls locally (the model is told to
900
- * summarize, not act) and feed the rejections back for another turn.
901
- */
902
- private async runCompactFork(
903
- historyMessages: ChatCompletionMessageParam[],
904
- compactPrompt: string,
905
- abortSignal?: AbortSignal,
906
- ): Promise<ForkLoopResult> {
907
- return this.runForkLoop(
908
- historyMessages,
909
- compactPrompt,
910
- {
911
- maxTurns: MAX_FORK_TURNS,
912
- deniedToolMessage: "Tool use is not allowed during compaction",
913
- },
914
- abortSignal,
915
- );
916
- }
917
-
918
- /**
919
- * Auto-memory extraction via the perfect fork: the extraction prompt is run
920
- * against the same request prefix as the main conversation (same system
921
- * prompt, tools, model, and message history) so the prompt cache is reused.
922
- * Gate-approved tools execute locally in a stripped context; everything else
923
- * is denied. Usage is reported with operation_type "agent" so extraction
924
- * token costs stay visible in session accounting.
925
- */
926
- public async runAutoMemoryFork(
927
- messages: Message[],
928
- prompt: string,
929
- options: {
930
- canUseTool: (name: string, args: Record<string, unknown>) => boolean;
931
- deniedToolMessage?: string;
932
- maxTurns?: number;
933
- },
934
- abortSignal?: AbortSignal,
935
- ): Promise<ForkLoopResult> {
936
- const modelConfig = this.getModelConfig();
937
- const historyMessages = convertMessagesForAPI(messages, {
938
- supportsVision: supportsVision(modelConfig.capabilities),
939
- });
940
- // Give the fork a real signal even when the caller has none, so tools
941
- // (e.g. Bash's foreground path) always receive a well-formed context.
942
- const signal = abortSignal ?? new AbortController().signal;
943
- const result = await this.runForkLoop(
944
- historyMessages,
945
- prompt,
946
- {
947
- maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
948
- canUseTool: options.canUseTool,
949
- deniedToolMessage: options.deniedToolMessage,
950
- },
951
- signal,
952
- );
953
-
954
- if (result.usage && this.callbacks?.onUsageAdded) {
955
- this.callbacks.onUsageAdded({
956
- ...result.usage,
957
- model: modelConfig.model,
958
- operation_type: "agent",
959
- });
960
- }
961
- return result;
962
- }
963
-
964
- /**
965
- * Parse a fork tool call's JSON arguments, recovering truncated JSON the
966
- * same way the main loop does. Unparseable arguments fall back to `{}` and
967
- * are rejected by the gate or the tool's own parameter validation.
968
- */
969
- private parseForkToolArgs(
970
- argsString: string | undefined,
971
- ): Record<string, unknown> {
972
- if (!argsString?.trim()) return {};
973
- try {
974
- return JSON.parse(argsString) as Record<string, unknown>;
975
- } catch {
976
- try {
977
- return JSON.parse(recoverTruncatedJson(argsString)) as Record<
978
- string,
979
- unknown
980
- >;
981
- } catch {
982
- return {};
983
- }
984
- }
985
- }
986
-
987
- /**
988
- * Execute a single tool call inside a fork with a stripped context: no
989
- * permission manager (never prompts the user), no message manager (no
990
- * conditional-rule triggering), no messageId (no file-history snapshots),
991
- * and no background task manager (commands run in the foreground). Only
992
- * gate-approved tool names reach this path.
993
- */
994
- private async executeForkTool(
995
- name: string,
996
- args: Record<string, unknown>,
997
- workdir: string,
998
- sessionId: string | undefined,
999
- abortSignal: AbortSignal | undefined,
1000
- readFileState: ToolContext["readFileState"],
1001
- ): Promise<string> {
1002
- const plugin = this.toolManager.getTools().find((t) => t.name === name);
1003
- if (!plugin) {
1004
- return `Tool '${name}' not found`;
1005
- }
1006
- const context: ToolContext = {
1007
- abortSignal,
1008
- workdir,
1009
- originalWorkdir: this.originalWorkdir,
1010
- sessionId,
1011
- taskManager: this.taskManager,
1012
- readFileState,
1013
- onShortResultUpdate: () => {},
1014
- onResultUpdate: () => {},
1015
- onCwdChange: () => {},
1016
- };
1017
- try {
1018
- const result = await plugin.execute(args, context);
1019
- if (result.content) return result.content;
1020
- if (result.error) return `Error: ${result.error}`;
1021
- return "";
1022
- } catch (error) {
1023
- const message = error instanceof Error ? error.message : String(error);
1024
- logger?.error(`Fork tool execution failed for ${name}:`, error);
1025
- return `Tool execution failed: ${message}`;
1026
- }
1027
- }
1028
-
1029
- /**
1030
- * Fork-path side question ("/btw"): run a single-turn fork of the
1031
- * conversation using the same system prompt, tools, model, and generation
1032
- * params as the main loop, so the forked request prefix matches exactly and
1033
- * the prompt cache is reused. The in-progress assistant message (if any) is
1034
- * stripped so the fork starts from the last completed request prefix. Tools
1035
- * are never executed — the wrapped question instructs the model to answer
1036
- * directly; an attempted tool call is surfaced as an error string.
1037
- */
1038
- async runBtwFork(
1039
- question: string,
1040
- abortSignal?: AbortSignal,
1041
- onContent?: (content: string) => void,
1042
- onReasoning?: (content: string) => void,
1043
- ): Promise<{ content?: string; error?: string }> {
1044
- const modelConfig = this.getModelConfig();
1045
- const gatewayConfig = this.getGatewayConfig();
1046
- const sessionId = this.messageManager.getSessionId();
1047
- const workdir = this.getWorkdir();
1048
-
1049
- const rawMessages = this.messageManager.getMessages();
1050
-
1051
- // Strip the in-progress assistant message (a block still in "streaming"
1052
- // stage) so the fork's request prefix matches the last completed
1053
- // main-loop request and the prompt cache is reused.
1054
- const lastMessage = rawMessages[rawMessages.length - 1];
1055
- const hasInProgressMessage =
1056
- lastMessage?.role === "assistant" &&
1057
- lastMessage.blocks.some(
1058
- (b) => "stage" in b && (b as { stage?: string }).stage === "streaming",
1059
- );
1060
-
1061
- const forkMessages: ChatCompletionMessageParam[] = convertMessagesForAPI(
1062
- hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages,
1063
- { supportsVision: supportsVision(modelConfig.capabilities) },
1064
- );
1065
-
1066
- // Mirror the main loop's memory injection so the request prefix matches.
1067
- const { prependContent } =
1068
- await this.messageManager.getMemoryForInjection();
1069
- if (prependContent.trim()) {
1070
- forkMessages.unshift({
1071
- role: "user",
1072
- content: wrapInSystemReminder(prependContent),
1073
- });
1074
- }
1075
-
1076
- // Wrap the question with the side-question instructions (verbatim
1077
- // Claude Code sideQuestion.ts) so the model answers directly.
1078
- const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
1079
-
1080
- IMPORTANT CONTEXT:
1081
- - You are a separate, lightweight agent spawned to answer this one question
1082
- - The main agent is NOT interrupted - it continues working independently in the background
1083
- - You share the conversation context but are a completely separate instance
1084
- - Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
1085
-
1086
- CRITICAL CONSTRAINTS:
1087
- - You have NO tools available - you cannot read files, run commands, search, or take any actions
1088
- - This is a one-off response - there will be no follow-up turns
1089
- - You can ONLY provide information based on what you already know from the conversation context
1090
- - NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
1091
- - If you don't know the answer, say so - do not offer to look it up or investigate
1092
-
1093
- Simply answer the question with the information you have.</system-reminder>
1094
-
1095
- ${question}`;
1096
- forkMessages.push({ role: "user", content: wrappedQuestion });
1097
-
1098
- const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
1099
- const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
1100
-
1101
- try {
1102
- // Surface partial output to the caller (e.g. the /btw overlay's
1103
- // streaming display) as it arrives. Reasoning chunks from thinking
1104
- // models stream through a separate channel when the caller supplies
1105
- // one (webview hosts distinguish thinking from content so the panel
1106
- // can drop thinking text once content starts); otherwise they fall
1107
- // back to the content channel (CLI overlay mixes both).
1108
- const streamToOverlay = (text: string) => {
1109
- if (text.trim()) {
1110
- onContent?.(text);
1111
- }
1112
- };
1113
- const streamReasoning = (text: string) => {
1114
- if (text.trim()) {
1115
- if (onReasoning) {
1116
- onReasoning(text);
1117
- } else {
1118
- onContent?.(text);
1119
- }
1120
- }
1121
- };
1122
- const result = await aiService.callAgent({
1123
- gatewayConfig,
1124
- modelConfig,
1125
- messages: forkMessages,
1126
- sessionId,
1127
- abortSignal,
1128
- workdir,
1129
- tools: toolsConfig,
1130
- systemPrompt,
1131
- toolChoice: this.toolChoiceOverride,
1132
- // Stream so a slow reasoning model emits first bytes before the
1133
- // gateway's idle timeout fires (same rationale as runCompactFork).
1134
- stream: true,
1135
- onContentUpdate: streamToOverlay,
1136
- onReasoningUpdate: streamReasoning,
1137
- });
1138
-
1139
- if (result.content?.trim()) {
1140
- return { content: result.content };
1141
- }
1142
-
1143
- // A thinking model may emit only reasoning content (e.g. the stream
1144
- // is truncated before the final answer); surface that instead of
1145
- // falling through to "No response received".
1146
- if (result.reasoning_content?.trim()) {
1147
- return { content: result.reasoning_content };
1148
- }
1149
-
1150
- if (result.tool_calls && result.tool_calls.length > 0) {
1151
- const firstFunctionCall = result.tool_calls.find(
1152
- (call) => call.type === "function",
1153
- );
1154
- const toolName = firstFunctionCall?.function?.name ?? "a tool";
1155
- return {
1156
- error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
1157
- };
1158
- }
1159
-
1160
- // Neither text nor tool calls.
1161
- return { error: "No response received" };
1162
- } catch (error) {
1163
- // aiService.callAgent converts AbortError into a plain Error, so the
1164
- // abort is detected via the signal itself; rethrow so the UI can
1165
- // silently dismiss instead of showing an error.
1166
- if (abortSignal?.aborted) {
1167
- throw error;
1168
- }
1169
- return {
1170
- error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
1171
- };
1172
- }
1173
- }
1174
-
1175
- /**
1176
- * Build post-compact context restoration content.
1177
- * Restores file reads, working directory, plan mode, skills, and background tasks.
1178
- */
1179
- private async buildPostCompactContext(summary: string): Promise<string> {
1180
- const POST_COMPACT_TOKEN_BUDGET = 50_000;
1181
- const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000;
1182
- const POST_COMPACT_MAX_FILES_TO_RESTORE = 5;
1183
- const contextParts: string[] = [];
1184
-
1185
- // 1. File context restoration
1186
- const recentFiles = this.messageManager.getRecentFileReads(
1187
- POST_COMPACT_MAX_FILES_TO_RESTORE,
1188
- POST_COMPACT_MAX_TOKENS_PER_FILE,
1189
- );
1190
- let usedTokens = 0;
1191
- for (const file of recentFiles) {
1192
- const fileTokens = estimateTokens(file.content);
1193
- if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE) continue;
1194
- if (fileTokens > 0) usedTokens += fileTokens;
1195
- contextParts.push(`\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``);
1196
- if (contextParts.length >= POST_COMPACT_MAX_FILES_TO_RESTORE) break;
1197
- if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break;
1198
- }
1199
-
1200
- // 4. Invoked skills context (with token budget, matching Claude Code)
1201
- const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000;
1202
- const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000;
1203
- const invokedSkillNames = this.messageManager.getInvokedSkillNames(10);
1204
- if (invokedSkillNames.length > 0 && this.skillManager) {
1205
- const invokedSkillParts: string[] = [];
1206
- let skillsUsedTokens = 0;
1207
- for (const skillName of invokedSkillNames) {
1208
- try {
1209
- const skill = await this.skillManager.loadSkill(skillName);
1210
- if (!skill) continue;
1211
-
1212
- const contentMatch = skill.content.match(
1213
- /^---\n[\s\S]*?\n---\n([\s\S]*)$/,
1214
- );
1215
- let skillContent = contentMatch
1216
- ? contentMatch[1].trim()
1217
- : skill.content;
1218
-
1219
- const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4;
1220
- if (skillContent.length > maxSkillChars) {
1221
- skillContent =
1222
- skillContent.slice(0, maxSkillChars) + "\n\n...[truncated]...";
1223
- }
1224
-
1225
- const skillTokens = estimateTokens(skillContent);
1226
- if (skillsUsedTokens + skillTokens > POST_COMPACT_SKILLS_TOKEN_BUDGET)
1227
- break;
1228
- skillsUsedTokens += skillTokens;
1229
-
1230
- invokedSkillParts.push(
1231
- `\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``,
1232
- );
1233
- } catch {
1234
- // Skip skills that can't be loaded
1235
- }
1236
- }
1237
- if (invokedSkillParts.length > 0) {
1238
- contextParts.push(
1239
- `\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`,
1240
- );
1241
- }
1242
- }
1243
-
1244
- // 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded)
1245
- const agents =
1246
- this.backgroundTaskManager
1247
- ?.getAllTasks()
1248
- .filter((a) => a.type === "subagent") || [];
1249
- if (agents.length > 0) {
1250
- const agentParts: string[] = [];
1251
- for (const a of agents) {
1252
- if (a.status === "killed") {
1253
- agentParts.push(
1254
- `Task "${a.description}" (${a.id}) was stopped by the user.`,
1255
- );
1256
- } else if (a.status === "running") {
1257
- const parts = [
1258
- `Background agent "${a.description}" (${a.id}) is still running.`,
1259
- `Do NOT spawn a duplicate. You will be notified when it completes.`,
1260
- ];
1261
- if (a.outputPath) {
1262
- parts.push(`You can read partial output at ${a.outputPath}.`);
1263
- }
1264
- agentParts.push(parts.join(" "));
1265
- } else {
1266
- // completed or failed
1267
- const parts = [
1268
- `Task ${a.id} (status: ${a.status}) (description: ${a.description}).`,
1269
- ];
1270
- const deltaText = a.status === "failed" ? a.stderr : a.stdout;
1271
- if (deltaText && deltaText.length > 0) {
1272
- const summary =
1273
- deltaText.length > 500
1274
- ? deltaText.slice(0, 500) + "..."
1275
- : deltaText;
1276
- parts.push(`Delta: ${summary}`);
1277
- }
1278
- if (a.outputPath) {
1279
- parts.push(
1280
- `Read the output file to retrieve the result: ${a.outputPath}.`,
1281
- );
1282
- }
1283
- agentParts.push(parts.join(" "));
1284
- }
1285
- }
1286
- if (agentParts.length > 0) {
1287
- contextParts.push(`\n\n[Background Tasks]\n${agentParts.join("\n")}`);
1288
- }
1289
- }
1290
-
1291
- return (
1292
- summary +
1293
- (contextParts.length > 0
1294
- ? `\n\n[Context Restoration]` + contextParts.join("")
1295
- : "")
1296
- );
1297
- }
1298
-
1299
- public getIsCompacting(): boolean {
1300
- return this.isCompacting;
1301
- }
1302
-
1303
- public setIsCompacting(isCompacting: boolean): void {
1304
- if (this.isCompacting !== isCompacting) {
1305
- this.isCompacting = isCompacting;
1306
- this.callbacks.onCompactionStateChange?.(isCompacting);
1307
- }
1308
- }
1309
-
1310
- private get subagentManager(): SubagentManager | undefined {
1311
- return this.container.get<SubagentManager>("SubagentManager");
1312
- }
1313
-
1314
- private get cronManager(): CronManager | undefined {
1315
- return this.container.get<CronManager>("CronManager");
1316
- }
1317
-
1318
- private get skillManager(): SkillManager | undefined {
1319
- return this.container.get<SkillManager>("SkillManager");
1320
- }
1321
-
1322
- public async sendAIMessage(
1323
- options: {
1324
- recursionDepth?: number;
1325
- model?: string;
1326
- /** Rules for automatic tool approval (e.g., "Bash(git status*)") */
1327
- allowedRules?: string[];
1328
- maxTokens?: number;
1329
- } = {},
1330
- ): Promise<void> {
1331
- const { recursionDepth = 0, model, allowedRules, maxTokens } = options;
1332
- let turnOffset = recursionDepth;
1333
-
1334
- // Reserve this turn synchronously before any async work. Bumping the
1335
- // generation invalidates any in-flight turn's end-of-turn cleanup, and
1336
- // recording myGeneration lets us skip cleanup if a newer turn (or abort)
1337
- // has superseded us. setIsLoading(true) here also closes the "idle but
1338
- // previous turn not finished" race window by marking us busy immediately.
1339
- this.turnGeneration++;
1340
- let myGeneration = this.turnGeneration;
1341
- this.setIsLoading(true);
1342
-
1343
- outer: while (true) {
1344
- let shouldRestart = false;
1345
-
1346
- // OpenTelemetry: start interaction span for this turn (initial call only)
1347
- let turnSequence = 0;
1348
- if (turnOffset === 0) {
1349
- const messages = this.messageManager.getMessages();
1350
- turnSequence = messages.filter((m) => m.role === "user").length;
1351
- const lastUserMessage = [...messages]
1352
- .reverse()
1353
- .find((m) => m.role === "user");
1354
- const userPromptText =
1355
- lastUserMessage?.blocks.find((b) => b.type === "text")?.content || "";
1356
- startInteractionSpan(userPromptText, turnSequence);
1357
-
1358
- // Log user_prompt event
1359
- logOTelEvent("user_prompt", {
1360
- prompt_length: String(userPromptText.length),
1361
- }).catch(() => {}); // Non-blocking
1362
- }
1363
-
1364
- // Apply allowed rules for the initial call
1365
- if (turnOffset === 0) {
1366
- if (allowedRules && allowedRules.length > 0) {
1367
- this.permissionManager?.addTemporaryRules(allowedRules);
1368
- }
1369
- }
1370
-
1371
- // Scan for file mentions in the last user message to trigger conditional rules
1372
- if (turnOffset === 0) {
1373
- const messages = this.messageManager.getMessages();
1374
- const lastMessage = messages[messages.length - 1];
1375
- if (lastMessage && lastMessage.role === "user") {
1376
- for (const block of lastMessage.blocks) {
1377
- if (block.type === "text") {
1378
- const content = block.content;
1379
- const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
1380
- let match;
1381
- while ((match = fileMentionRegex.exec(content)) !== null) {
1382
- const filePath = match[1];
1383
- this.messageManager.triggerFileRead(filePath);
1384
- }
1385
- }
1386
- }
1387
- }
1388
- }
1389
-
1390
- // Only create new AbortControllers for the initial call (turnOffset === 0)
1391
- // For restarts, reuse existing controllers to maintain abort signal
1392
- let abortController: AbortController;
1393
- let toolAbortController: AbortController;
1394
-
1395
- if (turnOffset === 0) {
1396
- // Create new AbortControllers for initial call
1397
- abortController = new AbortController();
1398
- this.abortController = abortController;
1399
-
1400
- toolAbortController = new AbortController();
1401
- this.toolAbortController = toolAbortController;
1402
- } else {
1403
- // Reuse existing controllers
1404
- abortController = this.abortController!;
1405
- toolAbortController = this.toolAbortController!;
1406
- }
1407
-
1408
- let turnDepth = turnOffset;
1409
-
1410
- inner: while (true) {
1411
- let llmSpan: import("@opentelemetry/api").Span | undefined;
1412
- try {
1413
- // Save session in each iteration to ensure message persistence
1414
- await this.messageManager.saveSession();
1415
-
1416
- // Get current permission mode
1417
- const currentMode = this.permissionManager?.getCurrentEffectiveMode(
1418
- this.getModelConfig().permissionMode,
1419
- );
1420
-
1421
- // Add plan mode reminder as persistent meta message before getting messages
1422
- this.maybeAddPlanModeMessage(currentMode);
1423
-
1424
- // Process conditional rules triggered by file reads (persist as meta messages)
1425
- const triggeredRules = this.messageManager.processTriggeredRules();
1426
- for (const rule of triggeredRules) {
1427
- this.messageManager.addUserMessage({
1428
- content: `<!-- rule: ${rule.id} -->\n<system-reminder>\nThe following rules from .wave/rules apply to your work. Be sure to adhere to these instructions.\n\n${rule.content}\n</system-reminder>`,
1429
- isMeta: true,
1430
- });
1431
- }
1432
-
1433
- // Get recent message history
1434
- const rawMessages = this.messageManager.getMessages();
1435
- const currentModelConfig = this.getModelConfig();
1436
- const recentMessages = convertMessagesForAPI(rawMessages, {
1437
- supportsVision: supportsVision(currentModelConfig.capabilities),
1438
- });
1439
-
1440
- // Track if assistant message has been created
1441
- let assistantMessageCreated = false;
1442
-
1443
- logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
1444
-
1445
- const { toolsConfig, toolNames, filteredToolPlugins } =
1446
- this.resolveFilteredTools();
1447
-
1448
- // Get memory for message-array injection (not system prompt)
1449
- const { prependContent } =
1450
- await this.messageManager.getMemoryForInjection();
1451
-
1452
- const mainSystemPrompt =
1453
- await this.buildMainSystemPrompt(filteredToolPlugins);
1454
-
1455
- // Call AI service with streaming callbacks if enabled
1456
- const callAgentOptions: CallAgentOptions = {
1457
- gatewayConfig: this.getGatewayConfig(),
1458
- modelConfig: this.getModelConfig(),
1459
- messages: recentMessages,
1460
- sessionId: this.messageManager.getSessionId(),
1461
- abortSignal: abortController.signal,
1462
- workdir: this.getWorkdir(), // Pass working directory
1463
- tools: toolsConfig, // Pass filtered tool configuration
1464
- model: model, // Use passed model
1465
- systemPrompt: mainSystemPrompt, // Pass custom system prompt
1466
- maxTokens: maxTokens, // Pass max tokens override
1467
- toolChoice: this.toolChoiceOverride, // Pass tool_choice override
1468
- // Fast-model subagents send disable-thinking params only when
1469
- // explicitly configured (never in the agent loop).
1470
- disableThinkingOptions:
1471
- this.modelOverride === "fastModel"
1472
- ? this.getModelConfig().disableThinkingOptions
1473
- : undefined,
1474
- };
1475
-
1476
- // Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
1477
- if (prependContent.trim()) {
1478
- callAgentOptions.messages.unshift({
1479
- role: "user",
1480
- content: wrapInSystemReminder(prependContent),
1481
- });
1482
- }
1483
-
1484
- // Task reminder: persist as meta message (conditional rules already persisted above)
1485
- const taskReminderText =
1486
- await this.maybeGetTaskReminderText(toolNames);
1487
- if (taskReminderText) {
1488
- this.messageManager.addUserMessage({
1489
- content: taskReminderText,
1490
- isMeta: true,
1491
- });
1492
- }
1493
-
1494
- // Add streaming callbacks only if streaming is enabled
1495
- if (this.stream) {
1496
- callAgentOptions.onContentUpdate = (content: string) => {
1497
- // Create assistant message on first chunk if not already created
1498
- if (!assistantMessageCreated) {
1499
- this.messageManager.addAssistantMessage();
1500
- assistantMessageCreated = true;
1501
- }
1502
- this.messageManager.updateCurrentMessageContent(content);
1503
- };
1504
- callAgentOptions.onToolUpdate = (toolCall) => {
1505
- // Create assistant message on first tool update if not already created
1506
- if (!assistantMessageCreated) {
1507
- this.messageManager.addAssistantMessage();
1508
- assistantMessageCreated = true;
1509
- }
1510
-
1511
- // Use parametersChunk as compact param for better performance
1512
- // No need to extract params or generate compact params during streaming
1513
-
1514
- // Update tool block with streaming parameters using parametersChunk as compact param
1515
- this.messageManager.updateToolBlock({
1516
- id: toolCall.id,
1517
- name: toolCall.name,
1518
- parameters: toolCall.parameters,
1519
- parametersChunk: toolCall.parametersChunk,
1520
- stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
1521
- });
1522
- };
1523
- callAgentOptions.onReasoningUpdate = (reasoning: string) => {
1524
- // Create assistant message on first reasoning update if not already created
1525
- if (!assistantMessageCreated) {
1526
- this.messageManager.addAssistantMessage();
1527
- assistantMessageCreated = true;
1528
- }
1529
- this.messageManager.updateCurrentMessageReasoning(reasoning);
1530
- };
1531
- }
1532
-
1533
- llmSpan = startLLMRequestSpan(
1534
- model || this.getModelConfig().model || "",
1535
- {
1536
- context: "interaction",
1537
- systemPrompt:
1538
- typeof callAgentOptions.systemPrompt === "string"
1539
- ? callAgentOptions.systemPrompt
1540
- : undefined,
1541
- inputMessages: callAgentOptions.messages,
1542
- toolsSchema: callAgentOptions.tools
1543
- ? JSON.stringify(callAgentOptions.tools)
1544
- : undefined,
1545
- },
1546
- );
1547
-
1548
- const result = await aiService.callAgent(callAgentOptions);
1549
-
1550
- // End LLM span with usage data
1551
- endLLMRequestSpan(llmSpan, {
1552
- model: model || this.getModelConfig().model || "",
1553
- success: true,
1554
- hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
1555
- inputTokens: result.usage?.prompt_tokens,
1556
- outputTokens: result.usage?.completion_tokens,
1557
- cacheReadTokens: result.usage?.cache_read_input_tokens,
1558
- cacheCreationTokens: result.usage?.cache_creation_input_tokens,
1559
- modelOutput: result.content,
1560
- });
1561
-
1562
- const createdByStreaming = assistantMessageCreated;
1563
-
1564
- // For non-streaming mode, create assistant message after callAgent returns
1565
- // Also create if streaming mode but no streaming callbacks were called (e.g., when content comes directly in result)
1566
- if (
1567
- !this.stream ||
1568
- (!assistantMessageCreated &&
1569
- (result.content || result.tool_calls || result.reasoning_content))
1570
- ) {
1571
- this.messageManager.addAssistantMessage();
1572
- assistantMessageCreated = true;
1573
- }
1574
-
1575
- // Log finish reason and response headers if available
1576
- if (result.finish_reason) {
1577
- // Log warning headers when finish reason is length
1578
- if (result.finish_reason === "length") {
1579
- logger?.warn(
1580
- "AI response truncated due to length limit. Response headers:",
1581
- result.response_headers,
1582
- );
1583
- }
1584
- }
1585
-
1586
- if (
1587
- result.additionalFields &&
1588
- Object.keys(result.additionalFields).length > 0
1589
- ) {
1590
- this.messageManager.mergeAssistantAdditionalFields(
1591
- result.additionalFields,
1592
- );
1593
- }
1594
-
1595
- // Handle result reasoning content from non-streaming mode
1596
- if (result.reasoning_content && !createdByStreaming) {
1597
- this.messageManager.updateCurrentMessageReasoning(
1598
- result.reasoning_content,
1599
- );
1600
- }
1601
-
1602
- // Handle result content from non-streaming mode
1603
- if (result.content && !createdByStreaming) {
1604
- this.messageManager.updateCurrentMessageContent(result.content);
1605
- }
1606
-
1607
- // Handle usage tracking for agent operations
1608
- let usage: Usage | undefined;
1609
- if (result.usage) {
1610
- usage = {
1611
- prompt_tokens: result.usage.prompt_tokens,
1612
- completion_tokens: result.usage.completion_tokens,
1613
- total_tokens: result.usage.total_tokens,
1614
- model: model || this.getModelConfig().model,
1615
- operation_type: "agent",
1616
- // Preserve cache fields if present
1617
- ...(result.usage.cache_read_input_tokens !== undefined && {
1618
- cache_read_input_tokens: result.usage.cache_read_input_tokens,
1619
- }),
1620
- ...(result.usage.cache_creation_input_tokens !== undefined && {
1621
- cache_creation_input_tokens:
1622
- result.usage.cache_creation_input_tokens,
1623
- }),
1624
- ...(result.usage.cache_creation && {
1625
- cache_creation: result.usage.cache_creation,
1626
- }),
1627
- };
1628
- }
1629
-
1630
- // Set usage on the assistant message if available
1631
- if (usage) {
1632
- const messages = this.messageManager.getMessages();
1633
- const lastMessage = messages[messages.length - 1];
1634
- if (lastMessage && lastMessage.role === "assistant") {
1635
- lastMessage.usage = usage;
1636
- this.messageManager.setMessages(messages);
1637
- }
1638
-
1639
- // Notify Agent to add to usage tracking
1640
- if (this.callbacks?.onUsageAdded) {
1641
- this.callbacks.onUsageAdded(usage);
1642
- }
1643
- }
1644
-
1645
- // Collect tool calls for processing
1646
- const toolCalls: ChatCompletionMessageFunctionToolCall[] = [];
1647
- if (result.tool_calls) {
1648
- for (const toolCall of result.tool_calls) {
1649
- if (toolCall.type === "function") {
1650
- toolCalls.push(toolCall);
1651
- }
1652
- }
1653
- }
1654
-
1655
- if (toolCalls.length > 0) {
1656
- // Partition tool calls into batches: consecutive concurrency-safe
1657
- // tools run in parallel, non-safe tools (Edit, Write, MCP) run one
1658
- // at a time to prevent read-modify-write races on the same file.
1659
- const batches: {
1660
- calls: ChatCompletionMessageFunctionToolCall[];
1661
- safe: boolean;
1662
- }[] = [];
1663
- for (const call of toolCalls) {
1664
- const toolName = call.function?.name || "";
1665
- const safe = this.toolManager.isConcurrencySafe(toolName);
1666
- const lastBatch = batches[batches.length - 1];
1667
- if (lastBatch && lastBatch.safe && safe) {
1668
- lastBatch.calls.push(call);
1669
- } else {
1670
- batches.push({ calls: [call], safe });
1671
- }
1672
- }
1673
-
1674
- // Execute batches sequentially; within a safe batch, tools run in parallel
1675
- for (const batch of batches) {
1676
- if (
1677
- abortController.signal.aborted ||
1678
- toolAbortController.signal.aborted
1679
- ) {
1680
- break;
1681
- }
1682
- if (batch.calls.length === 1) {
1683
- await this.executeToolCall(
1684
- batch.calls[0],
1685
- abortController,
1686
- toolAbortController,
1687
- result.finish_reason,
1688
- );
1689
- } else {
1690
- await Promise.all(
1691
- batch.calls.map((call) =>
1692
- this.executeToolCall(
1693
- call,
1694
- abortController,
1695
- toolAbortController,
1696
- result.finish_reason,
1697
- ),
1698
- ),
1699
- );
1700
- }
1701
- }
1702
- }
1703
-
1704
- // Handle token statistics and message compaction
1705
- await this.handleTokenUsageAndCompaction(
1706
- result.usage,
1707
- abortController,
1708
- );
1709
-
1710
- // Finalize text/reasoning blocks for the final response (no tools)
1711
- this.messageManager.finalizeStreamingBlocks();
1712
-
1713
- // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
1714
- if (toolCalls.length > 0 || result.finish_reason === "length") {
1715
- // Check maxTurns limit before continuing
1716
- if (this.maxTurns && turnDepth + 1 >= this.maxTurns) {
1717
- logger?.debug(`Max turns (${this.maxTurns}) reached, stopping.`);
1718
- } else {
1719
- // Record committed snapshots to message history
1720
- if (this.reversionManager) {
1721
- const snapshots =
1722
- this.reversionManager.getAndClearCommittedSnapshots();
1723
- if (snapshots.length > 0) {
1724
- this.messageManager.addFileHistoryBlock(snapshots);
1725
- }
1726
- }
1727
-
1728
- // Check interruption status
1729
- const isCurrentlyAborted =
1730
- abortController.signal.aborted ||
1731
- toolAbortController.signal.aborted;
1732
-
1733
- // Check if all tools were manually backgrounded
1734
- const lastMessage =
1735
- this.messageManager.getMessages()[
1736
- this.messageManager.getMessages().length - 1
1737
- ];
1738
- const toolBlocks =
1739
- lastMessage?.blocks.filter(
1740
- (block): block is import("../types/messaging.js").ToolBlock =>
1741
- block.type === "tool",
1742
- ) || [];
1743
- const hasBackgrounded =
1744
- toolBlocks.length > 0 &&
1745
- toolBlocks.some((block) => block.isManuallyBackgrounded);
1746
-
1747
- if (hasBackgrounded) {
1748
- logger?.info(
1749
- "Some tools were manually backgrounded, stopping.",
1750
- );
1751
- } else if (!isCurrentlyAborted) {
1752
- // If response was truncated, add a hidden continuation message
1753
- if (result.finish_reason === "length") {
1754
- this.messageManager.addUserMessage({
1755
- content:
1756
- "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.",
1757
- isMeta: true,
1758
- });
1759
- }
1760
-
1761
- // Duplicate Tool Call Detection
1762
- if (toolCalls.length > 0) {
1763
- const messages = this.messageManager.getMessages();
1764
- // Find the most recent assistant message BEFORE the current one that has tool blocks
1765
- // The current assistant message is messages[messages.length - 1]
1766
- let previousAssistantWithTools: Message | undefined;
1767
- for (let i = messages.length - 2; i >= 0; i--) {
1768
- const msg = messages[i];
1769
- if (
1770
- msg.role === "assistant" &&
1771
- msg.blocks.some((b) => b.type === "tool")
1772
- ) {
1773
- previousAssistantWithTools = msg;
1774
- break;
1775
- }
1776
- }
1777
-
1778
- if (previousAssistantWithTools) {
1779
- const previousToolBlocks =
1780
- previousAssistantWithTools.blocks.filter(
1781
- (b): b is import("../types/messaging.js").ToolBlock =>
1782
- b.type === "tool",
1783
- );
1784
-
1785
- for (const currentToolCall of toolCalls) {
1786
- const currentName = currentToolCall.function?.name;
1787
- const currentArgs = currentToolCall.function?.arguments;
1788
-
1789
- const isDuplicate = previousToolBlocks.some(
1790
- (prevBlock) =>
1791
- prevBlock.name === currentName &&
1792
- prevBlock.parameters === currentArgs,
1793
- );
1794
-
1795
- if (isDuplicate && currentName) {
1796
- const toolId = currentToolCall.id;
1797
- const lastMessage = messages[messages.length - 1];
1798
- const toolBlock = lastMessage.blocks.find(
1799
- (b): b is import("../types/messaging.js").ToolBlock =>
1800
- b.type === "tool" && b.id === toolId,
1801
- );
1802
- if (toolBlock) {
1803
- const warning = `\n\nNote: You just called this tool with the same arguments in the previous turn. Please ensure you are not in a loop and consider if you need to change your approach.`;
1804
- this.messageManager.updateToolBlock({
1805
- id: toolId,
1806
- result: (toolBlock.result || "") + warning,
1807
- stage: "end",
1808
- });
1809
- }
1810
- }
1811
- }
1812
- }
1813
- }
1814
-
1815
- // Yield to the event loop so macrotasks (abort timers,
1816
- // signals) can be processed between turns. Without this,
1817
- // mocked async operations (microtasks) would starve the
1818
- // event loop and abort timers would never fire.
1819
- await new Promise((resolve) => setImmediate(resolve));
1820
-
1821
- // Re-check abort status after yielding — the signal may
1822
- // have fired during the setImmediate gap.
1823
- if (
1824
- abortController.signal.aborted ||
1825
- toolAbortController.signal.aborted
1826
- ) {
1827
- break inner;
1828
- }
1829
-
1830
- turnDepth++;
1831
- continue inner;
1832
- }
1833
- }
1834
- }
1835
-
1836
- // No tool calls (or stop conditions) → inner loop done
1837
- break inner;
1838
- } catch (error) {
1839
- // End LLM span with error
1840
- endLLMRequestSpan(llmSpan, {
1841
- model: model || this.getModelConfig().model || "",
1842
- success: false,
1843
- error: error instanceof Error ? error.message : String(error),
1844
- });
1845
-
1846
- // Log error event
1847
- logOTelEvent("error", {
1848
- error_type:
1849
- error instanceof Error ? error.constructor.name : "Unknown",
1850
- message: error instanceof Error ? error.message : String(error),
1851
- }).catch(() => {}); // Non-blocking
1852
-
1853
- // Finalize any streaming text/reasoning blocks so the UI stops ticking
1854
- // its in-progress timer (e.g. when the request is aborted mid-thought).
1855
- this.messageManager.finalizeStreamingBlocks();
1856
-
1857
- // Finalize any tool blocks stuck in start/streaming/running so the UI
1858
- // stops showing the yellow "running" spinner (e.g. abort mid-tool-stream).
1859
- this.messageManager.finalizeAbortedToolBlocks(
1860
- error instanceof Error ? error.message : undefined,
1861
- );
1862
-
1863
- this.messageManager.addErrorBlock(
1864
- error instanceof Error ? error.message : "Unknown error occurred",
1865
- );
1866
-
1867
- // Exit inner loop on error
1868
- break inner;
1869
- }
1870
- }
1871
-
1872
- // Finally-equivalent (runs once per outer iteration):
1873
- // Only execute cleanup and hooks for the initial call
1874
- if (turnOffset === 0) {
1875
- // OpenTelemetry: end interaction span
1876
- endInteractionSpan();
1877
-
1878
- // Save session in each iteration to ensure message persistence
1879
- await this.messageManager.saveSession();
1880
-
1881
- // Clear temporary rules
1882
- this.permissionManager?.clearTemporaryRules();
1883
-
1884
- // Clear abort controllers
1885
- this.abortController = null;
1886
- this.toolAbortController = null;
1887
-
1888
- // Execute Stop/SubagentStop hooks only if the operation was not aborted
1889
- const isCurrentlyAborted =
1890
- abortController.signal.aborted || toolAbortController.signal.aborted;
1891
-
1892
- if (!isCurrentlyAborted) {
1893
- // Record committed snapshots to message history for the final turn
1894
- if (this.reversionManager) {
1895
- const snapshots =
1896
- this.reversionManager.getAndClearCommittedSnapshots();
1897
- if (snapshots.length > 0) {
1898
- this.messageManager.addFileHistoryBlock(snapshots);
1899
- }
1900
- }
1901
-
1902
- // Execute Stop hooks
1903
- const shouldContinue = await this.executeStopHooks();
1904
-
1905
- // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1906
- // restart the AI conversation cycle
1907
- if (shouldContinue) {
1908
- logger?.info(
1909
- `${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
1910
- );
1911
-
1912
- // Restart the conversation to let AI fix the issues
1913
- shouldRestart = true;
1914
- turnOffset = 0;
1915
- }
1916
- }
1917
-
1918
- // Inject pending notifications from background tasks (after Stop hooks,
1919
- // aligned with Claude Code which fires Stop hooks unconditionally)
1920
- const messageQueue = this.container.has("MessageQueue")
1921
- ? this.container.get<MessageQueue>("MessageQueue")
1922
- : undefined;
1923
- if (messageQueue && messageQueue.hasNotifications()) {
1924
- const notifications = messageQueue.drainNotifications();
1925
- for (const notification of notifications) {
1926
- const block = parseTaskNotificationXml(notification);
1927
- if (block) {
1928
- this.messageManager.addNotificationMessage({
1929
- taskId: block.taskId,
1930
- taskType: block.taskType,
1931
- status: block.status,
1932
- summary: block.summary,
1933
- outputFile: block.outputFile,
1934
- });
1935
- }
1936
- }
1937
- // Re-assert loading state before restarting: if this turn was
1938
- // aborted, abortAIMessage already reset loading to false, and the
1939
- // restart below continues the conversation — the UI must show
1940
- // streaming again (cursor, stop button, ESC handling).
1941
- this.setIsLoading(true);
1942
- // Adopt the current (possibly abort-bumped) generation so this
1943
- // continued turn's end-of-turn cleanup is not skipped as superseded.
1944
- myGeneration = this.turnGeneration;
1945
- // Restart outer loop to process the notifications
1946
- shouldRestart = true;
1947
- turnOffset = 0;
1948
- }
1949
- }
1950
-
1951
- if (!shouldRestart) {
1952
- // Release loading state only after ALL cleanup is done (session save,
1953
- // Stop hooks, notification injection). Generation check ensures a
1954
- // superseded (aborted or newer) turn doesn't clobber the current
1955
- // loading state.
1956
- if (myGeneration === this.turnGeneration) {
1957
- this.setIsLoading(false);
1958
- }
1959
- break outer;
1960
- }
1961
- }
1962
- }
1963
-
1964
- /**
1965
- * Execute Stop or SubagentStop hooks when AI response cycle completes
1966
- * Uses "SubagentStop" hook name when triggered by a subagent, otherwise uses "Stop"
1967
- * @returns Promise<boolean> - true if should continue conversation, false if should stop
1968
- */
1969
- private async executeStopHooks(): Promise<boolean> {
1970
- if (!this.hookManager) return false;
1971
-
1972
- try {
1973
- // Use "SubagentStop" hook name when triggered by a subagent, otherwise use "Stop"
1974
- const hookName = this.subagentType ? "SubagentStop" : "Stop";
1975
-
1976
- // For main-agent Stop events, snapshot running background tasks and
1977
- // session cron jobs so the hook can decide whether to block stopping.
1978
- // SubagentStop does not include these fields (per spec FR-065).
1979
- const backgroundTasks: BackgroundTaskInfo[] | undefined =
1980
- hookName === "Stop"
1981
- ? (this.backgroundTaskManager?.getAllTasks() ?? [])
1982
- .filter((t) => t.status === "running")
1983
- .map((t) => mapBackgroundTask(t, this.subagentManager))
1984
- : undefined;
1985
-
1986
- const sessionCrons: SessionCronInfo[] | undefined =
1987
- hookName === "Stop"
1988
- ? (this.cronManager?.listJobs() ?? []).map((j) => ({
1989
- id: j.id,
1990
- schedule: j.cron,
1991
- recurring: j.recurring,
1992
- prompt: j.prompt,
1993
- }))
1994
- : undefined;
1995
-
1996
- // Extract text content from the last assistant message so hooks can
1997
- // inspect the final response without reading the transcript file.
1998
- const allMessages = this.messageManager.getMessages();
1999
- const lastAssistant = [...allMessages]
2000
- .reverse()
2001
- .find((m) => m.role === "assistant");
2002
- const lastAssistantText = lastAssistant
2003
- ? lastAssistant.blocks
2004
- .filter((b) => b.type === "text")
2005
- .map((b) => b.content)
2006
- .join("\n")
2007
- .trim() || undefined
2008
- : undefined;
2009
-
2010
- const context: ExtendedHookExecutionContext = {
2011
- event: hookName,
2012
- projectDir: this.getWorkdir(),
2013
- timestamp: new Date(),
2014
- sessionId: this.messageManager.getSessionId(),
2015
- transcriptPath: this.messageManager.getTranscriptPath(),
2016
- cwd: this.getWorkdir(),
2017
- subagentType: this.subagentType, // Include subagent type in hook context
2018
- backgroundTasks, // Stop-only: running background tasks snapshot
2019
- sessionCrons, // Stop-only: session cron jobs snapshot
2020
- lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
2021
- // Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
2022
- env: Object.fromEntries(
2023
- Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2024
- ) as Record<string, string>, // Include environment variables
2025
- };
2026
-
2027
- const results = await this.hookManager.executeHooks(hookName, context);
2028
-
2029
- // Process hook results to handle exit codes and appropriate responses
2030
- let shouldContinue = false;
2031
- if (results.length > 0) {
2032
- const processResult = this.hookManager.processHookResults(
2033
- hookName,
2034
- results,
2035
- this.messageManager,
2036
- );
2037
-
2038
- // If hook processing indicates we should block (exit code 2), continue conversation
2039
- if (processResult.shouldBlock) {
2040
- logger?.info(
2041
- `${hookName} hook blocked stopping with error:`,
2042
- processResult.errorMessage,
2043
- );
2044
- shouldContinue = true;
2045
- }
2046
- }
2047
-
2048
- // Log hook execution results for debugging
2049
- if (results.length > 0) {
2050
- logger?.debug(
2051
- `Executed ${results.length} ${hookName} hook(s):`,
2052
- results.map((r) => ({
2053
- success: r.success,
2054
- duration: r.duration,
2055
- exitCode: r.exitCode,
2056
- timedOut: r.timedOut,
2057
- stderr: r.stderr,
2058
- })),
2059
- );
2060
- }
2061
-
2062
- // Trigger auto-memory extraction if enabled and this is the main agent
2063
- if (!this.subagentType) {
2064
- const autoMemoryService =
2065
- this.container.get<
2066
- import("../services/autoMemoryService.js").AutoMemoryService
2067
- >("AutoMemoryService");
2068
- if (autoMemoryService) {
2069
- // Trigger extraction, but don't block the return.
2070
- // onTurnEnd itself returns quickly after forking.
2071
- autoMemoryService.onTurnEnd(this.getWorkdir()).catch((err) => {
2072
- logger?.error("Auto-memory extraction trigger failed:", err);
2073
- });
2074
- }
2075
- }
2076
-
2077
- return shouldContinue;
2078
- } catch (error) {
2079
- // Hook execution errors should not interrupt the main workflow
2080
- logger?.error(
2081
- `${this.subagentType ? "SubagentStop" : "Stop"} hook execution failed:`,
2082
- error,
2083
- );
2084
- return false;
2085
- }
2086
- }
2087
-
2088
- /**
2089
- * Execute a single tool call: arg parsing, block emission, PreToolUse hooks,
2090
- * tool execution, PostToolUse hooks, and error handling.
2091
- */
2092
- private async executeToolCall(
2093
- functionToolCall: ChatCompletionMessageFunctionToolCall,
2094
- abortController: AbortController,
2095
- toolAbortController: AbortController,
2096
- finishReason?: string | null,
2097
- ): Promise<void> {
2098
- const toolId = functionToolCall.id || "";
2099
-
2100
- // Check if already interrupted, skip tool execution if so
2101
- if (abortController.signal.aborted || toolAbortController.signal.aborted) {
2102
- return;
2103
- }
2104
-
2105
- const toolName = functionToolCall.function?.name || "";
2106
- // Safely parse tool parameters, handle tools without parameters
2107
- let toolArgs: Record<string, unknown> = {};
2108
- let jsonRecovered = false;
2109
- const argsString = functionToolCall.function?.arguments?.trim();
2110
-
2111
- if (!argsString || argsString === "") {
2112
- // Tool without parameters, use empty object
2113
- toolArgs = {};
2114
- } else {
2115
- let recoveredArgs = argsString;
2116
- try {
2117
- toolArgs = JSON.parse(argsString);
2118
- } catch {
2119
- // Attempt to recover truncated JSON (e.g., missing closing braces)
2120
- recoveredArgs = recoverTruncatedJson(argsString);
2121
- try {
2122
- toolArgs = JSON.parse(recoveredArgs);
2123
- jsonRecovered = true;
2124
- logger.warn(`Recovered truncated JSON for tool "${toolName}"`);
2125
- } catch (parseError) {
2126
- let errorMessage = `Failed to parse tool arguments`;
2127
- if (finishReason === "length") {
2128
- errorMessage += " (output truncated, please reduce your output)";
2129
- }
2130
- logger?.error(errorMessage, parseError);
2131
- this.messageManager.updateToolBlock({
2132
- id: toolId,
2133
- parameters: argsString,
2134
- result: errorMessage,
2135
- success: false,
2136
- error: errorMessage,
2137
- stage: "end",
2138
- name: toolName,
2139
- compactParams: "",
2140
- timestamp: Date.now(),
2141
- });
2142
- return;
2143
- }
2144
- }
2145
- }
2146
-
2147
- const compactParams = this.generateCompactParams(toolName, toolArgs);
2148
-
2149
- // Emit start stage for non-streaming tool calls
2150
- if (!this.stream) {
2151
- this.messageManager.updateToolBlock({
2152
- id: toolId,
2153
- stage: "start",
2154
- name: toolName,
2155
- compactParams,
2156
- parameters: argsString,
2157
- });
2158
- }
2159
-
2160
- // Emit running stage (tool execution about to start)
2161
- this.messageManager.updateToolBlock({
2162
- id: toolId,
2163
- stage: "running",
2164
- name: toolName,
2165
- compactParams,
2166
- parameters: argsString,
2167
- parametersChunk: "",
2168
- });
2169
-
2170
- try {
2171
- // Execute PreToolUse hooks before tool execution
2172
- const shouldExecuteTool = await this.executePreToolUseHooks(
2173
- toolName,
2174
- toolArgs,
2175
- toolId,
2176
- );
2177
-
2178
- // If PreToolUse hooks blocked execution, skip tool execution
2179
- if (!shouldExecuteTool) {
2180
- logger?.info(`Tool ${toolName} execution blocked by PreToolUse hooks`);
2181
- return;
2182
- }
2183
-
2184
- // Create tool execution context
2185
- const context: ToolContext = {
2186
- abortSignal: toolAbortController.signal,
2187
- backgroundTaskManager: this.backgroundTaskManager,
2188
- workdir: this.getWorkdir(),
2189
- originalWorkdir: this.originalWorkdir,
2190
- messageId: this.messageManager.getMessages().slice(-1)[0]?.id,
2191
- sessionId: this.messageManager.getSessionId(),
2192
- toolCallId: toolId,
2193
- taskManager: this.taskManager,
2194
- readFileState: this.readFileState,
2195
- onShortResultUpdate: (shortResult: string) => {
2196
- this.messageManager.updateToolBlock({
2197
- id: toolId,
2198
- shortResult,
2199
- stage: "running",
2200
- compactParams,
2201
- name: toolName,
2202
- });
2203
- },
2204
- onResultUpdate: (result: string) => {
2205
- this.messageManager.updateToolBlock({
2206
- id: toolId,
2207
- result,
2208
- stage: "running",
2209
- compactParams,
2210
- name: toolName,
2211
- });
2212
- },
2213
- onCwdChange: async (newCwd: string) => {
2214
- const oldCwd = this.getWorkdir();
2215
- this.container.register("Workdir", newCwd);
2216
- this._onCwdChange?.(newCwd);
2217
- if (this.hookManager) {
2218
- const sessionId = this.messageManager.getSessionId();
2219
- const transcriptPath = this.messageManager.getTranscriptPath();
2220
- const env = Object.fromEntries(
2221
- Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2222
- ) as Record<string, string>;
2223
- await this.hookManager.executeCwdChangedHooks(
2224
- oldCwd,
2225
- newCwd,
2226
- sessionId,
2227
- transcriptPath,
2228
- env,
2229
- );
2230
- }
2231
- },
2232
- };
2233
-
2234
- // Execute tool
2235
- const toolResult = await this.toolManager.execute(
2236
- functionToolCall.function?.name || "",
2237
- toolArgs,
2238
- context,
2239
- );
2240
-
2241
- // Build result content, adding truncation warning if JSON was recovered
2242
- let toolResultContent =
2243
- toolResult.content ||
2244
- (toolResult.error ? `Error: ${toolResult.error}` : "");
2245
- if (jsonRecovered) {
2246
- toolResultContent +=
2247
- "\n\nTool arguments were truncated (likely exceeded max output tokens). Please reduce your output or split into multiple tool calls.";
2248
- }
2249
-
2250
- // Update message state - tool execution completed
2251
- this.messageManager.updateToolBlock({
2252
- id: toolId,
2253
- parameters: argsString,
2254
- result: toolResultContent,
2255
- success: toolResult.success,
2256
- error: toolResult.error,
2257
- stage: "end",
2258
- name: toolName,
2259
- compactParams,
2260
- shortResult: toolResult.shortResult,
2261
- isManuallyBackgrounded: toolResult.isManuallyBackgrounded,
2262
- startLineNumber: toolResult.startLineNumber,
2263
- images: toolResult.images,
2264
- timestamp: Date.now(),
2265
- });
2266
-
2267
- // Execute PostToolUse hooks after successful tool completion
2268
- await this.executePostToolUseHooks(
2269
- toolId,
2270
- toolName,
2271
- toolArgs,
2272
- toolResult,
2273
- );
2274
- } catch (toolError) {
2275
- const errorMessage =
2276
- toolError instanceof Error ? toolError.message : String(toolError);
2277
-
2278
- this.messageManager.updateToolBlock({
2279
- id: toolId,
2280
- parameters: JSON.stringify(toolArgs, null, 2),
2281
- result: `Tool execution failed: ${errorMessage}`,
2282
- success: false,
2283
- error: errorMessage,
2284
- stage: "end",
2285
- name: toolName,
2286
- compactParams,
2287
- isManuallyBackgrounded: false,
2288
- timestamp: Date.now(),
2289
- });
2290
- }
2291
- }
2292
-
2293
- /**
2294
- * Execute PreToolUse hooks before tool execution
2295
- * Returns true if hooks allow tool execution, false if blocked
2296
- */
2297
- private async executePreToolUseHooks(
2298
- toolName: string,
2299
- toolInput?: Record<string, unknown>,
2300
- toolId?: string,
2301
- ): Promise<boolean> {
2302
- if (!this.hookManager) return true;
2303
-
2304
- try {
2305
- const context: ExtendedHookExecutionContext = {
2306
- event: "PreToolUse",
2307
- projectDir: this.getWorkdir(),
2308
- timestamp: new Date(),
2309
- toolName,
2310
- sessionId: this.messageManager.getSessionId(),
2311
- transcriptPath: this.messageManager.getTranscriptPath(),
2312
- cwd: this.getWorkdir(),
2313
- toolInput,
2314
- subagentType: this.subagentType, // Include subagent type in hook context
2315
- env: Object.fromEntries(
2316
- Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2317
- ) as Record<string, string>, // Include environment variables
2318
- };
2319
-
2320
- const results = await this.hookManager.executeHooks(
2321
- "PreToolUse",
2322
- context,
2323
- );
2324
-
2325
- // Process hook results to handle exit codes and determine if tool should be blocked
2326
- let shouldContinue = true;
2327
- if (results.length > 0) {
2328
- const processResult = this.hookManager.processHookResults(
2329
- "PreToolUse",
2330
- results,
2331
- this.messageManager,
2332
- toolId, // Pass toolId for proper PreToolUse blocking error handling
2333
- JSON.stringify(toolInput || {}, null, 2), // Pass serialized tool parameters
2334
- );
2335
- shouldContinue = !processResult.shouldBlock;
2336
- }
2337
-
2338
- // Log tool_decision event
2339
- logOTelEvent("tool_decision", {
2340
- tool_name: toolName,
2341
- decision: shouldContinue ? "approved" : "blocked",
2342
- source: "hook",
2343
- }).catch(() => {}); // Non-blocking
2344
-
2345
- // Log hook execution results for debugging
2346
- if (results.length > 0) {
2347
- logger?.debug(
2348
- `Executed ${results.length} PreToolUse hook(s) for ${toolName}:`,
2349
- results.map((r) => ({
2350
- success: r.success,
2351
- duration: r.duration,
2352
- exitCode: r.exitCode,
2353
- timedOut: r.timedOut,
2354
- stderr: r.stderr,
2355
- })),
2356
- );
2357
- }
2358
-
2359
- return shouldContinue;
2360
- } catch (error) {
2361
- // Hook execution errors should not interrupt the main workflow
2362
- logger?.error("PreToolUse hook execution failed:", error);
2363
- return true; // Allow tool execution on hook errors
2364
- }
2365
- }
2366
-
2367
- /**
2368
- * Execute PostToolUse hooks after tool completion
2369
- */
2370
- private async executePostToolUseHooks(
2371
- toolId: string,
2372
- toolName: string,
2373
- toolInput?: Record<string, unknown>,
2374
- toolResponse?: ToolResult,
2375
- ): Promise<void> {
2376
- if (!this.hookManager) return;
2377
-
2378
- try {
2379
- const context: ExtendedHookExecutionContext = {
2380
- event: "PostToolUse",
2381
- projectDir: this.getWorkdir(),
2382
- timestamp: new Date(),
2383
- toolName,
2384
- sessionId: this.messageManager.getSessionId(),
2385
- transcriptPath: this.messageManager.getTranscriptPath(),
2386
- cwd: this.getWorkdir(),
2387
- toolInput,
2388
- toolResponse,
2389
- subagentType: this.subagentType, // Include subagent type in hook context
2390
- planFilePath: this.permissionManager?.getPlanFilePath(),
2391
- env: Object.fromEntries(
2392
- Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
2393
- ) as Record<string, string>, // Include environment variables
2394
- };
2395
-
2396
- const results = await this.hookManager.executeHooks(
2397
- "PostToolUse",
2398
- context,
2399
- );
2400
-
2401
- // Process hook results to handle exit codes and update tool results
2402
- if (results.length > 0) {
2403
- this.hookManager.processHookResults(
2404
- "PostToolUse",
2405
- results,
2406
- this.messageManager,
2407
- toolId,
2408
- );
2409
- }
2410
-
2411
- // Log hook execution results for debugging
2412
- if (results.length > 0) {
2413
- logger?.debug(
2414
- `Executed ${results.length} PostToolUse hook(s) for ${toolName}:`,
2415
- results.map((r) => ({
2416
- success: r.success,
2417
- duration: r.duration,
2418
- exitCode: r.exitCode,
2419
- timedOut: r.timedOut,
2420
- stderr: r.stderr,
2421
- })),
2422
- );
2423
- }
2424
- } catch (error) {
2425
- // Hook execution errors should not interrupt the main workflow
2426
- logger?.error("PostToolUse hook execution failed:", error);
2427
- }
2428
- }
2429
- }