wave-agent-sdk 0.17.9 → 0.17.11

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 (85) hide show
  1. package/builtin/skills/settings/ENV.md +1 -1
  2. package/dist/managers/aiManager.d.ts +11 -4
  3. package/dist/managers/aiManager.d.ts.map +1 -1
  4. package/dist/managers/aiManager.js +232 -200
  5. package/dist/managers/backgroundTaskManager.js +1 -1
  6. package/dist/managers/bangManager.js +1 -1
  7. package/dist/managers/hookManager.js +2 -4
  8. package/dist/managers/mcpManager.d.ts.map +1 -1
  9. package/dist/managers/mcpManager.js +1 -3
  10. package/dist/managers/messageManager.d.ts.map +1 -1
  11. package/dist/managers/messageManager.js +1 -8
  12. package/dist/managers/toolManager.d.ts +6 -0
  13. package/dist/managers/toolManager.d.ts.map +1 -1
  14. package/dist/managers/toolManager.js +17 -12
  15. package/dist/services/aiService.d.ts.map +1 -1
  16. package/dist/services/aiService.js +20 -12
  17. package/dist/services/authService.d.ts +0 -1
  18. package/dist/services/authService.d.ts.map +1 -1
  19. package/dist/services/authService.js +1 -29
  20. package/dist/services/configurationService.d.ts.map +1 -1
  21. package/dist/services/configurationService.js +13 -0
  22. package/dist/services/fileWatcher.d.ts +2 -0
  23. package/dist/services/fileWatcher.d.ts.map +1 -1
  24. package/dist/services/fileWatcher.js +12 -1
  25. package/dist/services/jsonlHandler.d.ts +1 -1
  26. package/dist/services/jsonlHandler.d.ts.map +1 -1
  27. package/dist/services/jsonlHandler.js +7 -22
  28. package/dist/services/session.d.ts +2 -9
  29. package/dist/services/session.d.ts.map +1 -1
  30. package/dist/services/session.js +16 -46
  31. package/dist/tools/bashTool.d.ts.map +1 -1
  32. package/dist/tools/bashTool.js +3 -1
  33. package/dist/tools/editTool.d.ts.map +1 -1
  34. package/dist/tools/editTool.js +28 -1
  35. package/dist/tools/enterPlanMode.d.ts.map +1 -1
  36. package/dist/tools/enterPlanMode.js +8 -0
  37. package/dist/tools/exitPlanMode.d.ts.map +1 -1
  38. package/dist/tools/exitPlanMode.js +8 -0
  39. package/dist/tools/readTool.d.ts.map +1 -1
  40. package/dist/tools/readTool.js +18 -10
  41. package/dist/tools/types.d.ts +8 -2
  42. package/dist/tools/types.d.ts.map +1 -1
  43. package/dist/tools/writeTool.d.ts.map +1 -1
  44. package/dist/tools/writeTool.js +14 -1
  45. package/dist/types/agent.d.ts +0 -2
  46. package/dist/types/agent.d.ts.map +1 -1
  47. package/dist/types/config.d.ts +1 -0
  48. package/dist/types/config.d.ts.map +1 -1
  49. package/dist/utils/cacheControlUtils.d.ts +20 -15
  50. package/dist/utils/cacheControlUtils.d.ts.map +1 -1
  51. package/dist/utils/cacheControlUtils.js +69 -66
  52. package/dist/utils/constants.d.ts +1 -1
  53. package/dist/utils/constants.js +1 -1
  54. package/dist/utils/containerSetup.d.ts.map +1 -1
  55. package/dist/utils/containerSetup.js +1 -6
  56. package/dist/utils/taskReminder.d.ts +17 -0
  57. package/dist/utils/taskReminder.d.ts.map +1 -0
  58. package/dist/utils/taskReminder.js +76 -0
  59. package/package.json +1 -1
  60. package/src/managers/aiManager.ts +326 -262
  61. package/src/managers/backgroundTaskManager.ts +1 -1
  62. package/src/managers/bangManager.ts +1 -1
  63. package/src/managers/hookManager.ts +6 -6
  64. package/src/managers/mcpManager.ts +2 -4
  65. package/src/managers/messageManager.ts +1 -9
  66. package/src/managers/toolManager.ts +18 -12
  67. package/src/services/aiService.ts +23 -12
  68. package/src/services/authService.ts +1 -31
  69. package/src/services/configurationService.ts +21 -0
  70. package/src/services/fileWatcher.ts +15 -1
  71. package/src/services/jsonlHandler.ts +7 -27
  72. package/src/services/session.ts +16 -57
  73. package/src/tools/bashTool.ts +3 -1
  74. package/src/tools/editTool.ts +31 -1
  75. package/src/tools/enterPlanMode.ts +9 -0
  76. package/src/tools/exitPlanMode.ts +10 -0
  77. package/src/tools/readTool.ts +20 -10
  78. package/src/tools/types.ts +15 -3
  79. package/src/tools/writeTool.ts +15 -1
  80. package/src/types/agent.ts +0 -2
  81. package/src/types/config.ts +1 -0
  82. package/src/utils/cacheControlUtils.ts +77 -95
  83. package/src/utils/constants.ts +1 -1
  84. package/src/utils/containerSetup.ts +3 -7
  85. package/src/utils/taskReminder.ts +108 -0
@@ -4,7 +4,11 @@ import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
4
4
  import { microcompactMessages } from "../utils/microcompact.js";
5
5
  import { parseTaskNotificationXml } from "../utils/notificationXml.js";
6
6
  import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
7
- import * as fs from "node:fs/promises";
7
+ import {
8
+ getTaskReminderTurnCounts,
9
+ maybeInjectTaskReminder,
10
+ TASK_REMINDER_CONFIG,
11
+ } from "../utils/taskReminder.js";
8
12
  import { existsSync } from "node:fs";
9
13
  import type {
10
14
  GatewayConfig,
@@ -18,6 +22,7 @@ import type { ToolContext, ToolResult } from "../tools/types.js";
18
22
  import type { MessageManager } from "./messageManager.js";
19
23
  import type { BackgroundTaskManager } from "./backgroundTaskManager.js";
20
24
  import { ChatCompletionMessageFunctionToolCall } from "openai/resources.js";
25
+ import type { ChatCompletionMessageParam } from "openai/resources.js";
21
26
  import type { HookManager } from "./hookManager.js";
22
27
  import type { ExtendedHookExecutionContext } from "../types/hooks.js";
23
28
  import type { PermissionManager } from "./permissionManager.js";
@@ -75,6 +80,11 @@ export class AIManager {
75
80
  private originalWorkdir: string;
76
81
  private consecutiveCompactionFailures: number = 0;
77
82
  private readonly maxTurns?: number;
83
+ /** Tracks file mtime/hash at read time for staleness detection on Edit/Write */
84
+ private readFileState = new Map<
85
+ string,
86
+ { mtime: number; hash: string; offset?: number; limit?: number }
87
+ >();
78
88
  /** Override tool_choice for this AI manager (e.g. for structured output) */
79
89
  public toolChoiceOverride?:
80
90
  | "auto"
@@ -238,32 +248,29 @@ export class AIManager {
238
248
  }
239
249
 
240
250
  /**
241
- * Build plan mode system-reminder messages to inject into the API message stream.
242
- * These are transient messages not stored in the message history.
243
- * This preserves prompt caching by keeping the system prompt constant.
251
+ * Add plan mode reminder as a persistent meta message to the message manager.
252
+ * Called before getMessages() so the stored message is included in the API call.
244
253
  */
245
- private buildPlanModeMessages(
254
+ private maybeAddPlanModeMessage(
246
255
  currentMode: PermissionMode | undefined,
247
- ): import("openai/resources.js").ChatCompletionMessageParam[] {
248
- const messages: import("openai/resources.js").ChatCompletionMessageParam[] =
249
- [];
250
- if (!this.permissionManager) return messages;
256
+ ): void {
257
+ if (!this.permissionManager) return;
251
258
 
252
259
  // Handle exit notification (one-time after leaving plan mode)
253
260
  if (this.permissionManager.getNeedsPlanModeExitAttachment()) {
254
261
  const planFilePath = this.permissionManager.getPlanFilePath();
255
262
  const planExists = planFilePath ? existsSync(planFilePath) : false;
256
- messages.push({
257
- role: "user",
263
+ this.messageManager.addUserMessage({
258
264
  content: buildExitedPlanModeReminder(planFilePath, planExists),
265
+ isMeta: true,
259
266
  });
260
267
  this.permissionManager.setNeedsPlanModeExitAttachment(false);
261
268
  }
262
269
 
263
- if (currentMode !== "plan") return messages;
270
+ if (currentMode !== "plan") return;
264
271
 
265
272
  const planFilePath = this.permissionManager.getPlanFilePath();
266
- if (!planFilePath) return messages;
273
+ if (!planFilePath) return;
267
274
 
268
275
  const planExists = existsSync(planFilePath);
269
276
 
@@ -272,25 +279,46 @@ export class AIManager {
272
279
  if (planMgr?.isPlanEntryReminderPending()) {
273
280
  if (this.permissionManager.hasExitedPlanModeInSession() && planExists) {
274
281
  // Re-entry: use small reminder
275
- messages.push({
276
- role: "user",
282
+ this.messageManager.addUserMessage({
277
283
  content: buildPlanModeReEntryReminder(planFilePath),
284
+ isMeta: true,
278
285
  });
279
286
  } else {
280
287
  // First entry: use full reminder
281
- messages.push({
282
- role: "user",
288
+ this.messageManager.addUserMessage({
283
289
  content: buildPlanModeReminder(
284
290
  planFilePath,
285
291
  planExists,
286
292
  !!this.subagentType,
287
293
  ),
294
+ isMeta: true,
288
295
  });
289
296
  }
290
297
  planMgr.consumePlanEntryReminder();
291
298
  }
299
+ }
300
+
301
+ private async maybeInjectTaskReminderIntoMessages(
302
+ messages: ChatCompletionMessageParam[],
303
+ toolNames: Set<string>,
304
+ ): Promise<void> {
305
+ // Guard: no task tools available
306
+ if (!toolNames.has("TaskUpdate")) return;
307
+
308
+ const internalMessages = this.messageManager.getMessages();
309
+ const turnCounts = getTaskReminderTurnCounts(internalMessages);
310
+
311
+ if (
312
+ turnCounts.turnsSinceLastTaskManagement <
313
+ TASK_REMINDER_CONFIG.TURNS_SINCE_WRITE ||
314
+ turnCounts.turnsSinceLastReminder <
315
+ TASK_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
316
+ ) {
317
+ return;
318
+ }
292
319
 
293
- return messages;
320
+ const tasks = await this.taskManager.listTasks();
321
+ maybeInjectTaskReminder(messages, turnCounts, tasks);
294
322
  }
295
323
 
296
324
  public setIsLoading(isLoading: boolean): void {
@@ -470,6 +498,25 @@ export class AIManager {
470
498
  compactUsage,
471
499
  );
472
500
 
501
+ // Re-add plan mode reminder as persistent meta message after compaction
502
+ const postCompactMode = this.permissionManager?.getCurrentEffectiveMode(
503
+ this.getModelConfig().permissionMode,
504
+ );
505
+ if (postCompactMode === "plan") {
506
+ const planFilePath = this.permissionManager?.getPlanFilePath();
507
+ if (planFilePath) {
508
+ const planExists = existsSync(planFilePath);
509
+ this.messageManager.addUserMessage({
510
+ content: buildPlanModeReminder(
511
+ planFilePath,
512
+ planExists,
513
+ !!this.subagentType,
514
+ ),
515
+ isMeta: true,
516
+ });
517
+ }
518
+ }
519
+
473
520
  // 8. Track usage
474
521
  if (compactUsage && this.callbacks?.onUsageAdded) {
475
522
  this.callbacks.onUsageAdded(compactUsage);
@@ -569,26 +616,6 @@ export class AIManager {
569
616
  if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break;
570
617
  }
571
618
 
572
- // 2. Plan mode context
573
- const currentMode = this.permissionManager?.getCurrentEffectiveMode(
574
- this.getModelConfig().permissionMode,
575
- );
576
- if (currentMode === "plan") {
577
- const planFilePath = this.permissionManager?.getPlanFilePath();
578
- if (planFilePath) {
579
- let planExists = false;
580
- try {
581
- await fs.access(planFilePath);
582
- planExists = true;
583
- } catch {
584
- // Plan file doesn't exist yet
585
- }
586
- contextParts.push(
587
- `\n\n${buildPlanModeReminder(planFilePath, planExists, !!this.subagentType)}`,
588
- );
589
- }
590
- }
591
-
592
619
  // 4. Invoked skills context (with token budget, matching Claude Code)
593
620
  const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000;
594
621
  const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000;
@@ -784,6 +811,14 @@ export class AIManager {
784
811
  toolAbortController = this.toolAbortController!;
785
812
  }
786
813
 
814
+ // Get current permission mode
815
+ const currentMode = this.permissionManager?.getCurrentEffectiveMode(
816
+ this.getModelConfig().permissionMode,
817
+ );
818
+
819
+ // Add plan mode reminder as persistent meta message before getting messages
820
+ this.maybeAddPlanModeMessage(currentMode);
821
+
787
822
  // Get recent message history with microcompact applied
788
823
  const rawMessages = this.messageManager.getMessages();
789
824
  const microcompactedMessages = microcompactMessages(rawMessages, {
@@ -801,23 +836,12 @@ export class AIManager {
801
836
 
802
837
  logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
803
838
 
804
- // Get current permission mode and plan file path
805
- const currentMode = this.permissionManager?.getCurrentEffectiveMode(
806
- this.getModelConfig().permissionMode,
807
- );
808
839
  const toolsConfig = this.getFilteredToolsConfig();
809
840
  const toolNames = new Set(toolsConfig.map((t) => t.function.name));
810
841
  const filteredToolPlugins = this.toolManager
811
842
  .getTools()
812
843
  .filter((t) => toolNames.has(t.name));
813
844
 
814
- // Inject plan mode system-reminder messages (not system prompt)
815
- // This preserves prompt caching by keeping the system prompt constant
816
- const planModeMessages = this.buildPlanModeMessages(currentMode);
817
- if (planModeMessages.length > 0) {
818
- recentMessages.push(...planModeMessages);
819
- }
820
-
821
845
  let autoMemoryOptions: { directory: string; content: string } | undefined;
822
846
 
823
847
  if (this.getAutoMemoryEnabled()) {
@@ -857,6 +881,12 @@ export class AIManager {
857
881
  toolChoice: this.toolChoiceOverride, // Pass tool_choice override
858
882
  };
859
883
 
884
+ // Inject task reminder if needed (not persisted, regenerated each turn)
885
+ await this.maybeInjectTaskReminderIntoMessages(
886
+ callAgentOptions.messages,
887
+ toolNames,
888
+ );
889
+
860
890
  // Add streaming callbacks only if streaming is enabled
861
891
  if (this.stream) {
862
892
  callAgentOptions.onContentUpdate = (content: string) => {
@@ -1001,218 +1031,52 @@ export class AIManager {
1001
1031
  }
1002
1032
 
1003
1033
  if (toolCalls.length > 0) {
1004
- // Execute all tools in parallel using Promise.all
1005
- const toolExecutionPromises = toolCalls.map(
1006
- async (functionToolCall) => {
1007
- const toolId = functionToolCall.id || "";
1008
-
1009
- // Check if already interrupted, skip tool execution if so
1010
- if (
1011
- abortController.signal.aborted ||
1012
- toolAbortController.signal.aborted
1013
- ) {
1014
- return;
1015
- }
1016
-
1017
- const toolName = functionToolCall.function?.name || "";
1018
- // Safely parse tool parameters, handle tools without parameters
1019
- let toolArgs: Record<string, unknown> = {};
1020
- let jsonRecovered = false;
1021
- const argsString = functionToolCall.function?.arguments?.trim();
1022
-
1023
- if (!argsString || argsString === "") {
1024
- // Tool without parameters, use empty object
1025
- toolArgs = {};
1026
- } else {
1027
- let recoveredArgs = argsString;
1028
- try {
1029
- toolArgs = JSON.parse(argsString);
1030
- } catch {
1031
- // Attempt to recover truncated JSON (e.g., missing closing braces)
1032
- recoveredArgs = recoverTruncatedJson(argsString);
1033
- try {
1034
- toolArgs = JSON.parse(recoveredArgs);
1035
- jsonRecovered = true;
1036
- logger.warn(
1037
- `Recovered truncated JSON for tool "${toolName}"`,
1038
- );
1039
- } catch (parseError) {
1040
- let errorMessage = `Failed to parse tool arguments`;
1041
- if (result.finish_reason === "length") {
1042
- errorMessage +=
1043
- " (output truncated, please reduce your output)";
1044
- }
1045
- logger?.error(errorMessage, parseError);
1046
- this.messageManager.updateToolBlock({
1047
- id: toolId,
1048
- parameters: argsString,
1049
- result: errorMessage,
1050
- success: false,
1051
- error: errorMessage,
1052
- stage: "end",
1053
- name: toolName,
1054
- compactParams: "",
1055
- timestamp: Date.now(),
1056
- });
1057
- return;
1058
- }
1059
- }
1060
- }
1034
+ // Partition tool calls into batches: consecutive concurrency-safe
1035
+ // tools run in parallel, non-safe tools (Edit, Write, MCP) run one
1036
+ // at a time to prevent read-modify-write races on the same file.
1037
+ const batches: {
1038
+ calls: ChatCompletionMessageFunctionToolCall[];
1039
+ safe: boolean;
1040
+ }[] = [];
1041
+ for (const call of toolCalls) {
1042
+ const toolName = call.function?.name || "";
1043
+ const safe = this.toolManager.isConcurrencySafe(toolName);
1044
+ const lastBatch = batches[batches.length - 1];
1045
+ if (lastBatch && lastBatch.safe && safe) {
1046
+ lastBatch.calls.push(call);
1047
+ } else {
1048
+ batches.push({ calls: [call], safe });
1049
+ }
1050
+ }
1061
1051
 
1062
- const compactParams = this.generateCompactParams(
1063
- toolName,
1064
- toolArgs,
1052
+ // Execute batches sequentially; within a safe batch, tools run in parallel
1053
+ for (const batch of batches) {
1054
+ if (
1055
+ abortController.signal.aborted ||
1056
+ toolAbortController.signal.aborted
1057
+ ) {
1058
+ break;
1059
+ }
1060
+ if (batch.calls.length === 1) {
1061
+ await this.executeToolCall(
1062
+ batch.calls[0],
1063
+ abortController,
1064
+ toolAbortController,
1065
+ result.finish_reason,
1065
1066
  );
1066
-
1067
- // Emit start stage for non-streaming tool calls
1068
- if (!this.stream) {
1069
- this.messageManager.updateToolBlock({
1070
- id: toolId,
1071
- stage: "start",
1072
- name: toolName,
1073
- compactParams,
1074
- parameters: argsString,
1075
- });
1076
- }
1077
-
1078
- // Emit running stage (tool execution about to start)
1079
- this.messageManager.updateToolBlock({
1080
- id: toolId,
1081
- stage: "running",
1082
- name: toolName,
1083
- compactParams,
1084
- parameters: argsString,
1085
- parametersChunk: "",
1086
- });
1087
-
1088
- try {
1089
- // Execute PreToolUse hooks before tool execution
1090
- const shouldExecuteTool = await this.executePreToolUseHooks(
1091
- toolName,
1092
- toolArgs,
1093
- toolId,
1094
- );
1095
-
1096
- // If PreToolUse hooks blocked execution, skip tool execution
1097
- if (!shouldExecuteTool) {
1098
- logger?.info(
1099
- `Tool ${toolName} execution blocked by PreToolUse hooks`,
1100
- );
1101
- return; // Skip this tool and return from this map function
1102
- }
1103
-
1104
- // Create tool execution context
1105
- const context: ToolContext = {
1106
- abortSignal: toolAbortController.signal,
1107
- backgroundTaskManager: this.backgroundTaskManager,
1108
- workdir: this.getWorkdir(),
1109
- originalWorkdir: this.originalWorkdir,
1110
- env: this.container.get<Record<string, string>>("MergedEnv"),
1111
- messageId: this.messageManager.getMessages().slice(-1)[0]?.id,
1112
- sessionId: this.messageManager.getSessionId(),
1113
- toolCallId: toolId,
1114
- taskManager: this.taskManager,
1115
- onShortResultUpdate: (shortResult: string) => {
1116
- this.messageManager.updateToolBlock({
1117
- id: toolId,
1118
- shortResult,
1119
- stage: "running", // Keep it in running stage while updating shortResult
1120
- });
1121
- },
1122
- onResultUpdate: (result: string) => {
1123
- this.messageManager.updateToolBlock({
1124
- id: toolId,
1125
- result,
1126
- stage: "running", // Keep it in running stage while updating result
1127
- });
1128
- },
1129
- onCwdChange: async (newCwd: string) => {
1130
- const oldCwd = this.getWorkdir();
1131
- this.container.register("Workdir", newCwd);
1132
- this._onCwdChange?.(newCwd);
1133
- if (this.hookManager) {
1134
- const sessionId = this.messageManager.getSessionId();
1135
- const transcriptPath =
1136
- this.messageManager.getTranscriptPath();
1137
- const env = Object.fromEntries(
1138
- Object.entries(process.env).filter(
1139
- (e) => e[1] !== undefined,
1140
- ),
1141
- ) as Record<string, string>;
1142
- await this.hookManager.executeCwdChangedHooks(
1143
- oldCwd,
1144
- newCwd,
1145
- sessionId,
1146
- transcriptPath,
1147
- env,
1148
- );
1149
- }
1150
- },
1151
- };
1152
-
1153
- // Execute tool
1154
- const toolResult = await this.toolManager.execute(
1155
- functionToolCall.function?.name || "",
1156
- toolArgs,
1157
- context,
1158
- );
1159
-
1160
- // Build result content, adding truncation warning if JSON was recovered
1161
- let toolResultContent =
1162
- toolResult.content ||
1163
- (toolResult.error ? `Error: ${toolResult.error}` : "");
1164
- if (jsonRecovered) {
1165
- toolResultContent +=
1166
- "\n\nTool arguments were truncated (likely exceeded max output tokens). Please reduce your output or split into multiple tool calls.";
1167
- }
1168
-
1169
- // Update message state - tool execution completed
1170
- this.messageManager.updateToolBlock({
1171
- id: toolId,
1172
- parameters: argsString,
1173
- result: toolResultContent,
1174
- success: toolResult.success,
1175
- error: toolResult.error,
1176
- stage: "end",
1177
- name: toolName,
1178
- compactParams,
1179
- shortResult: toolResult.shortResult,
1180
- isManuallyBackgrounded: toolResult.isManuallyBackgrounded,
1181
- startLineNumber: toolResult.startLineNumber,
1182
- timestamp: Date.now(),
1183
- });
1184
-
1185
- // Execute PostToolUse hooks after successful tool completion
1186
- await this.executePostToolUseHooks(
1187
- toolId,
1188
- toolName,
1189
- toolArgs,
1190
- toolResult,
1191
- );
1192
- } catch (toolError) {
1193
- const errorMessage =
1194
- toolError instanceof Error
1195
- ? toolError.message
1196
- : String(toolError);
1197
-
1198
- this.messageManager.updateToolBlock({
1199
- id: toolId,
1200
- parameters: JSON.stringify(toolArgs, null, 2),
1201
- result: `Tool execution failed: ${errorMessage}`,
1202
- success: false,
1203
- error: errorMessage,
1204
- stage: "end",
1205
- name: toolName,
1206
- compactParams,
1207
- isManuallyBackgrounded: false,
1208
- timestamp: Date.now(),
1209
- });
1210
- }
1211
- },
1212
- );
1213
-
1214
- // Wait for all tools to complete execution in parallel
1215
- await Promise.all(toolExecutionPromises);
1067
+ } else {
1068
+ await Promise.all(
1069
+ batch.calls.map((call) =>
1070
+ this.executeToolCall(
1071
+ call,
1072
+ abortController,
1073
+ toolAbortController,
1074
+ result.finish_reason,
1075
+ ),
1076
+ ),
1077
+ );
1078
+ }
1079
+ }
1216
1080
  }
1217
1081
 
1218
1082
  // Handle token statistics and message compaction
@@ -1584,6 +1448,206 @@ export class AIManager {
1584
1448
  }
1585
1449
  }
1586
1450
 
1451
+ /**
1452
+ * Execute a single tool call: arg parsing, block emission, PreToolUse hooks,
1453
+ * tool execution, PostToolUse hooks, and error handling.
1454
+ */
1455
+ private async executeToolCall(
1456
+ functionToolCall: ChatCompletionMessageFunctionToolCall,
1457
+ abortController: AbortController,
1458
+ toolAbortController: AbortController,
1459
+ finishReason?: string | null,
1460
+ ): Promise<void> {
1461
+ const toolId = functionToolCall.id || "";
1462
+
1463
+ // Check if already interrupted, skip tool execution if so
1464
+ if (abortController.signal.aborted || toolAbortController.signal.aborted) {
1465
+ return;
1466
+ }
1467
+
1468
+ const toolName = functionToolCall.function?.name || "";
1469
+ // Safely parse tool parameters, handle tools without parameters
1470
+ let toolArgs: Record<string, unknown> = {};
1471
+ let jsonRecovered = false;
1472
+ const argsString = functionToolCall.function?.arguments?.trim();
1473
+
1474
+ if (!argsString || argsString === "") {
1475
+ // Tool without parameters, use empty object
1476
+ toolArgs = {};
1477
+ } else {
1478
+ let recoveredArgs = argsString;
1479
+ try {
1480
+ toolArgs = JSON.parse(argsString);
1481
+ } catch {
1482
+ // Attempt to recover truncated JSON (e.g., missing closing braces)
1483
+ recoveredArgs = recoverTruncatedJson(argsString);
1484
+ try {
1485
+ toolArgs = JSON.parse(recoveredArgs);
1486
+ jsonRecovered = true;
1487
+ logger.warn(`Recovered truncated JSON for tool "${toolName}"`);
1488
+ } catch (parseError) {
1489
+ let errorMessage = `Failed to parse tool arguments`;
1490
+ if (finishReason === "length") {
1491
+ errorMessage += " (output truncated, please reduce your output)";
1492
+ }
1493
+ logger?.error(errorMessage, parseError);
1494
+ this.messageManager.updateToolBlock({
1495
+ id: toolId,
1496
+ parameters: argsString,
1497
+ result: errorMessage,
1498
+ success: false,
1499
+ error: errorMessage,
1500
+ stage: "end",
1501
+ name: toolName,
1502
+ compactParams: "",
1503
+ timestamp: Date.now(),
1504
+ });
1505
+ return;
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ const compactParams = this.generateCompactParams(toolName, toolArgs);
1511
+
1512
+ // Emit start stage for non-streaming tool calls
1513
+ if (!this.stream) {
1514
+ this.messageManager.updateToolBlock({
1515
+ id: toolId,
1516
+ stage: "start",
1517
+ name: toolName,
1518
+ compactParams,
1519
+ parameters: argsString,
1520
+ });
1521
+ }
1522
+
1523
+ // Emit running stage (tool execution about to start)
1524
+ this.messageManager.updateToolBlock({
1525
+ id: toolId,
1526
+ stage: "running",
1527
+ name: toolName,
1528
+ compactParams,
1529
+ parameters: argsString,
1530
+ parametersChunk: "",
1531
+ });
1532
+
1533
+ try {
1534
+ // Execute PreToolUse hooks before tool execution
1535
+ const shouldExecuteTool = await this.executePreToolUseHooks(
1536
+ toolName,
1537
+ toolArgs,
1538
+ toolId,
1539
+ );
1540
+
1541
+ // If PreToolUse hooks blocked execution, skip tool execution
1542
+ if (!shouldExecuteTool) {
1543
+ logger?.info(`Tool ${toolName} execution blocked by PreToolUse hooks`);
1544
+ return;
1545
+ }
1546
+
1547
+ // Create tool execution context
1548
+ const context: ToolContext = {
1549
+ abortSignal: toolAbortController.signal,
1550
+ backgroundTaskManager: this.backgroundTaskManager,
1551
+ workdir: this.getWorkdir(),
1552
+ originalWorkdir: this.originalWorkdir,
1553
+ messageId: this.messageManager.getMessages().slice(-1)[0]?.id,
1554
+ sessionId: this.messageManager.getSessionId(),
1555
+ toolCallId: toolId,
1556
+ taskManager: this.taskManager,
1557
+ readFileState: this.readFileState,
1558
+ onShortResultUpdate: (shortResult: string) => {
1559
+ this.messageManager.updateToolBlock({
1560
+ id: toolId,
1561
+ shortResult,
1562
+ stage: "running",
1563
+ });
1564
+ },
1565
+ onResultUpdate: (result: string) => {
1566
+ this.messageManager.updateToolBlock({
1567
+ id: toolId,
1568
+ result,
1569
+ stage: "running",
1570
+ });
1571
+ },
1572
+ onCwdChange: async (newCwd: string) => {
1573
+ const oldCwd = this.getWorkdir();
1574
+ this.container.register("Workdir", newCwd);
1575
+ this._onCwdChange?.(newCwd);
1576
+ if (this.hookManager) {
1577
+ const sessionId = this.messageManager.getSessionId();
1578
+ const transcriptPath = this.messageManager.getTranscriptPath();
1579
+ const env = Object.fromEntries(
1580
+ Object.entries(process.env).filter((e) => e[1] !== undefined),
1581
+ ) as Record<string, string>;
1582
+ await this.hookManager.executeCwdChangedHooks(
1583
+ oldCwd,
1584
+ newCwd,
1585
+ sessionId,
1586
+ transcriptPath,
1587
+ env,
1588
+ );
1589
+ }
1590
+ },
1591
+ };
1592
+
1593
+ // Execute tool
1594
+ const toolResult = await this.toolManager.execute(
1595
+ functionToolCall.function?.name || "",
1596
+ toolArgs,
1597
+ context,
1598
+ );
1599
+
1600
+ // Build result content, adding truncation warning if JSON was recovered
1601
+ let toolResultContent =
1602
+ toolResult.content ||
1603
+ (toolResult.error ? `Error: ${toolResult.error}` : "");
1604
+ if (jsonRecovered) {
1605
+ toolResultContent +=
1606
+ "\n\nTool arguments were truncated (likely exceeded max output tokens). Please reduce your output or split into multiple tool calls.";
1607
+ }
1608
+
1609
+ // Update message state - tool execution completed
1610
+ this.messageManager.updateToolBlock({
1611
+ id: toolId,
1612
+ parameters: argsString,
1613
+ result: toolResultContent,
1614
+ success: toolResult.success,
1615
+ error: toolResult.error,
1616
+ stage: "end",
1617
+ name: toolName,
1618
+ compactParams,
1619
+ shortResult: toolResult.shortResult,
1620
+ isManuallyBackgrounded: toolResult.isManuallyBackgrounded,
1621
+ startLineNumber: toolResult.startLineNumber,
1622
+ timestamp: Date.now(),
1623
+ });
1624
+
1625
+ // Execute PostToolUse hooks after successful tool completion
1626
+ await this.executePostToolUseHooks(
1627
+ toolId,
1628
+ toolName,
1629
+ toolArgs,
1630
+ toolResult,
1631
+ );
1632
+ } catch (toolError) {
1633
+ const errorMessage =
1634
+ toolError instanceof Error ? toolError.message : String(toolError);
1635
+
1636
+ this.messageManager.updateToolBlock({
1637
+ id: toolId,
1638
+ parameters: JSON.stringify(toolArgs, null, 2),
1639
+ result: `Tool execution failed: ${errorMessage}`,
1640
+ success: false,
1641
+ error: errorMessage,
1642
+ stage: "end",
1643
+ name: toolName,
1644
+ compactParams,
1645
+ isManuallyBackgrounded: false,
1646
+ timestamp: Date.now(),
1647
+ });
1648
+ }
1649
+ }
1650
+
1587
1651
  /**
1588
1652
  * Execute PreToolUse hooks before tool execution
1589
1653
  * Returns true if hooks allow tool execution, false if blocked
@@ -1682,9 +1746,9 @@ export class AIManager {
1682
1746
  toolResponse,
1683
1747
  subagentType: this.subagentType, // Include subagent type in hook context
1684
1748
  planFilePath: this.permissionManager?.getPlanFilePath(),
1685
- env:
1686
- this.container.get<Record<string, string>>("MergedEnv") ||
1687
- (process.env as Record<string, string>),
1749
+ env: Object.fromEntries(
1750
+ Object.entries(process.env).filter((e) => e[1] !== undefined),
1751
+ ) as Record<string, string>, // Include environment variables
1688
1752
  };
1689
1753
 
1690
1754
  const results = await this.hookManager.executeHooks(