wave-agent-sdk 0.18.3 → 0.18.4

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 (53) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +0 -2
  3. package/dist/managers/MemoryRuleManager.d.ts +9 -0
  4. package/dist/managers/MemoryRuleManager.d.ts.map +1 -1
  5. package/dist/managers/MemoryRuleManager.js +18 -0
  6. package/dist/managers/aiManager.d.ts +1 -1
  7. package/dist/managers/aiManager.d.ts.map +1 -1
  8. package/dist/managers/aiManager.js +370 -345
  9. package/dist/managers/messageManager.d.ts +32 -13
  10. package/dist/managers/messageManager.d.ts.map +1 -1
  11. package/dist/managers/messageManager.js +82 -76
  12. package/dist/prompts/index.d.ts +10 -4
  13. package/dist/prompts/index.d.ts.map +1 -1
  14. package/dist/prompts/index.js +21 -20
  15. package/dist/services/aiService.d.ts +2 -1
  16. package/dist/services/aiService.d.ts.map +1 -1
  17. package/dist/services/aiService.js +37 -10
  18. package/dist/services/memory.d.ts +1 -0
  19. package/dist/services/memory.d.ts.map +1 -1
  20. package/dist/services/memory.js +35 -17
  21. package/dist/tools/agentTool.d.ts.map +1 -1
  22. package/dist/tools/agentTool.js +24 -2
  23. package/dist/tools/editTool.js +3 -3
  24. package/dist/tools/readTool.js +2 -2
  25. package/dist/tools/writeTool.js +2 -2
  26. package/dist/utils/containerSetup.d.ts.map +1 -1
  27. package/dist/utils/containerSetup.js +1 -0
  28. package/dist/utils/openaiClient.d.ts.map +1 -1
  29. package/dist/utils/openaiClient.js +28 -7
  30. package/dist/utils/taskReminder.d.ts +2 -3
  31. package/dist/utils/taskReminder.d.ts.map +1 -1
  32. package/dist/utils/taskReminder.js +7 -18
  33. package/package.json +1 -1
  34. package/scripts/install_ripgrep.js +21 -1
  35. package/src/agent.ts +0 -5
  36. package/src/managers/MemoryRuleManager.ts +23 -0
  37. package/src/managers/aiManager.ts +473 -434
  38. package/src/managers/messageManager.ts +99 -82
  39. package/src/prompts/index.ts +33 -24
  40. package/src/services/aiService.ts +39 -12
  41. package/src/services/memory.ts +34 -16
  42. package/src/tools/agentTool.ts +24 -2
  43. package/src/tools/editTool.ts +3 -3
  44. package/src/tools/readTool.ts +2 -2
  45. package/src/tools/writeTool.ts +2 -2
  46. package/src/utils/containerSetup.ts +1 -0
  47. package/src/utils/openaiClient.ts +35 -7
  48. package/src/utils/taskReminder.ts +7 -19
  49. package/vendor/ripgrep/linux-aarch64/rg +0 -0
  50. package/vendor/ripgrep/macos-aarch64/rg +0 -0
  51. package/vendor/ripgrep/macos-x86_64/rg +0 -0
  52. package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
  53. package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
@@ -21,7 +21,7 @@ import type { ToolContext, ToolResult } from "../tools/types.js";
21
21
  import type { MessageManager } from "./messageManager.js";
22
22
  import type { BackgroundTaskManager } from "./backgroundTaskManager.js";
23
23
  import { ChatCompletionMessageFunctionToolCall } from "openai/resources.js";
24
- import type { ChatCompletionMessageParam } from "openai/resources.js";
24
+
25
25
  import type { HookManager } from "./hookManager.js";
26
26
  import type { ExtendedHookExecutionContext } from "../types/hooks.js";
27
27
  import type { PermissionManager } from "./permissionManager.js";
@@ -297,12 +297,11 @@ export class AIManager {
297
297
  }
298
298
  }
299
299
 
300
- private async maybeInjectTaskReminderIntoMessages(
301
- messages: ChatCompletionMessageParam[],
300
+ private async maybeGetTaskReminderText(
302
301
  toolNames: Set<string>,
303
- ): Promise<void> {
302
+ ): Promise<string | null> {
304
303
  // Guard: no task tools available
305
- if (!toolNames.has("TaskUpdate")) return;
304
+ if (!toolNames.has("TaskUpdate")) return null;
306
305
 
307
306
  const internalMessages = this.messageManager.getMessages();
308
307
  const turnCounts = getTaskReminderTurnCounts(internalMessages);
@@ -313,11 +312,11 @@ export class AIManager {
313
312
  turnCounts.turnsSinceLastReminder <
314
313
  TASK_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
315
314
  ) {
316
- return;
315
+ return null;
317
316
  }
318
317
 
319
318
  const tasks = await this.taskManager.listTasks();
320
- maybeInjectTaskReminder(messages, turnCounts, tasks);
319
+ return maybeInjectTaskReminder(turnCounts, tasks);
321
320
  }
322
321
 
323
322
  public setIsLoading(isLoading: boolean): void {
@@ -743,481 +742,531 @@ export class AIManager {
743
742
  } = {},
744
743
  ): Promise<void> {
745
744
  const { recursionDepth = 0, model, allowedRules, maxTokens } = options;
745
+ let turnOffset = recursionDepth;
746
746
 
747
- // OpenTelemetry: start interaction span for this turn (initial call only)
748
- let turnSequence = 0;
749
- if (recursionDepth === 0) {
750
- const messages = this.messageManager.getMessages();
751
- turnSequence = messages.filter((m) => m.role === "user").length;
752
- const lastUserMessage = [...messages]
753
- .reverse()
754
- .find((m) => m.role === "user");
755
- const userPromptText =
756
- lastUserMessage?.blocks.find((b) => b.type === "text")?.content || "";
757
- startInteractionSpan(userPromptText, turnSequence);
758
-
759
- // Log user_prompt event
760
- logOTelEvent("user_prompt", {
761
- prompt_length: String(userPromptText.length),
762
- }).catch(() => {}); // Non-blocking
763
- }
747
+ outer: while (true) {
748
+ let shouldRestart = false;
749
+
750
+ // OpenTelemetry: start interaction span for this turn (initial call only)
751
+ let turnSequence = 0;
752
+ if (turnOffset === 0) {
753
+ const messages = this.messageManager.getMessages();
754
+ turnSequence = messages.filter((m) => m.role === "user").length;
755
+ const lastUserMessage = [...messages]
756
+ .reverse()
757
+ .find((m) => m.role === "user");
758
+ const userPromptText =
759
+ lastUserMessage?.blocks.find((b) => b.type === "text")?.content || "";
760
+ startInteractionSpan(userPromptText, turnSequence);
761
+
762
+ // Log user_prompt event
763
+ logOTelEvent("user_prompt", {
764
+ prompt_length: String(userPromptText.length),
765
+ }).catch(() => {}); // Non-blocking
766
+ }
764
767
 
765
- // Set loading state early for the initial call, before any async work
766
- if (recursionDepth === 0) {
767
- this.setIsLoading(true);
768
- if (allowedRules && allowedRules.length > 0) {
769
- this.permissionManager?.addTemporaryRules(allowedRules);
768
+ // Set loading state early for the initial call, before any async work
769
+ if (turnOffset === 0) {
770
+ this.setIsLoading(true);
771
+ if (allowedRules && allowedRules.length > 0) {
772
+ this.permissionManager?.addTemporaryRules(allowedRules);
773
+ }
770
774
  }
771
- }
772
775
 
773
- // Scan for file mentions in the last user message
774
- if (recursionDepth === 0) {
775
- const messages = this.messageManager.getMessages();
776
- const lastMessage = messages[messages.length - 1];
777
- if (lastMessage && lastMessage.role === "user") {
778
- for (const block of lastMessage.blocks) {
779
- if (block.type === "text") {
780
- const content = block.content;
781
- const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
782
- let match;
783
- while ((match = fileMentionRegex.exec(content)) !== null) {
784
- const filePath = match[1];
785
- this.messageManager.touchFile(filePath);
776
+ // Scan for file mentions in the last user message to trigger conditional rules
777
+ if (turnOffset === 0) {
778
+ const messages = this.messageManager.getMessages();
779
+ const lastMessage = messages[messages.length - 1];
780
+ if (lastMessage && lastMessage.role === "user") {
781
+ for (const block of lastMessage.blocks) {
782
+ if (block.type === "text") {
783
+ const content = block.content;
784
+ const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
785
+ let match;
786
+ while ((match = fileMentionRegex.exec(content)) !== null) {
787
+ const filePath = match[1];
788
+ this.messageManager.triggerFileRead(filePath);
789
+ }
786
790
  }
787
791
  }
788
792
  }
789
793
  }
790
- }
791
-
792
- // Save session in each recursion to ensure message persistence
793
- await this.messageManager.saveSession();
794
794
 
795
- // Only create new AbortControllers for the initial call (recursionDepth === 0)
796
- // For recursive calls, reuse existing controllers to maintain abort signal
797
- let abortController: AbortController;
798
- let toolAbortController: AbortController;
795
+ // Only create new AbortControllers for the initial call (turnOffset === 0)
796
+ // For restarts, reuse existing controllers to maintain abort signal
797
+ let abortController: AbortController;
798
+ let toolAbortController: AbortController;
799
799
 
800
- if (recursionDepth === 0) {
801
- // Create new AbortControllers for initial call
802
- abortController = new AbortController();
803
- this.abortController = abortController;
800
+ if (turnOffset === 0) {
801
+ // Create new AbortControllers for initial call
802
+ abortController = new AbortController();
803
+ this.abortController = abortController;
804
804
 
805
- toolAbortController = new AbortController();
806
- this.toolAbortController = toolAbortController;
807
- } else {
808
- // Reuse existing controllers for recursive calls
809
- abortController = this.abortController!;
810
- toolAbortController = this.toolAbortController!;
811
- }
812
-
813
- // Get current permission mode
814
- const currentMode = this.permissionManager?.getCurrentEffectiveMode(
815
- this.getModelConfig().permissionMode,
816
- );
805
+ toolAbortController = new AbortController();
806
+ this.toolAbortController = toolAbortController;
807
+ } else {
808
+ // Reuse existing controllers
809
+ abortController = this.abortController!;
810
+ toolAbortController = this.toolAbortController!;
811
+ }
817
812
 
818
- // Add plan mode reminder as persistent meta message before getting messages
819
- this.maybeAddPlanModeMessage(currentMode);
813
+ let turnDepth = turnOffset;
820
814
 
821
- // Get recent message history
822
- const rawMessages = this.messageManager.getMessages();
823
- const recentMessages = convertMessagesForAPI(rawMessages);
815
+ inner: while (true) {
816
+ try {
817
+ // Save session in each iteration to ensure message persistence
818
+ await this.messageManager.saveSession();
824
819
 
825
- try {
826
- // Get combined memory content
827
- const combinedMemory = await this.messageManager.getCombinedMemory();
820
+ // Get current permission mode
821
+ const currentMode = this.permissionManager?.getCurrentEffectiveMode(
822
+ this.getModelConfig().permissionMode,
823
+ );
828
824
 
829
- // Track if assistant message has been created
830
- let assistantMessageCreated = false;
825
+ // Add plan mode reminder as persistent meta message before getting messages
826
+ this.maybeAddPlanModeMessage(currentMode);
831
827
 
832
- logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
828
+ // Process conditional rules triggered by file reads (persist as meta messages)
829
+ const triggeredRules = this.messageManager.processTriggeredRules();
830
+ for (const rule of triggeredRules) {
831
+ this.messageManager.addUserMessage({
832
+ 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>`,
833
+ isMeta: true,
834
+ });
835
+ }
833
836
 
834
- const toolsConfig = this.getFilteredToolsConfig();
835
- const toolNames = new Set(toolsConfig.map((t) => t.function.name));
836
- const filteredToolPlugins = this.toolManager
837
- .getTools()
838
- .filter((t) => toolNames.has(t.name));
837
+ // Get recent message history
838
+ const rawMessages = this.messageManager.getMessages();
839
+ const recentMessages = convertMessagesForAPI(rawMessages);
839
840
 
840
- let autoMemoryOptions: { directory: string; content: string } | undefined;
841
+ // Track if assistant message has been created
842
+ let assistantMessageCreated = false;
841
843
 
842
- if (this.getAutoMemoryEnabled()) {
843
- const directory = this.memoryService.getAutoMemoryDirectory(
844
- this.getWorkdir(),
845
- );
846
- const content = await this.memoryService.getAutoMemoryContent(
847
- this.getWorkdir(),
848
- );
849
- autoMemoryOptions = { directory, content };
850
- }
844
+ logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
851
845
 
852
- // Call AI service with streaming callbacks if enabled
853
- const callAgentOptions: CallAgentOptions = {
854
- gatewayConfig: this.getGatewayConfig(),
855
- modelConfig: this.getModelConfig(),
856
- messages: recentMessages,
857
- sessionId: this.messageManager.getSessionId(),
858
- abortSignal: abortController.signal,
859
- workdir: this.getWorkdir(), // Pass working directory
860
- tools: toolsConfig, // Pass filtered tool configuration
861
- model: model, // Use passed model
862
- systemPrompt: buildSystemPrompt(
863
- this.systemPrompt,
864
- filteredToolPlugins,
865
- {
866
- workdir: this.getWorkdir(),
867
- originalWorkdir: this.getOriginalWorkdir(),
868
- memory: combinedMemory,
869
- language: this.getLanguage(),
870
- isSubagent: !!this.subagentType,
871
- autoMemory: autoMemoryOptions,
872
- permissionMode: currentMode,
873
- },
874
- ), // Pass custom system prompt
875
- maxTokens: maxTokens, // Pass max tokens override
876
- toolChoice: this.toolChoiceOverride, // Pass tool_choice override
877
- };
846
+ const toolsConfig = this.getFilteredToolsConfig();
847
+ const toolNames = new Set(toolsConfig.map((t) => t.function.name));
848
+ const filteredToolPlugins = this.toolManager
849
+ .getTools()
850
+ .filter((t) => toolNames.has(t.name));
878
851
 
879
- // Inject task reminder if needed (not persisted, regenerated each turn)
880
- await this.maybeInjectTaskReminderIntoMessages(
881
- callAgentOptions.messages,
882
- toolNames,
883
- );
852
+ let autoMemoryOptions:
853
+ | { directory: string; content: string }
854
+ | undefined;
884
855
 
885
- // Add streaming callbacks only if streaming is enabled
886
- if (this.stream) {
887
- callAgentOptions.onContentUpdate = (content: string) => {
888
- // Create assistant message on first chunk if not already created
889
- if (!assistantMessageCreated) {
890
- this.messageManager.addAssistantMessage();
891
- assistantMessageCreated = true;
892
- }
893
- this.messageManager.updateCurrentMessageContent(content);
894
- };
895
- callAgentOptions.onToolUpdate = (toolCall) => {
896
- // Create assistant message on first tool update if not already created
897
- if (!assistantMessageCreated) {
898
- this.messageManager.addAssistantMessage();
899
- assistantMessageCreated = true;
856
+ if (this.getAutoMemoryEnabled()) {
857
+ const directory = this.memoryService.getAutoMemoryDirectory(
858
+ this.getWorkdir(),
859
+ );
860
+ const content = await this.memoryService.getAutoMemoryContent(
861
+ this.getWorkdir(),
862
+ );
863
+ autoMemoryOptions = { directory, content };
900
864
  }
901
865
 
902
- // Use parametersChunk as compact param for better performance
903
- // No need to extract params or generate compact params during streaming
904
-
905
- // Update tool block with streaming parameters using parametersChunk as compact param
906
- this.messageManager.updateToolBlock({
907
- id: toolCall.id,
908
- name: toolCall.name,
909
- parameters: toolCall.parameters,
910
- parametersChunk: toolCall.parametersChunk,
911
- stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
912
- });
913
- };
914
- callAgentOptions.onReasoningUpdate = (reasoning: string) => {
915
- // Create assistant message on first reasoning update if not already created
916
- if (!assistantMessageCreated) {
917
- this.messageManager.addAssistantMessage();
918
- assistantMessageCreated = true;
866
+ // Get memory for message-array injection (not system prompt)
867
+ const { prependContent } =
868
+ await this.messageManager.getMemoryForInjection();
869
+
870
+ // Call AI service with streaming callbacks if enabled
871
+ const callAgentOptions: CallAgentOptions = {
872
+ gatewayConfig: this.getGatewayConfig(),
873
+ modelConfig: this.getModelConfig(),
874
+ messages: recentMessages,
875
+ sessionId: this.messageManager.getSessionId(),
876
+ abortSignal: abortController.signal,
877
+ workdir: this.getWorkdir(), // Pass working directory
878
+ tools: toolsConfig, // Pass filtered tool configuration
879
+ model: model, // Use passed model
880
+ systemPrompt: buildSystemPrompt(
881
+ this.systemPrompt,
882
+ filteredToolPlugins,
883
+ {
884
+ workdir: this.getWorkdir(),
885
+ originalWorkdir: this.getOriginalWorkdir(),
886
+ language: this.getLanguage(),
887
+ isSubagent: !!this.subagentType,
888
+ autoMemory: autoMemoryOptions,
889
+ },
890
+ ), // Pass custom system prompt
891
+ maxTokens: maxTokens, // Pass max tokens override
892
+ toolChoice: this.toolChoiceOverride, // Pass tool_choice override
893
+ };
894
+
895
+ // Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
896
+ if (prependContent.trim()) {
897
+ callAgentOptions.messages.unshift({
898
+ role: "user",
899
+ content: `<system-reminder>\n${prependContent}\n</system-reminder>`,
900
+ });
919
901
  }
920
- this.messageManager.updateCurrentMessageReasoning(reasoning);
921
- };
922
- }
923
-
924
- startLLMRequestSpan(model || this.getModelConfig().model || "");
925
902
 
926
- const result = await aiService.callAgent(callAgentOptions);
927
-
928
- // End LLM span with usage data
929
- endLLMRequestSpan({
930
- model: model || this.getModelConfig().model || "",
931
- success: true,
932
- hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
933
- });
934
-
935
- const createdByStreaming = assistantMessageCreated;
936
-
937
- // For non-streaming mode, create assistant message after callAgent returns
938
- // Also create if streaming mode but no streaming callbacks were called (e.g., when content comes directly in result)
939
- if (
940
- !this.stream ||
941
- (!assistantMessageCreated &&
942
- (result.content || result.tool_calls || result.reasoning_content))
943
- ) {
944
- this.messageManager.addAssistantMessage();
945
- assistantMessageCreated = true;
946
- }
903
+ // Task reminder: persist as meta message (conditional rules already persisted above)
904
+ const taskReminderText =
905
+ await this.maybeGetTaskReminderText(toolNames);
906
+ if (taskReminderText) {
907
+ this.messageManager.addUserMessage({
908
+ content: taskReminderText,
909
+ isMeta: true,
910
+ });
911
+ }
947
912
 
948
- // Log finish reason and response headers if available
949
- if (result.finish_reason) {
950
- // Log warning headers when finish reason is length
951
- if (result.finish_reason === "length") {
952
- logger?.warn(
953
- "AI response truncated due to length limit. Response headers:",
954
- result.response_headers,
955
- );
956
- }
957
- }
913
+ // Add streaming callbacks only if streaming is enabled
914
+ if (this.stream) {
915
+ callAgentOptions.onContentUpdate = (content: string) => {
916
+ // Create assistant message on first chunk if not already created
917
+ if (!assistantMessageCreated) {
918
+ this.messageManager.addAssistantMessage();
919
+ assistantMessageCreated = true;
920
+ }
921
+ this.messageManager.updateCurrentMessageContent(content);
922
+ };
923
+ callAgentOptions.onToolUpdate = (toolCall) => {
924
+ // Create assistant message on first tool update if not already created
925
+ if (!assistantMessageCreated) {
926
+ this.messageManager.addAssistantMessage();
927
+ assistantMessageCreated = true;
928
+ }
958
929
 
959
- if (
960
- result.additionalFields &&
961
- Object.keys(result.additionalFields).length > 0
962
- ) {
963
- this.messageManager.mergeAssistantAdditionalFields(
964
- result.additionalFields,
965
- );
966
- }
930
+ // Use parametersChunk as compact param for better performance
931
+ // No need to extract params or generate compact params during streaming
967
932
 
968
- // Handle result reasoning content from non-streaming mode
969
- if (result.reasoning_content && !createdByStreaming) {
970
- this.messageManager.updateCurrentMessageReasoning(
971
- result.reasoning_content,
972
- );
973
- }
933
+ // Update tool block with streaming parameters using parametersChunk as compact param
934
+ this.messageManager.updateToolBlock({
935
+ id: toolCall.id,
936
+ name: toolCall.name,
937
+ parameters: toolCall.parameters,
938
+ parametersChunk: toolCall.parametersChunk,
939
+ stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
940
+ });
941
+ };
942
+ callAgentOptions.onReasoningUpdate = (reasoning: string) => {
943
+ // Create assistant message on first reasoning update if not already created
944
+ if (!assistantMessageCreated) {
945
+ this.messageManager.addAssistantMessage();
946
+ assistantMessageCreated = true;
947
+ }
948
+ this.messageManager.updateCurrentMessageReasoning(reasoning);
949
+ };
950
+ }
974
951
 
975
- // Handle result content from non-streaming mode
976
- if (result.content && !createdByStreaming) {
977
- this.messageManager.updateCurrentMessageContent(result.content);
978
- }
952
+ startLLMRequestSpan(model || this.getModelConfig().model || "");
979
953
 
980
- // Handle usage tracking for agent operations
981
- let usage: Usage | undefined;
982
- if (result.usage) {
983
- usage = {
984
- prompt_tokens: result.usage.prompt_tokens,
985
- completion_tokens: result.usage.completion_tokens,
986
- total_tokens: result.usage.total_tokens,
987
- model: model || this.getModelConfig().model,
988
- operation_type: "agent",
989
- // Preserve cache fields if present
990
- ...(result.usage.cache_read_input_tokens !== undefined && {
991
- cache_read_input_tokens: result.usage.cache_read_input_tokens,
992
- }),
993
- ...(result.usage.cache_creation_input_tokens !== undefined && {
994
- cache_creation_input_tokens:
995
- result.usage.cache_creation_input_tokens,
996
- }),
997
- ...(result.usage.cache_creation && {
998
- cache_creation: result.usage.cache_creation,
999
- }),
1000
- };
1001
- }
954
+ const result = await aiService.callAgent(callAgentOptions);
1002
955
 
1003
- // Set usage on the assistant message if available
1004
- if (usage) {
1005
- const messages = this.messageManager.getMessages();
1006
- const lastMessage = messages[messages.length - 1];
1007
- if (lastMessage && lastMessage.role === "assistant") {
1008
- lastMessage.usage = usage;
1009
- this.messageManager.setMessages(messages);
1010
- }
956
+ // End LLM span with usage data
957
+ endLLMRequestSpan({
958
+ model: model || this.getModelConfig().model || "",
959
+ success: true,
960
+ hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
961
+ });
1011
962
 
1012
- // Notify Agent to add to usage tracking
1013
- if (this.callbacks?.onUsageAdded) {
1014
- this.callbacks.onUsageAdded(usage);
1015
- }
1016
- }
963
+ const createdByStreaming = assistantMessageCreated;
1017
964
 
1018
- // Collect tool calls for processing
1019
- const toolCalls: ChatCompletionMessageFunctionToolCall[] = [];
1020
- if (result.tool_calls) {
1021
- for (const toolCall of result.tool_calls) {
1022
- if (toolCall.type === "function") {
1023
- toolCalls.push(toolCall);
965
+ // For non-streaming mode, create assistant message after callAgent returns
966
+ // Also create if streaming mode but no streaming callbacks were called (e.g., when content comes directly in result)
967
+ if (
968
+ !this.stream ||
969
+ (!assistantMessageCreated &&
970
+ (result.content || result.tool_calls || result.reasoning_content))
971
+ ) {
972
+ this.messageManager.addAssistantMessage();
973
+ assistantMessageCreated = true;
1024
974
  }
1025
- }
1026
- }
1027
975
 
1028
- if (toolCalls.length > 0) {
1029
- // Partition tool calls into batches: consecutive concurrency-safe
1030
- // tools run in parallel, non-safe tools (Edit, Write, MCP) run one
1031
- // at a time to prevent read-modify-write races on the same file.
1032
- const batches: {
1033
- calls: ChatCompletionMessageFunctionToolCall[];
1034
- safe: boolean;
1035
- }[] = [];
1036
- for (const call of toolCalls) {
1037
- const toolName = call.function?.name || "";
1038
- const safe = this.toolManager.isConcurrencySafe(toolName);
1039
- const lastBatch = batches[batches.length - 1];
1040
- if (lastBatch && lastBatch.safe && safe) {
1041
- lastBatch.calls.push(call);
1042
- } else {
1043
- batches.push({ calls: [call], safe });
976
+ // Log finish reason and response headers if available
977
+ if (result.finish_reason) {
978
+ // Log warning headers when finish reason is length
979
+ if (result.finish_reason === "length") {
980
+ logger?.warn(
981
+ "AI response truncated due to length limit. Response headers:",
982
+ result.response_headers,
983
+ );
984
+ }
1044
985
  }
1045
- }
1046
986
 
1047
- // Execute batches sequentially; within a safe batch, tools run in parallel
1048
- for (const batch of batches) {
1049
987
  if (
1050
- abortController.signal.aborted ||
1051
- toolAbortController.signal.aborted
988
+ result.additionalFields &&
989
+ Object.keys(result.additionalFields).length > 0
1052
990
  ) {
1053
- break;
1054
- }
1055
- if (batch.calls.length === 1) {
1056
- await this.executeToolCall(
1057
- batch.calls[0],
1058
- abortController,
1059
- toolAbortController,
1060
- result.finish_reason,
991
+ this.messageManager.mergeAssistantAdditionalFields(
992
+ result.additionalFields,
1061
993
  );
1062
- } else {
1063
- await Promise.all(
1064
- batch.calls.map((call) =>
1065
- this.executeToolCall(
1066
- call,
1067
- abortController,
1068
- toolAbortController,
1069
- result.finish_reason,
1070
- ),
1071
- ),
994
+ }
995
+
996
+ // Handle result reasoning content from non-streaming mode
997
+ if (result.reasoning_content && !createdByStreaming) {
998
+ this.messageManager.updateCurrentMessageReasoning(
999
+ result.reasoning_content,
1072
1000
  );
1073
1001
  }
1074
- }
1075
- }
1076
1002
 
1077
- // Handle token statistics and message compaction
1078
- await this.handleTokenUsageAndCompaction(result.usage, abortController);
1003
+ // Handle result content from non-streaming mode
1004
+ if (result.content && !createdByStreaming) {
1005
+ this.messageManager.updateCurrentMessageContent(result.content);
1006
+ }
1079
1007
 
1080
- // Finalize text/reasoning blocks for the final response (no tools)
1081
- this.messageManager.finalizeStreamingBlocks();
1008
+ // Handle usage tracking for agent operations
1009
+ let usage: Usage | undefined;
1010
+ if (result.usage) {
1011
+ usage = {
1012
+ prompt_tokens: result.usage.prompt_tokens,
1013
+ completion_tokens: result.usage.completion_tokens,
1014
+ total_tokens: result.usage.total_tokens,
1015
+ model: model || this.getModelConfig().model,
1016
+ operation_type: "agent",
1017
+ // Preserve cache fields if present
1018
+ ...(result.usage.cache_read_input_tokens !== undefined && {
1019
+ cache_read_input_tokens: result.usage.cache_read_input_tokens,
1020
+ }),
1021
+ ...(result.usage.cache_creation_input_tokens !== undefined && {
1022
+ cache_creation_input_tokens:
1023
+ result.usage.cache_creation_input_tokens,
1024
+ }),
1025
+ ...(result.usage.cache_creation && {
1026
+ cache_creation: result.usage.cache_creation,
1027
+ }),
1028
+ };
1029
+ }
1082
1030
 
1083
- // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
1084
- if (toolCalls.length > 0 || result.finish_reason === "length") {
1085
- // Check maxTurns limit before recursing
1086
- if (this.maxTurns && recursionDepth + 1 >= this.maxTurns) {
1087
- logger?.debug(
1088
- `Max turns (${this.maxTurns}) reached, stopping recursion.`,
1089
- );
1090
- } else {
1091
- // Record committed snapshots to message history
1092
- if (this.reversionManager) {
1093
- const snapshots =
1094
- this.reversionManager.getAndClearCommittedSnapshots();
1095
- if (snapshots.length > 0) {
1096
- this.messageManager.addFileHistoryBlock(snapshots);
1031
+ // Set usage on the assistant message if available
1032
+ if (usage) {
1033
+ const messages = this.messageManager.getMessages();
1034
+ const lastMessage = messages[messages.length - 1];
1035
+ if (lastMessage && lastMessage.role === "assistant") {
1036
+ lastMessage.usage = usage;
1037
+ this.messageManager.setMessages(messages);
1038
+ }
1039
+
1040
+ // Notify Agent to add to usage tracking
1041
+ if (this.callbacks?.onUsageAdded) {
1042
+ this.callbacks.onUsageAdded(usage);
1097
1043
  }
1098
1044
  }
1099
1045
 
1100
- // Check interruption status
1101
- const isCurrentlyAborted =
1102
- abortController.signal.aborted ||
1103
- toolAbortController.signal.aborted;
1046
+ // Collect tool calls for processing
1047
+ const toolCalls: ChatCompletionMessageFunctionToolCall[] = [];
1048
+ if (result.tool_calls) {
1049
+ for (const toolCall of result.tool_calls) {
1050
+ if (toolCall.type === "function") {
1051
+ toolCalls.push(toolCall);
1052
+ }
1053
+ }
1054
+ }
1104
1055
 
1105
- // Check if all tools were manually backgrounded
1106
- const lastMessage =
1107
- this.messageManager.getMessages()[
1108
- this.messageManager.getMessages().length - 1
1109
- ];
1110
- const toolBlocks =
1111
- lastMessage?.blocks.filter(
1112
- (block): block is import("../types/messaging.js").ToolBlock =>
1113
- block.type === "tool",
1114
- ) || [];
1115
- const hasBackgrounded =
1116
- toolBlocks.length > 0 &&
1117
- toolBlocks.some((block) => block.isManuallyBackgrounded);
1118
-
1119
- if (hasBackgrounded) {
1120
- logger?.info(
1121
- "Some tools were manually backgrounded, stopping recursion.",
1122
- );
1123
- } else if (!isCurrentlyAborted) {
1124
- // If response was truncated, add a hidden continuation message
1125
- if (result.finish_reason === "length") {
1126
- this.messageManager.addUserMessage({
1127
- content:
1128
- "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.",
1129
- isMeta: true,
1130
- });
1056
+ if (toolCalls.length > 0) {
1057
+ // Partition tool calls into batches: consecutive concurrency-safe
1058
+ // tools run in parallel, non-safe tools (Edit, Write, MCP) run one
1059
+ // at a time to prevent read-modify-write races on the same file.
1060
+ const batches: {
1061
+ calls: ChatCompletionMessageFunctionToolCall[];
1062
+ safe: boolean;
1063
+ }[] = [];
1064
+ for (const call of toolCalls) {
1065
+ const toolName = call.function?.name || "";
1066
+ const safe = this.toolManager.isConcurrencySafe(toolName);
1067
+ const lastBatch = batches[batches.length - 1];
1068
+ if (lastBatch && lastBatch.safe && safe) {
1069
+ lastBatch.calls.push(call);
1070
+ } else {
1071
+ batches.push({ calls: [call], safe });
1072
+ }
1131
1073
  }
1132
1074
 
1133
- // Duplicate Tool Call Detection
1134
- if (toolCalls.length > 0) {
1135
- const messages = this.messageManager.getMessages();
1136
- // Find the most recent assistant message BEFORE the current one that has tool blocks
1137
- // The current assistant message is messages[messages.length - 1]
1138
- let previousAssistantWithTools: Message | undefined;
1139
- for (let i = messages.length - 2; i >= 0; i--) {
1140
- const msg = messages[i];
1141
- if (
1142
- msg.role === "assistant" &&
1143
- msg.blocks.some((b) => b.type === "tool")
1144
- ) {
1145
- previousAssistantWithTools = msg;
1146
- break;
1075
+ // Execute batches sequentially; within a safe batch, tools run in parallel
1076
+ for (const batch of batches) {
1077
+ if (
1078
+ abortController.signal.aborted ||
1079
+ toolAbortController.signal.aborted
1080
+ ) {
1081
+ break;
1082
+ }
1083
+ if (batch.calls.length === 1) {
1084
+ await this.executeToolCall(
1085
+ batch.calls[0],
1086
+ abortController,
1087
+ toolAbortController,
1088
+ result.finish_reason,
1089
+ );
1090
+ } else {
1091
+ await Promise.all(
1092
+ batch.calls.map((call) =>
1093
+ this.executeToolCall(
1094
+ call,
1095
+ abortController,
1096
+ toolAbortController,
1097
+ result.finish_reason,
1098
+ ),
1099
+ ),
1100
+ );
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ // Handle token statistics and message compaction
1106
+ await this.handleTokenUsageAndCompaction(
1107
+ result.usage,
1108
+ abortController,
1109
+ );
1110
+
1111
+ // Finalize text/reasoning blocks for the final response (no tools)
1112
+ this.messageManager.finalizeStreamingBlocks();
1113
+
1114
+ // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
1115
+ if (toolCalls.length > 0 || result.finish_reason === "length") {
1116
+ // Check maxTurns limit before continuing
1117
+ if (this.maxTurns && turnDepth + 1 >= this.maxTurns) {
1118
+ logger?.debug(`Max turns (${this.maxTurns}) reached, stopping.`);
1119
+ } else {
1120
+ // Record committed snapshots to message history
1121
+ if (this.reversionManager) {
1122
+ const snapshots =
1123
+ this.reversionManager.getAndClearCommittedSnapshots();
1124
+ if (snapshots.length > 0) {
1125
+ this.messageManager.addFileHistoryBlock(snapshots);
1147
1126
  }
1148
1127
  }
1149
1128
 
1150
- if (previousAssistantWithTools) {
1151
- const previousToolBlocks =
1152
- previousAssistantWithTools.blocks.filter(
1153
- (b): b is import("../types/messaging.js").ToolBlock =>
1154
- b.type === "tool",
1155
- );
1156
-
1157
- for (const currentToolCall of toolCalls) {
1158
- const currentName = currentToolCall.function?.name;
1159
- const currentArgs = currentToolCall.function?.arguments;
1160
-
1161
- const isDuplicate = previousToolBlocks.some(
1162
- (prevBlock) =>
1163
- prevBlock.name === currentName &&
1164
- prevBlock.parameters === currentArgs,
1165
- );
1166
-
1167
- if (isDuplicate && currentName) {
1168
- const toolId = currentToolCall.id;
1169
- const lastMessage = messages[messages.length - 1];
1170
- const toolBlock = lastMessage.blocks.find(
1171
- (b): b is import("../types/messaging.js").ToolBlock =>
1172
- b.type === "tool" && b.id === toolId,
1173
- );
1174
- if (toolBlock) {
1175
- 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.`;
1176
- this.messageManager.updateToolBlock({
1177
- id: toolId,
1178
- result: (toolBlock.result || "") + warning,
1179
- stage: "end",
1180
- });
1129
+ // Check interruption status
1130
+ const isCurrentlyAborted =
1131
+ abortController.signal.aborted ||
1132
+ toolAbortController.signal.aborted;
1133
+
1134
+ // Check if all tools were manually backgrounded
1135
+ const lastMessage =
1136
+ this.messageManager.getMessages()[
1137
+ this.messageManager.getMessages().length - 1
1138
+ ];
1139
+ const toolBlocks =
1140
+ lastMessage?.blocks.filter(
1141
+ (block): block is import("../types/messaging.js").ToolBlock =>
1142
+ block.type === "tool",
1143
+ ) || [];
1144
+ const hasBackgrounded =
1145
+ toolBlocks.length > 0 &&
1146
+ toolBlocks.some((block) => block.isManuallyBackgrounded);
1147
+
1148
+ if (hasBackgrounded) {
1149
+ logger?.info(
1150
+ "Some tools were manually backgrounded, stopping.",
1151
+ );
1152
+ } else if (!isCurrentlyAborted) {
1153
+ // If response was truncated, add a hidden continuation message
1154
+ if (result.finish_reason === "length") {
1155
+ this.messageManager.addUserMessage({
1156
+ content:
1157
+ "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.",
1158
+ isMeta: true,
1159
+ });
1160
+ }
1161
+
1162
+ // Duplicate Tool Call Detection
1163
+ if (toolCalls.length > 0) {
1164
+ const messages = this.messageManager.getMessages();
1165
+ // Find the most recent assistant message BEFORE the current one that has tool blocks
1166
+ // The current assistant message is messages[messages.length - 1]
1167
+ let previousAssistantWithTools: Message | undefined;
1168
+ for (let i = messages.length - 2; i >= 0; i--) {
1169
+ const msg = messages[i];
1170
+ if (
1171
+ msg.role === "assistant" &&
1172
+ msg.blocks.some((b) => b.type === "tool")
1173
+ ) {
1174
+ previousAssistantWithTools = msg;
1175
+ break;
1176
+ }
1177
+ }
1178
+
1179
+ if (previousAssistantWithTools) {
1180
+ const previousToolBlocks =
1181
+ previousAssistantWithTools.blocks.filter(
1182
+ (b): b is import("../types/messaging.js").ToolBlock =>
1183
+ b.type === "tool",
1184
+ );
1185
+
1186
+ for (const currentToolCall of toolCalls) {
1187
+ const currentName = currentToolCall.function?.name;
1188
+ const currentArgs = currentToolCall.function?.arguments;
1189
+
1190
+ const isDuplicate = previousToolBlocks.some(
1191
+ (prevBlock) =>
1192
+ prevBlock.name === currentName &&
1193
+ prevBlock.parameters === currentArgs,
1194
+ );
1195
+
1196
+ if (isDuplicate && currentName) {
1197
+ const toolId = currentToolCall.id;
1198
+ const lastMessage = messages[messages.length - 1];
1199
+ const toolBlock = lastMessage.blocks.find(
1200
+ (b): b is import("../types/messaging.js").ToolBlock =>
1201
+ b.type === "tool" && b.id === toolId,
1202
+ );
1203
+ if (toolBlock) {
1204
+ 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.`;
1205
+ this.messageManager.updateToolBlock({
1206
+ id: toolId,
1207
+ result: (toolBlock.result || "") + warning,
1208
+ stage: "end",
1209
+ });
1210
+ }
1211
+ }
1181
1212
  }
1182
1213
  }
1183
1214
  }
1215
+
1216
+ // Yield to the event loop so macrotasks (abort timers,
1217
+ // signals) can be processed between turns. Without this,
1218
+ // mocked async operations (microtasks) would starve the
1219
+ // event loop and abort timers would never fire.
1220
+ await new Promise((resolve) => setImmediate(resolve));
1221
+
1222
+ // Re-check abort status after yielding — the signal may
1223
+ // have fired during the setImmediate gap.
1224
+ if (
1225
+ abortController.signal.aborted ||
1226
+ toolAbortController.signal.aborted
1227
+ ) {
1228
+ break inner;
1229
+ }
1230
+
1231
+ turnDepth++;
1232
+ continue inner;
1184
1233
  }
1185
1234
  }
1186
-
1187
- // Recursively call AI service, increment recursion depth, and pass same configuration
1188
- await this.sendAIMessage({
1189
- recursionDepth: recursionDepth + 1,
1190
- model,
1191
- allowedRules,
1192
- maxTokens,
1193
- });
1194
1235
  }
1236
+
1237
+ // No tool calls (or stop conditions) → inner loop done
1238
+ break inner;
1239
+ } catch (error) {
1240
+ // End LLM span with error
1241
+ endLLMRequestSpan({
1242
+ model: model || this.getModelConfig().model || "",
1243
+ success: false,
1244
+ error: error instanceof Error ? error.message : String(error),
1245
+ });
1246
+
1247
+ // Log error event
1248
+ logOTelEvent("error", {
1249
+ error_type:
1250
+ error instanceof Error ? error.constructor.name : "Unknown",
1251
+ message: error instanceof Error ? error.message : String(error),
1252
+ }).catch(() => {}); // Non-blocking
1253
+
1254
+ this.messageManager.addErrorBlock(
1255
+ error instanceof Error ? error.message : "Unknown error occurred",
1256
+ );
1257
+
1258
+ // Exit inner loop on error
1259
+ break inner;
1195
1260
  }
1196
1261
  }
1197
- } catch (error) {
1198
- // End LLM span with error
1199
- endLLMRequestSpan({
1200
- model: model || this.getModelConfig().model || "",
1201
- success: false,
1202
- error: error instanceof Error ? error.message : String(error),
1203
- });
1204
-
1205
- // Log error event
1206
- logOTelEvent("error", {
1207
- error_type: error instanceof Error ? error.constructor.name : "Unknown",
1208
- message: error instanceof Error ? error.message : String(error),
1209
- }).catch(() => {}); // Non-blocking
1210
1262
 
1211
- this.messageManager.addErrorBlock(
1212
- error instanceof Error ? error.message : "Unknown error occurred",
1213
- );
1214
- } finally {
1263
+ // Finally-equivalent (runs once per outer iteration):
1215
1264
  // Only execute cleanup and hooks for the initial call
1216
- if (recursionDepth === 0) {
1265
+ if (turnOffset === 0) {
1217
1266
  // OpenTelemetry: end interaction span
1218
1267
  endInteractionSpan();
1219
1268
 
1220
- // Save session in each recursion to ensure message persistence
1269
+ // Save session in each iteration to ensure message persistence
1221
1270
  await this.messageManager.saveSession();
1222
1271
  // Set loading to false first
1223
1272
  this.setIsLoading(false);
@@ -1240,13 +1289,9 @@ export class AIManager {
1240
1289
  });
1241
1290
  }
1242
1291
  }
1243
- // Recursively process the notifications
1244
- await this.sendAIMessage({
1245
- recursionDepth: 0,
1246
- model,
1247
- allowedRules,
1248
- maxTokens,
1249
- });
1292
+ // Restart outer loop to process the notifications
1293
+ shouldRestart = true;
1294
+ turnOffset = 0;
1250
1295
  } else {
1251
1296
  // Clear temporary rules
1252
1297
  this.permissionManager?.clearTemporaryRules();
@@ -1317,12 +1362,9 @@ export class AIManager {
1317
1362
  // Keep loading state active to prevent UI flicker
1318
1363
  this.setIsLoading(true);
1319
1364
  goalContinuing = true;
1320
- await this.sendAIMessage({
1321
- recursionDepth: 0,
1322
- model,
1323
- allowedRules,
1324
- maxTokens,
1325
- });
1365
+ // Restart outer loop to continue goal pursuit
1366
+ shouldRestart = true;
1367
+ turnOffset = 0;
1326
1368
  }
1327
1369
  }
1328
1370
  }
@@ -1341,18 +1383,15 @@ export class AIManager {
1341
1383
  );
1342
1384
 
1343
1385
  // Restart the conversation to let AI fix the issues
1344
- // Use recursionDepth = 0 to set loading false again for continuation
1345
- await this.sendAIMessage({
1346
- recursionDepth: 0,
1347
- model,
1348
- allowedRules,
1349
- maxTokens,
1350
- });
1386
+ shouldRestart = true;
1387
+ turnOffset = 0;
1351
1388
  }
1352
1389
  }
1353
1390
  }
1354
1391
  }
1355
1392
  }
1393
+
1394
+ if (!shouldRestart) break outer;
1356
1395
  }
1357
1396
  }
1358
1397