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
@@ -165,20 +165,20 @@ export class AIManager {
165
165
  planMgr.consumePlanEntryReminder();
166
166
  }
167
167
  }
168
- async maybeInjectTaskReminderIntoMessages(messages, toolNames) {
168
+ async maybeGetTaskReminderText(toolNames) {
169
169
  // Guard: no task tools available
170
170
  if (!toolNames.has("TaskUpdate"))
171
- return;
171
+ return null;
172
172
  const internalMessages = this.messageManager.getMessages();
173
173
  const turnCounts = getTaskReminderTurnCounts(internalMessages);
174
174
  if (turnCounts.turnsSinceLastTaskManagement <
175
175
  TASK_REMINDER_CONFIG.TURNS_SINCE_WRITE ||
176
176
  turnCounts.turnsSinceLastReminder <
177
177
  TASK_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS) {
178
- return;
178
+ return null;
179
179
  }
180
180
  const tasks = await this.taskManager.listTasks();
181
- maybeInjectTaskReminder(messages, turnCounts, tasks);
181
+ return maybeInjectTaskReminder(turnCounts, tasks);
182
182
  }
183
183
  setIsLoading(isLoading) {
184
184
  this.isLoading = isLoading;
@@ -500,362 +500,397 @@ export class AIManager {
500
500
  }
501
501
  async sendAIMessage(options = {}) {
502
502
  const { recursionDepth = 0, model, allowedRules, maxTokens } = options;
503
- // OpenTelemetry: start interaction span for this turn (initial call only)
504
- let turnSequence = 0;
505
- if (recursionDepth === 0) {
506
- const messages = this.messageManager.getMessages();
507
- turnSequence = messages.filter((m) => m.role === "user").length;
508
- const lastUserMessage = [...messages]
509
- .reverse()
510
- .find((m) => m.role === "user");
511
- const userPromptText = lastUserMessage?.blocks.find((b) => b.type === "text")?.content || "";
512
- startInteractionSpan(userPromptText, turnSequence);
513
- // Log user_prompt event
514
- logOTelEvent("user_prompt", {
515
- prompt_length: String(userPromptText.length),
516
- }).catch(() => { }); // Non-blocking
517
- }
518
- // Set loading state early for the initial call, before any async work
519
- if (recursionDepth === 0) {
520
- this.setIsLoading(true);
521
- if (allowedRules && allowedRules.length > 0) {
522
- this.permissionManager?.addTemporaryRules(allowedRules);
503
+ let turnOffset = recursionDepth;
504
+ outer: while (true) {
505
+ let shouldRestart = false;
506
+ // OpenTelemetry: start interaction span for this turn (initial call only)
507
+ let turnSequence = 0;
508
+ if (turnOffset === 0) {
509
+ const messages = this.messageManager.getMessages();
510
+ turnSequence = messages.filter((m) => m.role === "user").length;
511
+ const lastUserMessage = [...messages]
512
+ .reverse()
513
+ .find((m) => m.role === "user");
514
+ const userPromptText = lastUserMessage?.blocks.find((b) => b.type === "text")?.content || "";
515
+ startInteractionSpan(userPromptText, turnSequence);
516
+ // Log user_prompt event
517
+ logOTelEvent("user_prompt", {
518
+ prompt_length: String(userPromptText.length),
519
+ }).catch(() => { }); // Non-blocking
523
520
  }
524
- }
525
- // Scan for file mentions in the last user message
526
- if (recursionDepth === 0) {
527
- const messages = this.messageManager.getMessages();
528
- const lastMessage = messages[messages.length - 1];
529
- if (lastMessage && lastMessage.role === "user") {
530
- for (const block of lastMessage.blocks) {
531
- if (block.type === "text") {
532
- const content = block.content;
533
- const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
534
- let match;
535
- while ((match = fileMentionRegex.exec(content)) !== null) {
536
- const filePath = match[1];
537
- this.messageManager.touchFile(filePath);
521
+ // Set loading state early for the initial call, before any async work
522
+ if (turnOffset === 0) {
523
+ this.setIsLoading(true);
524
+ if (allowedRules && allowedRules.length > 0) {
525
+ this.permissionManager?.addTemporaryRules(allowedRules);
526
+ }
527
+ }
528
+ // Scan for file mentions in the last user message to trigger conditional rules
529
+ if (turnOffset === 0) {
530
+ const messages = this.messageManager.getMessages();
531
+ const lastMessage = messages[messages.length - 1];
532
+ if (lastMessage && lastMessage.role === "user") {
533
+ for (const block of lastMessage.blocks) {
534
+ if (block.type === "text") {
535
+ const content = block.content;
536
+ const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
537
+ let match;
538
+ while ((match = fileMentionRegex.exec(content)) !== null) {
539
+ const filePath = match[1];
540
+ this.messageManager.triggerFileRead(filePath);
541
+ }
538
542
  }
539
543
  }
540
544
  }
541
545
  }
542
- }
543
- // Save session in each recursion to ensure message persistence
544
- await this.messageManager.saveSession();
545
- // Only create new AbortControllers for the initial call (recursionDepth === 0)
546
- // For recursive calls, reuse existing controllers to maintain abort signal
547
- let abortController;
548
- let toolAbortController;
549
- if (recursionDepth === 0) {
550
- // Create new AbortControllers for initial call
551
- abortController = new AbortController();
552
- this.abortController = abortController;
553
- toolAbortController = new AbortController();
554
- this.toolAbortController = toolAbortController;
555
- }
556
- else {
557
- // Reuse existing controllers for recursive calls
558
- abortController = this.abortController;
559
- toolAbortController = this.toolAbortController;
560
- }
561
- // Get current permission mode
562
- const currentMode = this.permissionManager?.getCurrentEffectiveMode(this.getModelConfig().permissionMode);
563
- // Add plan mode reminder as persistent meta message before getting messages
564
- this.maybeAddPlanModeMessage(currentMode);
565
- // Get recent message history
566
- const rawMessages = this.messageManager.getMessages();
567
- const recentMessages = convertMessagesForAPI(rawMessages);
568
- try {
569
- // Get combined memory content
570
- const combinedMemory = await this.messageManager.getCombinedMemory();
571
- // Track if assistant message has been created
572
- let assistantMessageCreated = false;
573
- logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
574
- const toolsConfig = this.getFilteredToolsConfig();
575
- const toolNames = new Set(toolsConfig.map((t) => t.function.name));
576
- const filteredToolPlugins = this.toolManager
577
- .getTools()
578
- .filter((t) => toolNames.has(t.name));
579
- let autoMemoryOptions;
580
- if (this.getAutoMemoryEnabled()) {
581
- const directory = this.memoryService.getAutoMemoryDirectory(this.getWorkdir());
582
- const content = await this.memoryService.getAutoMemoryContent(this.getWorkdir());
583
- autoMemoryOptions = { directory, content };
546
+ // Only create new AbortControllers for the initial call (turnOffset === 0)
547
+ // For restarts, reuse existing controllers to maintain abort signal
548
+ let abortController;
549
+ let toolAbortController;
550
+ if (turnOffset === 0) {
551
+ // Create new AbortControllers for initial call
552
+ abortController = new AbortController();
553
+ this.abortController = abortController;
554
+ toolAbortController = new AbortController();
555
+ this.toolAbortController = toolAbortController;
584
556
  }
585
- // Call AI service with streaming callbacks if enabled
586
- const callAgentOptions = {
587
- gatewayConfig: this.getGatewayConfig(),
588
- modelConfig: this.getModelConfig(),
589
- messages: recentMessages,
590
- sessionId: this.messageManager.getSessionId(),
591
- abortSignal: abortController.signal,
592
- workdir: this.getWorkdir(), // Pass working directory
593
- tools: toolsConfig, // Pass filtered tool configuration
594
- model: model, // Use passed model
595
- systemPrompt: buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
596
- workdir: this.getWorkdir(),
597
- originalWorkdir: this.getOriginalWorkdir(),
598
- memory: combinedMemory,
599
- language: this.getLanguage(),
600
- isSubagent: !!this.subagentType,
601
- autoMemory: autoMemoryOptions,
602
- permissionMode: currentMode,
603
- }), // Pass custom system prompt
604
- maxTokens: maxTokens, // Pass max tokens override
605
- toolChoice: this.toolChoiceOverride, // Pass tool_choice override
606
- };
607
- // Inject task reminder if needed (not persisted, regenerated each turn)
608
- await this.maybeInjectTaskReminderIntoMessages(callAgentOptions.messages, toolNames);
609
- // Add streaming callbacks only if streaming is enabled
610
- if (this.stream) {
611
- callAgentOptions.onContentUpdate = (content) => {
612
- // Create assistant message on first chunk if not already created
613
- if (!assistantMessageCreated) {
614
- this.messageManager.addAssistantMessage();
615
- assistantMessageCreated = true;
557
+ else {
558
+ // Reuse existing controllers
559
+ abortController = this.abortController;
560
+ toolAbortController = this.toolAbortController;
561
+ }
562
+ let turnDepth = turnOffset;
563
+ inner: while (true) {
564
+ try {
565
+ // Save session in each iteration to ensure message persistence
566
+ await this.messageManager.saveSession();
567
+ // Get current permission mode
568
+ const currentMode = this.permissionManager?.getCurrentEffectiveMode(this.getModelConfig().permissionMode);
569
+ // Add plan mode reminder as persistent meta message before getting messages
570
+ this.maybeAddPlanModeMessage(currentMode);
571
+ // Process conditional rules triggered by file reads (persist as meta messages)
572
+ const triggeredRules = this.messageManager.processTriggeredRules();
573
+ for (const rule of triggeredRules) {
574
+ this.messageManager.addUserMessage({
575
+ 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>`,
576
+ isMeta: true,
577
+ });
616
578
  }
617
- this.messageManager.updateCurrentMessageContent(content);
618
- };
619
- callAgentOptions.onToolUpdate = (toolCall) => {
620
- // Create assistant message on first tool update if not already created
621
- if (!assistantMessageCreated) {
622
- this.messageManager.addAssistantMessage();
623
- assistantMessageCreated = true;
579
+ // Get recent message history
580
+ const rawMessages = this.messageManager.getMessages();
581
+ const recentMessages = convertMessagesForAPI(rawMessages);
582
+ // Track if assistant message has been created
583
+ let assistantMessageCreated = false;
584
+ logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
585
+ const toolsConfig = this.getFilteredToolsConfig();
586
+ const toolNames = new Set(toolsConfig.map((t) => t.function.name));
587
+ const filteredToolPlugins = this.toolManager
588
+ .getTools()
589
+ .filter((t) => toolNames.has(t.name));
590
+ let autoMemoryOptions;
591
+ if (this.getAutoMemoryEnabled()) {
592
+ const directory = this.memoryService.getAutoMemoryDirectory(this.getWorkdir());
593
+ const content = await this.memoryService.getAutoMemoryContent(this.getWorkdir());
594
+ autoMemoryOptions = { directory, content };
624
595
  }
625
- // Use parametersChunk as compact param for better performance
626
- // No need to extract params or generate compact params during streaming
627
- // Update tool block with streaming parameters using parametersChunk as compact param
628
- this.messageManager.updateToolBlock({
629
- id: toolCall.id,
630
- name: toolCall.name,
631
- parameters: toolCall.parameters,
632
- parametersChunk: toolCall.parametersChunk,
633
- stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
596
+ // Get memory for message-array injection (not system prompt)
597
+ const { prependContent } = await this.messageManager.getMemoryForInjection();
598
+ // Call AI service with streaming callbacks if enabled
599
+ const callAgentOptions = {
600
+ gatewayConfig: this.getGatewayConfig(),
601
+ modelConfig: this.getModelConfig(),
602
+ messages: recentMessages,
603
+ sessionId: this.messageManager.getSessionId(),
604
+ abortSignal: abortController.signal,
605
+ workdir: this.getWorkdir(), // Pass working directory
606
+ tools: toolsConfig, // Pass filtered tool configuration
607
+ model: model, // Use passed model
608
+ systemPrompt: buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
609
+ workdir: this.getWorkdir(),
610
+ originalWorkdir: this.getOriginalWorkdir(),
611
+ language: this.getLanguage(),
612
+ isSubagent: !!this.subagentType,
613
+ autoMemory: autoMemoryOptions,
614
+ }), // Pass custom system prompt
615
+ maxTokens: maxTokens, // Pass max tokens override
616
+ toolChoice: this.toolChoiceOverride, // Pass tool_choice override
617
+ };
618
+ // Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
619
+ if (prependContent.trim()) {
620
+ callAgentOptions.messages.unshift({
621
+ role: "user",
622
+ content: `<system-reminder>\n${prependContent}\n</system-reminder>`,
623
+ });
624
+ }
625
+ // Task reminder: persist as meta message (conditional rules already persisted above)
626
+ const taskReminderText = await this.maybeGetTaskReminderText(toolNames);
627
+ if (taskReminderText) {
628
+ this.messageManager.addUserMessage({
629
+ content: taskReminderText,
630
+ isMeta: true,
631
+ });
632
+ }
633
+ // Add streaming callbacks only if streaming is enabled
634
+ if (this.stream) {
635
+ callAgentOptions.onContentUpdate = (content) => {
636
+ // Create assistant message on first chunk if not already created
637
+ if (!assistantMessageCreated) {
638
+ this.messageManager.addAssistantMessage();
639
+ assistantMessageCreated = true;
640
+ }
641
+ this.messageManager.updateCurrentMessageContent(content);
642
+ };
643
+ callAgentOptions.onToolUpdate = (toolCall) => {
644
+ // Create assistant message on first tool update if not already created
645
+ if (!assistantMessageCreated) {
646
+ this.messageManager.addAssistantMessage();
647
+ assistantMessageCreated = true;
648
+ }
649
+ // Use parametersChunk as compact param for better performance
650
+ // No need to extract params or generate compact params during streaming
651
+ // Update tool block with streaming parameters using parametersChunk as compact param
652
+ this.messageManager.updateToolBlock({
653
+ id: toolCall.id,
654
+ name: toolCall.name,
655
+ parameters: toolCall.parameters,
656
+ parametersChunk: toolCall.parametersChunk,
657
+ stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
658
+ });
659
+ };
660
+ callAgentOptions.onReasoningUpdate = (reasoning) => {
661
+ // Create assistant message on first reasoning update if not already created
662
+ if (!assistantMessageCreated) {
663
+ this.messageManager.addAssistantMessage();
664
+ assistantMessageCreated = true;
665
+ }
666
+ this.messageManager.updateCurrentMessageReasoning(reasoning);
667
+ };
668
+ }
669
+ startLLMRequestSpan(model || this.getModelConfig().model || "");
670
+ const result = await aiService.callAgent(callAgentOptions);
671
+ // End LLM span with usage data
672
+ endLLMRequestSpan({
673
+ model: model || this.getModelConfig().model || "",
674
+ success: true,
675
+ hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
634
676
  });
635
- };
636
- callAgentOptions.onReasoningUpdate = (reasoning) => {
637
- // Create assistant message on first reasoning update if not already created
638
- if (!assistantMessageCreated) {
677
+ const createdByStreaming = assistantMessageCreated;
678
+ // For non-streaming mode, create assistant message after callAgent returns
679
+ // Also create if streaming mode but no streaming callbacks were called (e.g., when content comes directly in result)
680
+ if (!this.stream ||
681
+ (!assistantMessageCreated &&
682
+ (result.content || result.tool_calls || result.reasoning_content))) {
639
683
  this.messageManager.addAssistantMessage();
640
684
  assistantMessageCreated = true;
641
685
  }
642
- this.messageManager.updateCurrentMessageReasoning(reasoning);
643
- };
644
- }
645
- startLLMRequestSpan(model || this.getModelConfig().model || "");
646
- const result = await aiService.callAgent(callAgentOptions);
647
- // End LLM span with usage data
648
- endLLMRequestSpan({
649
- model: model || this.getModelConfig().model || "",
650
- success: true,
651
- hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
652
- });
653
- const createdByStreaming = assistantMessageCreated;
654
- // For non-streaming mode, create assistant message after callAgent returns
655
- // Also create if streaming mode but no streaming callbacks were called (e.g., when content comes directly in result)
656
- if (!this.stream ||
657
- (!assistantMessageCreated &&
658
- (result.content || result.tool_calls || result.reasoning_content))) {
659
- this.messageManager.addAssistantMessage();
660
- assistantMessageCreated = true;
661
- }
662
- // Log finish reason and response headers if available
663
- if (result.finish_reason) {
664
- // Log warning headers when finish reason is length
665
- if (result.finish_reason === "length") {
666
- logger?.warn("AI response truncated due to length limit. Response headers:", result.response_headers);
667
- }
668
- }
669
- if (result.additionalFields &&
670
- Object.keys(result.additionalFields).length > 0) {
671
- this.messageManager.mergeAssistantAdditionalFields(result.additionalFields);
672
- }
673
- // Handle result reasoning content from non-streaming mode
674
- if (result.reasoning_content && !createdByStreaming) {
675
- this.messageManager.updateCurrentMessageReasoning(result.reasoning_content);
676
- }
677
- // Handle result content from non-streaming mode
678
- if (result.content && !createdByStreaming) {
679
- this.messageManager.updateCurrentMessageContent(result.content);
680
- }
681
- // Handle usage tracking for agent operations
682
- let usage;
683
- if (result.usage) {
684
- usage = {
685
- prompt_tokens: result.usage.prompt_tokens,
686
- completion_tokens: result.usage.completion_tokens,
687
- total_tokens: result.usage.total_tokens,
688
- model: model || this.getModelConfig().model,
689
- operation_type: "agent",
690
- // Preserve cache fields if present
691
- ...(result.usage.cache_read_input_tokens !== undefined && {
692
- cache_read_input_tokens: result.usage.cache_read_input_tokens,
693
- }),
694
- ...(result.usage.cache_creation_input_tokens !== undefined && {
695
- cache_creation_input_tokens: result.usage.cache_creation_input_tokens,
696
- }),
697
- ...(result.usage.cache_creation && {
698
- cache_creation: result.usage.cache_creation,
699
- }),
700
- };
701
- }
702
- // Set usage on the assistant message if available
703
- if (usage) {
704
- const messages = this.messageManager.getMessages();
705
- const lastMessage = messages[messages.length - 1];
706
- if (lastMessage && lastMessage.role === "assistant") {
707
- lastMessage.usage = usage;
708
- this.messageManager.setMessages(messages);
709
- }
710
- // Notify Agent to add to usage tracking
711
- if (this.callbacks?.onUsageAdded) {
712
- this.callbacks.onUsageAdded(usage);
713
- }
714
- }
715
- // Collect tool calls for processing
716
- const toolCalls = [];
717
- if (result.tool_calls) {
718
- for (const toolCall of result.tool_calls) {
719
- if (toolCall.type === "function") {
720
- toolCalls.push(toolCall);
686
+ // Log finish reason and response headers if available
687
+ if (result.finish_reason) {
688
+ // Log warning headers when finish reason is length
689
+ if (result.finish_reason === "length") {
690
+ logger?.warn("AI response truncated due to length limit. Response headers:", result.response_headers);
691
+ }
721
692
  }
722
- }
723
- }
724
- if (toolCalls.length > 0) {
725
- // Partition tool calls into batches: consecutive concurrency-safe
726
- // tools run in parallel, non-safe tools (Edit, Write, MCP) run one
727
- // at a time to prevent read-modify-write races on the same file.
728
- const batches = [];
729
- for (const call of toolCalls) {
730
- const toolName = call.function?.name || "";
731
- const safe = this.toolManager.isConcurrencySafe(toolName);
732
- const lastBatch = batches[batches.length - 1];
733
- if (lastBatch && lastBatch.safe && safe) {
734
- lastBatch.calls.push(call);
693
+ if (result.additionalFields &&
694
+ Object.keys(result.additionalFields).length > 0) {
695
+ this.messageManager.mergeAssistantAdditionalFields(result.additionalFields);
735
696
  }
736
- else {
737
- batches.push({ calls: [call], safe });
697
+ // Handle result reasoning content from non-streaming mode
698
+ if (result.reasoning_content && !createdByStreaming) {
699
+ this.messageManager.updateCurrentMessageReasoning(result.reasoning_content);
738
700
  }
739
- }
740
- // Execute batches sequentially; within a safe batch, tools run in parallel
741
- for (const batch of batches) {
742
- if (abortController.signal.aborted ||
743
- toolAbortController.signal.aborted) {
744
- break;
701
+ // Handle result content from non-streaming mode
702
+ if (result.content && !createdByStreaming) {
703
+ this.messageManager.updateCurrentMessageContent(result.content);
745
704
  }
746
- if (batch.calls.length === 1) {
747
- await this.executeToolCall(batch.calls[0], abortController, toolAbortController, result.finish_reason);
705
+ // Handle usage tracking for agent operations
706
+ let usage;
707
+ if (result.usage) {
708
+ usage = {
709
+ prompt_tokens: result.usage.prompt_tokens,
710
+ completion_tokens: result.usage.completion_tokens,
711
+ total_tokens: result.usage.total_tokens,
712
+ model: model || this.getModelConfig().model,
713
+ operation_type: "agent",
714
+ // Preserve cache fields if present
715
+ ...(result.usage.cache_read_input_tokens !== undefined && {
716
+ cache_read_input_tokens: result.usage.cache_read_input_tokens,
717
+ }),
718
+ ...(result.usage.cache_creation_input_tokens !== undefined && {
719
+ cache_creation_input_tokens: result.usage.cache_creation_input_tokens,
720
+ }),
721
+ ...(result.usage.cache_creation && {
722
+ cache_creation: result.usage.cache_creation,
723
+ }),
724
+ };
748
725
  }
749
- else {
750
- await Promise.all(batch.calls.map((call) => this.executeToolCall(call, abortController, toolAbortController, result.finish_reason)));
726
+ // Set usage on the assistant message if available
727
+ if (usage) {
728
+ const messages = this.messageManager.getMessages();
729
+ const lastMessage = messages[messages.length - 1];
730
+ if (lastMessage && lastMessage.role === "assistant") {
731
+ lastMessage.usage = usage;
732
+ this.messageManager.setMessages(messages);
733
+ }
734
+ // Notify Agent to add to usage tracking
735
+ if (this.callbacks?.onUsageAdded) {
736
+ this.callbacks.onUsageAdded(usage);
737
+ }
751
738
  }
752
- }
753
- }
754
- // Handle token statistics and message compaction
755
- await this.handleTokenUsageAndCompaction(result.usage, abortController);
756
- // Finalize text/reasoning blocks for the final response (no tools)
757
- this.messageManager.finalizeStreamingBlocks();
758
- // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
759
- if (toolCalls.length > 0 || result.finish_reason === "length") {
760
- // Check maxTurns limit before recursing
761
- if (this.maxTurns && recursionDepth + 1 >= this.maxTurns) {
762
- logger?.debug(`Max turns (${this.maxTurns}) reached, stopping recursion.`);
763
- }
764
- else {
765
- // Record committed snapshots to message history
766
- if (this.reversionManager) {
767
- const snapshots = this.reversionManager.getAndClearCommittedSnapshots();
768
- if (snapshots.length > 0) {
769
- this.messageManager.addFileHistoryBlock(snapshots);
739
+ // Collect tool calls for processing
740
+ const toolCalls = [];
741
+ if (result.tool_calls) {
742
+ for (const toolCall of result.tool_calls) {
743
+ if (toolCall.type === "function") {
744
+ toolCalls.push(toolCall);
745
+ }
770
746
  }
771
747
  }
772
- // Check interruption status
773
- const isCurrentlyAborted = abortController.signal.aborted ||
774
- toolAbortController.signal.aborted;
775
- // Check if all tools were manually backgrounded
776
- const lastMessage = this.messageManager.getMessages()[this.messageManager.getMessages().length - 1];
777
- const toolBlocks = lastMessage?.blocks.filter((block) => block.type === "tool") || [];
778
- const hasBackgrounded = toolBlocks.length > 0 &&
779
- toolBlocks.some((block) => block.isManuallyBackgrounded);
780
- if (hasBackgrounded) {
781
- logger?.info("Some tools were manually backgrounded, stopping recursion.");
748
+ if (toolCalls.length > 0) {
749
+ // Partition tool calls into batches: consecutive concurrency-safe
750
+ // tools run in parallel, non-safe tools (Edit, Write, MCP) run one
751
+ // at a time to prevent read-modify-write races on the same file.
752
+ const batches = [];
753
+ for (const call of toolCalls) {
754
+ const toolName = call.function?.name || "";
755
+ const safe = this.toolManager.isConcurrencySafe(toolName);
756
+ const lastBatch = batches[batches.length - 1];
757
+ if (lastBatch && lastBatch.safe && safe) {
758
+ lastBatch.calls.push(call);
759
+ }
760
+ else {
761
+ batches.push({ calls: [call], safe });
762
+ }
763
+ }
764
+ // Execute batches sequentially; within a safe batch, tools run in parallel
765
+ for (const batch of batches) {
766
+ if (abortController.signal.aborted ||
767
+ toolAbortController.signal.aborted) {
768
+ break;
769
+ }
770
+ if (batch.calls.length === 1) {
771
+ await this.executeToolCall(batch.calls[0], abortController, toolAbortController, result.finish_reason);
772
+ }
773
+ else {
774
+ await Promise.all(batch.calls.map((call) => this.executeToolCall(call, abortController, toolAbortController, result.finish_reason)));
775
+ }
776
+ }
782
777
  }
783
- else if (!isCurrentlyAborted) {
784
- // If response was truncated, add a hidden continuation message
785
- if (result.finish_reason === "length") {
786
- this.messageManager.addUserMessage({
787
- content: "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.",
788
- isMeta: true,
789
- });
778
+ // Handle token statistics and message compaction
779
+ await this.handleTokenUsageAndCompaction(result.usage, abortController);
780
+ // Finalize text/reasoning blocks for the final response (no tools)
781
+ this.messageManager.finalizeStreamingBlocks();
782
+ // Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
783
+ if (toolCalls.length > 0 || result.finish_reason === "length") {
784
+ // Check maxTurns limit before continuing
785
+ if (this.maxTurns && turnDepth + 1 >= this.maxTurns) {
786
+ logger?.debug(`Max turns (${this.maxTurns}) reached, stopping.`);
790
787
  }
791
- // Duplicate Tool Call Detection
792
- if (toolCalls.length > 0) {
793
- const messages = this.messageManager.getMessages();
794
- // Find the most recent assistant message BEFORE the current one that has tool blocks
795
- // The current assistant message is messages[messages.length - 1]
796
- let previousAssistantWithTools;
797
- for (let i = messages.length - 2; i >= 0; i--) {
798
- const msg = messages[i];
799
- if (msg.role === "assistant" &&
800
- msg.blocks.some((b) => b.type === "tool")) {
801
- previousAssistantWithTools = msg;
802
- break;
788
+ else {
789
+ // Record committed snapshots to message history
790
+ if (this.reversionManager) {
791
+ const snapshots = this.reversionManager.getAndClearCommittedSnapshots();
792
+ if (snapshots.length > 0) {
793
+ this.messageManager.addFileHistoryBlock(snapshots);
803
794
  }
804
795
  }
805
- if (previousAssistantWithTools) {
806
- const previousToolBlocks = previousAssistantWithTools.blocks.filter((b) => b.type === "tool");
807
- for (const currentToolCall of toolCalls) {
808
- const currentName = currentToolCall.function?.name;
809
- const currentArgs = currentToolCall.function?.arguments;
810
- const isDuplicate = previousToolBlocks.some((prevBlock) => prevBlock.name === currentName &&
811
- prevBlock.parameters === currentArgs);
812
- if (isDuplicate && currentName) {
813
- const toolId = currentToolCall.id;
814
- const lastMessage = messages[messages.length - 1];
815
- const toolBlock = lastMessage.blocks.find((b) => b.type === "tool" && b.id === toolId);
816
- if (toolBlock) {
817
- 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.`;
818
- this.messageManager.updateToolBlock({
819
- id: toolId,
820
- result: (toolBlock.result || "") + warning,
821
- stage: "end",
822
- });
796
+ // Check interruption status
797
+ const isCurrentlyAborted = abortController.signal.aborted ||
798
+ toolAbortController.signal.aborted;
799
+ // Check if all tools were manually backgrounded
800
+ const lastMessage = this.messageManager.getMessages()[this.messageManager.getMessages().length - 1];
801
+ const toolBlocks = lastMessage?.blocks.filter((block) => block.type === "tool") || [];
802
+ const hasBackgrounded = toolBlocks.length > 0 &&
803
+ toolBlocks.some((block) => block.isManuallyBackgrounded);
804
+ if (hasBackgrounded) {
805
+ logger?.info("Some tools were manually backgrounded, stopping.");
806
+ }
807
+ else if (!isCurrentlyAborted) {
808
+ // If response was truncated, add a hidden continuation message
809
+ if (result.finish_reason === "length") {
810
+ this.messageManager.addUserMessage({
811
+ content: "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.",
812
+ isMeta: true,
813
+ });
814
+ }
815
+ // Duplicate Tool Call Detection
816
+ if (toolCalls.length > 0) {
817
+ const messages = this.messageManager.getMessages();
818
+ // Find the most recent assistant message BEFORE the current one that has tool blocks
819
+ // The current assistant message is messages[messages.length - 1]
820
+ let previousAssistantWithTools;
821
+ for (let i = messages.length - 2; i >= 0; i--) {
822
+ const msg = messages[i];
823
+ if (msg.role === "assistant" &&
824
+ msg.blocks.some((b) => b.type === "tool")) {
825
+ previousAssistantWithTools = msg;
826
+ break;
823
827
  }
824
828
  }
829
+ if (previousAssistantWithTools) {
830
+ const previousToolBlocks = previousAssistantWithTools.blocks.filter((b) => b.type === "tool");
831
+ for (const currentToolCall of toolCalls) {
832
+ const currentName = currentToolCall.function?.name;
833
+ const currentArgs = currentToolCall.function?.arguments;
834
+ const isDuplicate = previousToolBlocks.some((prevBlock) => prevBlock.name === currentName &&
835
+ prevBlock.parameters === currentArgs);
836
+ if (isDuplicate && currentName) {
837
+ const toolId = currentToolCall.id;
838
+ const lastMessage = messages[messages.length - 1];
839
+ const toolBlock = lastMessage.blocks.find((b) => b.type === "tool" && b.id === toolId);
840
+ if (toolBlock) {
841
+ 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.`;
842
+ this.messageManager.updateToolBlock({
843
+ id: toolId,
844
+ result: (toolBlock.result || "") + warning,
845
+ stage: "end",
846
+ });
847
+ }
848
+ }
849
+ }
850
+ }
851
+ }
852
+ // Yield to the event loop so macrotasks (abort timers,
853
+ // signals) can be processed between turns. Without this,
854
+ // mocked async operations (microtasks) would starve the
855
+ // event loop and abort timers would never fire.
856
+ await new Promise((resolve) => setImmediate(resolve));
857
+ // Re-check abort status after yielding — the signal may
858
+ // have fired during the setImmediate gap.
859
+ if (abortController.signal.aborted ||
860
+ toolAbortController.signal.aborted) {
861
+ break inner;
825
862
  }
863
+ turnDepth++;
864
+ continue inner;
826
865
  }
827
866
  }
828
- // Recursively call AI service, increment recursion depth, and pass same configuration
829
- await this.sendAIMessage({
830
- recursionDepth: recursionDepth + 1,
831
- model,
832
- allowedRules,
833
- maxTokens,
834
- });
835
867
  }
868
+ // No tool calls (or stop conditions) → inner loop done
869
+ break inner;
870
+ }
871
+ catch (error) {
872
+ // End LLM span with error
873
+ endLLMRequestSpan({
874
+ model: model || this.getModelConfig().model || "",
875
+ success: false,
876
+ error: error instanceof Error ? error.message : String(error),
877
+ });
878
+ // Log error event
879
+ logOTelEvent("error", {
880
+ error_type: error instanceof Error ? error.constructor.name : "Unknown",
881
+ message: error instanceof Error ? error.message : String(error),
882
+ }).catch(() => { }); // Non-blocking
883
+ this.messageManager.addErrorBlock(error instanceof Error ? error.message : "Unknown error occurred");
884
+ // Exit inner loop on error
885
+ break inner;
836
886
  }
837
887
  }
838
- }
839
- catch (error) {
840
- // End LLM span with error
841
- endLLMRequestSpan({
842
- model: model || this.getModelConfig().model || "",
843
- success: false,
844
- error: error instanceof Error ? error.message : String(error),
845
- });
846
- // Log error event
847
- logOTelEvent("error", {
848
- error_type: error instanceof Error ? error.constructor.name : "Unknown",
849
- message: error instanceof Error ? error.message : String(error),
850
- }).catch(() => { }); // Non-blocking
851
- this.messageManager.addErrorBlock(error instanceof Error ? error.message : "Unknown error occurred");
852
- }
853
- finally {
888
+ // Finally-equivalent (runs once per outer iteration):
854
889
  // Only execute cleanup and hooks for the initial call
855
- if (recursionDepth === 0) {
890
+ if (turnOffset === 0) {
856
891
  // OpenTelemetry: end interaction span
857
892
  endInteractionSpan();
858
- // Save session in each recursion to ensure message persistence
893
+ // Save session in each iteration to ensure message persistence
859
894
  await this.messageManager.saveSession();
860
895
  // Set loading to false first
861
896
  this.setIsLoading(false);
@@ -877,13 +912,9 @@ export class AIManager {
877
912
  });
878
913
  }
879
914
  }
880
- // Recursively process the notifications
881
- await this.sendAIMessage({
882
- recursionDepth: 0,
883
- model,
884
- allowedRules,
885
- maxTokens,
886
- });
915
+ // Restart outer loop to process the notifications
916
+ shouldRestart = true;
917
+ turnOffset = 0;
887
918
  }
888
919
  else {
889
920
  // Clear temporary rules
@@ -943,12 +974,9 @@ export class AIManager {
943
974
  // Keep loading state active to prevent UI flicker
944
975
  this.setIsLoading(true);
945
976
  goalContinuing = true;
946
- await this.sendAIMessage({
947
- recursionDepth: 0,
948
- model,
949
- allowedRules,
950
- maxTokens,
951
- });
977
+ // Restart outer loop to continue goal pursuit
978
+ shouldRestart = true;
979
+ turnOffset = 0;
952
980
  }
953
981
  }
954
982
  }
@@ -963,18 +991,15 @@ export class AIManager {
963
991
  if (shouldContinue) {
964
992
  logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
965
993
  // Restart the conversation to let AI fix the issues
966
- // Use recursionDepth = 0 to set loading false again for continuation
967
- await this.sendAIMessage({
968
- recursionDepth: 0,
969
- model,
970
- allowedRules,
971
- maxTokens,
972
- });
994
+ shouldRestart = true;
995
+ turnOffset = 0;
973
996
  }
974
997
  }
975
998
  }
976
999
  }
977
1000
  }
1001
+ if (!shouldRestart)
1002
+ break outer;
978
1003
  }
979
1004
  }
980
1005
  /**