Skip to content

feat(agent): add parallel tool execution with error handling#2

Merged
cybersecua merged 1 commit intomainfrom
claude/parallel-tool-execution-EOFML
Mar 3, 2026
Merged

feat(agent): add parallel tool execution with error handling#2
cybersecua merged 1 commit intomainfrom
claude/parallel-tool-execution-EOFML

Conversation

@cybersecua
Copy link
Owner

  • Add parallelToolExecution, maxParallelTools, and toolRetryCount fields to AgentConfig (config.go) and the Agent struct.
  • Implement executeToolCallsInParallel: launches a goroutine per tool call protected by panic recovery; an optional semaphore limits concurrency when max_parallel_tools > 0; optional retry logic retries on transient hard errors up to tool_retry_count times.
  • Replace the sequential tool-call loop in the agent loop with a conditional: ≥2 concurrent calls with parallel_tool_execution: true use the parallel path; single calls or disabled mode fall back to the existing sequential path. Results are always applied to messages in original index order to keep tool-call IDs consistent.
  • Emit a parallel: true flag in progress events so the frontend can distinguish parallel from sequential execution batches.
  • Enable parallel execution by default in config.yaml; add max_parallel_tools and tool_retry_count knobs.

https://claude.ai/code/session_01Rd7wB3N5NRdEt5FTWPbABd

- Add `parallelToolExecution`, `maxParallelTools`, and `toolRetryCount`
  fields to `AgentConfig` (config.go) and the `Agent` struct.
- Implement `executeToolCallsInParallel`: launches a goroutine per tool
  call protected by panic recovery; an optional semaphore limits
  concurrency when `max_parallel_tools > 0`; optional retry logic retries
  on transient hard errors up to `tool_retry_count` times.
- Replace the sequential tool-call loop in the agent loop with a
  conditional: ≥2 concurrent calls with `parallel_tool_execution: true`
  use the parallel path; single calls or disabled mode fall back to the
  existing sequential path. Results are always applied to `messages` in
  original index order to keep tool-call IDs consistent.
- Emit a `parallel: true` flag in progress events so the frontend can
  distinguish parallel from sequential execution batches.
- Enable parallel execution by default in `config.yaml`; add
  `max_parallel_tools` and `tool_retry_count` knobs.

https://claude.ai/code/session_01Rd7wB3N5NRdEt5FTWPbABd
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the agent's efficiency and resilience by introducing parallel execution of tool calls. It allows the agent to process multiple tool requests concurrently, improving performance, especially when dealing with several independent tasks. The implementation includes robust error handling, such as panic recovery for individual tool goroutines and configurable retry logic for transient failures, ensuring the agent's stability. These changes provide greater control over resource utilization and fault tolerance through new configuration parameters.

Highlights

  • New Configuration Options: Introduced parallelToolExecution, maxParallelTools, and toolRetryCount configuration options for the agent, allowing fine-grained control over concurrent tool execution and error handling.
  • Parallel Tool Execution Implementation: Implemented executeToolCallsInParallel to concurrently execute tool calls using goroutines, incorporating a concurrency semaphore, panic recovery for individual tool failures, and retry mechanisms for transient errors.
  • Dynamic Tool Execution Path: Modified the agent's core loop to dynamically switch between parallel and sequential tool execution based on the parallelToolExecution setting and the number of pending tool calls.
  • Enhanced Progress Events: Enhanced progress events with a parallel: true flag to clearly indicate concurrent tool execution to the frontend.
  • Default Configuration Update: Updated config.yaml to enable parallel tool execution by default and expose the new knobs for concurrency limits and retry counts.
Changelog
  • config.yaml
    • Added parallel_tool_execution (default true), max_parallel_tools (default 0), and tool_retry_count (default 0) to the agent configuration.
  • internal/agent/agent.go
    • Added parallelToolExecution, maxParallelTools, and toolRetryCount fields to the Agent struct.
    • Initialized these new agent fields from the AgentConfig in NewAgent.
    • Refactored the tool call execution loop to conditionally use a new parallel execution path when parallelToolExecution is enabled and multiple tools are present.
    • Updated sendProgress events to include a parallel flag.
    • Defined parallelToolCallResult struct to hold results from parallel executions.
    • Implemented executeToolCallsInParallel function, which manages goroutines, uses a semaphore for concurrency control, includes panic recovery, and applies retry logic for transient errors.
  • internal/config/config.go
    • Added ParallelToolExecution, MaxParallelTools, and ToolRetryCount fields to the AgentConfig struct.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@cybersecua cybersecua merged commit c1cf5a0 into main Mar 3, 2026
@cybersecua cybersecua deleted the claude/parallel-tool-execution-EOFML branch March 3, 2026 22:19
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces parallel tool execution, a valuable feature for improving agent performance by allowing multiple tools to be called concurrently. However, the implementation has critical security and robustness issues. It is vulnerable to resource exhaustion (DoS) due to spawning an unbounded number of goroutines based on LLM output. Furthermore, the panic recovery mechanism leaks internal error details to the LLM, which could lead to sensitive information disclosure. There is also a functional bug where the retry logic for tool calls is effectively dead code. Beyond these security and functional concerns, the review also identified areas for improving code clarity, maintainability, and consistency, such as confusing logic around feature flag initialization, significant code duplication that could be refactored, and minor cleanups.

Comment on lines +1337 to +1339
for idx, tc := range toolCalls {
wg.Add(1)
go func(idx int, tc ToolCall) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The executeToolCallsInParallel function launches a new goroutine for every tool call returned by the LLM before acquiring a semaphore slot. If an attacker can influence the LLM (e.g., via prompt injection) to return a very large number of tool calls, this could lead to resource exhaustion (Denial of Service) by spawning an excessive number of goroutines. While Go handles goroutines efficiently, each one consumes memory and system resources.

Consider limiting the number of goroutines created or moving the goroutine creation inside a worker pool or after a more robust check on the number of tool calls.

Comment on lines +1344 to +1350
if r := recover(); r != nil {
a.logger.Error("Panic in parallel tool goroutine",
zap.Int("index", idx),
zap.String("tool", tc.Function.Name),
zap.Any("panic", r),
)
panicMsg := fmt.Sprintf("Internal panic executing tool %s: %v", tc.Function.Name, r)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The panic recovery logic in executeToolCallsInParallel captures the raw panic object r and includes it in the panicMsg, which is then sent back to the LLM as a tool result. If a tool panics and the panic object contains sensitive information (e.g., internal system details, stack traces, or secrets), this information will be leaked to the LLM and potentially to the end user.

It is recommended to use a generic error message for the LLM and log the detailed panic information only to the internal system logs.

Comment on lines +74 to +78
parallelToolExecution := true
if agentCfg != nil && !agentCfg.ParallelToolExecution {
// Only disable when explicitly set to false
parallelToolExecution = false
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for setting parallelToolExecution is a bit confusing, and the associated comments are misleading. The logic is equivalent to parallelToolExecution := agentCfg == nil || agentCfg.ParallelToolExecution. This means parallel execution is disabled if the agent block exists in your YAML but parallel_tool_execution is omitted, which contradicts the "enabled by default" comments. Consider simplifying the logic and updating the comments to reflect the actual behavior to avoid future confusion.

Comment on lines +748 to 887
if useParallel {
// ── Parallel path ────────────────────────────────────────────────────
a.logger.Info("Executing tool calls in parallel",
zap.Int("count", totalTools),
)
parallelResults := a.executeToolCallsInParallel(ctx, choice.Message.ToolCalls)

// Process results in original order so tool messages match tool_call IDs.
for _, pr := range parallelResults {
idx := pr.index
toolCall := choice.Message.ToolCalls[idx]

if pr.execErr != nil {
errorMsg := a.formatToolError(pr.toolName, pr.arguments, pr.execErr)
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: pr.toolCallID,
Content: errorMsg,
})
sendProgress("tool_result", fmt.Sprintf("Tool %s execution failed", pr.toolName), map[string]interface{}{
"toolName": pr.toolName,
"success": false,
"isError": true,
"error": pr.execErr.Error(),
"toolCallId": pr.toolCallID,
"index": idx + 1,
"total": totalTools,
"iteration": i + 1,
"parallel": true,
})
a.logger.Warn("Parallel tool execution failed, detailed error message returned",
zap.String("tool", pr.toolName),
zap.Error(pr.execErr),
)
} else {
execResult := pr.execResult
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: pr.toolCallID,
Content: execResult.Result,
})
if execResult.ExecutionID != "" {
result.MCPExecutionIDs = append(result.MCPExecutionIDs, execResult.ExecutionID)
}
resultPreview := execResult.Result
if len(resultPreview) > 200 {
resultPreview = resultPreview[:200] + "..."
}
sendProgress("tool_result", fmt.Sprintf("Tool %s execution completed", pr.toolName), map[string]interface{}{
"toolName": pr.toolName,
"success": !execResult.IsError,
"isError": execResult.IsError,
"result": execResult.Result,
"resultPreview": resultPreview,
"executionId": execResult.ExecutionID,
"toolCallId": pr.toolCallID,
"index": idx + 1,
"total": totalTools,
"iteration": i + 1,
"parallel": true,
})
if execResult.IsError {
a.logger.Warn("Parallel tool returned error result, continuing processing",
zap.String("tool", pr.toolName),
zap.String("result", execResult.Result),
)
}
}
sendProgress("tool_result", fmt.Sprintf("Tool %s execution completed", toolCall.Function.Name), map[string]interface{}{
"toolName": toolCall.Function.Name,
"success": !execResult.IsError,
"isError": execResult.IsError,
"result": execResult.Result, // full result
"resultPreview": resultPreview, // preview result
"executionId": execResult.ExecutionID,
"toolCallId": toolCall.ID,
"index": idx + 1,
"total": len(choice.Message.ToolCalls),
"iteration": i + 1,
})

// If the tool returned an error, log it but do not interrupt the flow
if execResult.IsError {
a.logger.Warn("Tool returned error result, but continuing processing",
_ = toolCall // referenced above via choice.Message.ToolCalls[idx]
}
} else {
// ── Sequential path (single tool call or parallelism disabled) ───────
for idx, toolCall := range choice.Message.ToolCalls {
// Execute tool
execResult, err := a.executeToolViaMCP(ctx, toolCall.Function.Name, toolCall.Function.Arguments)
if err != nil {
// Build detailed error message to help AI understand the problem and make decisions
errorMsg := a.formatToolError(toolCall.Function.Name, toolCall.Function.Arguments, err)
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: toolCall.ID,
Content: errorMsg,
})

// Send tool execution failure event
sendProgress("tool_result", fmt.Sprintf("Tool %s execution failed", toolCall.Function.Name), map[string]interface{}{
"toolName": toolCall.Function.Name,
"success": false,
"isError": true,
"error": err.Error(),
"toolCallId": toolCall.ID,
"index": idx + 1,
"total": totalTools,
"iteration": i + 1,
})

a.logger.Warn("Tool execution failed, detailed error message returned",
zap.String("tool", toolCall.Function.Name),
zap.String("result", execResult.Result),
zap.Error(err),
)
} else {
// Even if the tool returned an error result (IsError=true), continue processing and let AI decide the next step
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: toolCall.ID,
Content: execResult.Result,
})
// Collect execution ID
if execResult.ExecutionID != "" {
result.MCPExecutionIDs = append(result.MCPExecutionIDs, execResult.ExecutionID)
}

// Send tool execution success event
resultPreview := execResult.Result
if len(resultPreview) > 200 {
resultPreview = resultPreview[:200] + "..."
}
sendProgress("tool_result", fmt.Sprintf("Tool %s execution completed", toolCall.Function.Name), map[string]interface{}{
"toolName": toolCall.Function.Name,
"success": !execResult.IsError,
"isError": execResult.IsError,
"result": execResult.Result, // full result
"resultPreview": resultPreview, // preview result
"executionId": execResult.ExecutionID,
"toolCallId": toolCall.ID,
"index": idx + 1,
"total": totalTools,
"iteration": i + 1,
})

// If the tool returned an error, log it but do not interrupt the flow
if execResult.IsError {
a.logger.Warn("Tool returned error result, but continuing processing",
zap.String("tool", toolCall.Function.Name),
zap.String("result", execResult.Result),
)
}
}
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant code duplication between the parallel (if useParallel) and sequential (else) tool execution paths, especially in how results are processed, messages are appended, and progress is sent. This makes the code harder to maintain.

Consider refactoring the common logic into a helper function. This function could take a tool call result (perhaps by adapting the parallelToolCallResult struct for both paths) and handle appending messages, sending progress, and logging.

Comment on lines +758 to +816
toolCall := choice.Message.ToolCalls[idx]

if pr.execErr != nil {
errorMsg := a.formatToolError(pr.toolName, pr.arguments, pr.execErr)
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: pr.toolCallID,
Content: errorMsg,
})
sendProgress("tool_result", fmt.Sprintf("Tool %s execution failed", pr.toolName), map[string]interface{}{
"toolName": pr.toolName,
"success": false,
"isError": true,
"error": pr.execErr.Error(),
"toolCallId": pr.toolCallID,
"index": idx + 1,
"total": totalTools,
"iteration": i + 1,
"parallel": true,
})
a.logger.Warn("Parallel tool execution failed, detailed error message returned",
zap.String("tool", pr.toolName),
zap.Error(pr.execErr),
)
} else {
execResult := pr.execResult
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: pr.toolCallID,
Content: execResult.Result,
})
if execResult.ExecutionID != "" {
result.MCPExecutionIDs = append(result.MCPExecutionIDs, execResult.ExecutionID)
}
resultPreview := execResult.Result
if len(resultPreview) > 200 {
resultPreview = resultPreview[:200] + "..."
}
sendProgress("tool_result", fmt.Sprintf("Tool %s execution completed", pr.toolName), map[string]interface{}{
"toolName": pr.toolName,
"success": !execResult.IsError,
"isError": execResult.IsError,
"result": execResult.Result,
"resultPreview": resultPreview,
"executionId": execResult.ExecutionID,
"toolCallId": pr.toolCallID,
"index": idx + 1,
"total": totalTools,
"iteration": i + 1,
"parallel": true,
})
if execResult.IsError {
a.logger.Warn("Parallel tool returned error result, continuing processing",
zap.String("tool", pr.toolName),
zap.String("result", execResult.Result),
)
}
}
sendProgress("tool_result", fmt.Sprintf("Tool %s execution completed", toolCall.Function.Name), map[string]interface{}{
"toolName": toolCall.Function.Name,
"success": !execResult.IsError,
"isError": execResult.IsError,
"result": execResult.Result, // full result
"resultPreview": resultPreview, // preview result
"executionId": execResult.ExecutionID,
"toolCallId": toolCall.ID,
"index": idx + 1,
"total": len(choice.Message.ToolCalls),
"iteration": i + 1,
})

// If the tool returned an error, log it but do not interrupt the flow
if execResult.IsError {
a.logger.Warn("Tool returned error result, but continuing processing",
_ = toolCall // referenced above via choice.Message.ToolCalls[idx]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The variable toolCall declared on line 758 is unused, as indicated by the suppression on line 816 (_ = toolCall). Both the variable declaration and the suppression line can be removed to simplify the code.

Comment on lines +1351 to +1360
results[idx] = parallelToolCallResult{
index: idx,
toolCallID: tc.ID,
toolName: tc.Function.Name,
arguments: tc.Function.Arguments,
execResult: &ToolExecutionResult{
Result: panicMsg,
IsError: true,
},
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the panic recovery handler, the result of the panic is stored in execResult, but execErr is left as nil. A panic is a form of hard execution error, similar to what execErr is intended to represent. For consistency, it would be better to populate execErr with the panic information. This would allow the panic to be handled by the if pr.execErr != nil block in AgentLoopWithProgress, which is the more appropriate path for such errors.

results[idx] = parallelToolCallResult{
	index:      idx,
	toolCallID: tc.ID,
	toolName:   tc.Function.Name,
	arguments:  tc.Function.Arguments,
	execErr:    fmt.Errorf(panicMsg),
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants