feat: enable parallel tool execution with multi-tool status display

- Mark read-only core tools as parallel-safe (read, grep, find, ls)
- Mark spawn_subagent as parallel-safe for concurrent task delegation
- Update UI to track multiple active tools during parallel execution
- Display 'Running: tool1, tool2, ...' in spinner for concurrent tools
- Add test for parallel tool execution scenarios

Fantasy already supports parallel execution via ToolInfo.Parallel field.
Tools marked parallel run concurrently (up to 5 at a time).
This commit is contained in:
Ed Zynda
2026-03-14 17:24:20 +03:00
parent 4c126ca41b
commit 424847f0db
7 changed files with 79 additions and 18 deletions
+1
View File
@@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeFind(ctx, call, cfg.WorkDir)
+1
View File
@@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeGrep(ctx, call, cfg.WorkDir)
+1
View File
@@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeLs(ctx, call, cfg.WorkDir)
+1
View File
@@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"path"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeRead(ctx, call, cfg.WorkDir)
+1
View File
@@ -57,6 +57,7 @@ Example use cases:
},
},
Required: []string{"task"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeSubagent(ctx, call)
+44 -7
View File
@@ -397,8 +397,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if !strings.Contains(c.spinnerMsg, "exec_tool") {
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -410,7 +410,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c := newTestStream()
// Start spinning first (simulating execution in progress).
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
c.spinnerMsg = "Executing some_tool…"
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
@@ -420,8 +424,41 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg)
if len(c.activeTools) != 0 {
t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
}
}
// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
}
// Check SpinnerView shows all tools
view := c.SpinnerView()
if !strings.Contains(view, "Running:") {
t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
@@ -480,8 +517,8 @@ func TestStreamComponent_Reset(t *testing.T) {
if !c.timestamp.IsZero() {
t.Fatal("expected zero timestamp after Reset()")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg)
if len(c.activeTools) != 0 {
t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
}
}
+30 -11
View File
@@ -115,9 +115,9 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// spinnerMsg is the label shown next to the KITT animation (e.g.
// "Executing tool_name…"). Empty string means no label.
spinnerMsg string
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// streamContent accumulates all streaming text chunks.
streamContent strings.Builder
@@ -182,7 +182,7 @@ func (s *StreamComponent) Reset() {
s.phase = streamPhaseIdle
s.spinning = false
s.spinnerFrame = 0
s.spinnerMsg = ""
s.activeTools = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.timestamp = time.Time{}
@@ -267,9 +267,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ToolExecutionEvent:
if msg.IsStarting {
// Show the tool name on the spinner while the tool executes.
// For spawn_subagent, show a descriptive message with the task.
s.spinnerMsg = formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -277,8 +277,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd()
}
} else {
// Tool finished — clear execution label but keep spinning.
s.spinnerMsg = ""
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
}
}
@@ -375,14 +376,22 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if s.spinnerMsg == "" {
if len(s.activeTools) == 0 {
return " " + frame
}
theme := GetTheme()
msgStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Italic(true)
return " " + frame + " " + msgStyle.Render(s.spinnerMsg)
// Format active tools list
var toolsMsg string
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
} else {
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
// renderStreamingText renders the accumulated streaming text as a live assistant
@@ -401,6 +410,16 @@ func (s *StreamComponent) renderStreamingText(text string) string {
return msg.Content
}
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
}
}
return slice
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it extracts and displays the task being performed.
func formatToolExecutionMessage(toolName, toolArgs string) string {