Skip to content

Commit 8d7f482

Browse files
VascoSch92openhands-agentenyst
authored
feat(sdk/agent): Parallel Tool Call Execution (#2390)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
1 parent 4b5b3a4 commit 8d7f482

8 files changed

Lines changed: 1418 additions & 54 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Example: Parallel tool execution with tool_concurrency_limit.
2+
3+
Demonstrates how setting tool_concurrency_limit on an Agent enables
4+
concurrent tool execution within a single step. The orchestrator agent
5+
delegates to multiple sub-agents in parallel, and each sub-agent itself
6+
runs tools concurrently. This stress-tests the parallel execution system
7+
end-to-end.
8+
"""
9+
10+
import json
11+
import os
12+
import tempfile
13+
from collections import defaultdict
14+
from pathlib import Path
15+
16+
from openhands.sdk import (
17+
LLM,
18+
Agent,
19+
AgentContext,
20+
Conversation,
21+
Tool,
22+
register_agent,
23+
)
24+
from openhands.sdk.context import Skill
25+
from openhands.tools.delegate import DelegationVisualizer
26+
from openhands.tools.file_editor import FileEditorTool
27+
from openhands.tools.task import TaskToolSet
28+
from openhands.tools.terminal import TerminalTool
29+
30+
31+
llm = LLM(
32+
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
33+
api_key=os.getenv("LLM_API_KEY"),
34+
base_url=os.getenv("LLM_BASE_URL"),
35+
usage_id="parallel-tools-demo",
36+
)
37+
38+
39+
# --- Sub-agents ---
40+
41+
42+
def create_code_analyst(llm: LLM) -> Agent:
43+
"""Sub-agent that analyzes code structure."""
44+
return Agent(
45+
llm=llm,
46+
tools=[
47+
Tool(name=TerminalTool.name),
48+
Tool(name=FileEditorTool.name),
49+
],
50+
tool_concurrency_limit=4,
51+
agent_context=AgentContext(
52+
skills=[
53+
Skill(
54+
name="code_analysis",
55+
content=(
56+
"You analyze code structure. Use the terminal to count files, "
57+
"lines of code, and list directory structure. Use the file "
58+
"editor to read key files. Run multiple commands at once."
59+
),
60+
trigger=None,
61+
)
62+
],
63+
system_message_suffix="Be concise. Report findings in bullet points.",
64+
),
65+
)
66+
67+
68+
def create_doc_reviewer(llm: LLM) -> Agent:
69+
"""Sub-agent that reviews documentation."""
70+
return Agent(
71+
llm=llm,
72+
tools=[
73+
Tool(name=TerminalTool.name),
74+
Tool(name=FileEditorTool.name),
75+
],
76+
tool_concurrency_limit=4,
77+
agent_context=AgentContext(
78+
skills=[
79+
Skill(
80+
name="doc_review",
81+
content=(
82+
"You review project documentation. Check README files, "
83+
"docstrings, and inline comments. Use the terminal and "
84+
"file editor to inspect files. Run multiple commands at once."
85+
),
86+
trigger=None,
87+
)
88+
],
89+
system_message_suffix="Be concise. Report findings in bullet points.",
90+
),
91+
)
92+
93+
94+
def create_dependency_checker(llm: LLM) -> Agent:
95+
"""Sub-agent that checks project dependencies."""
96+
return Agent(
97+
llm=llm,
98+
tools=[
99+
Tool(name=TerminalTool.name),
100+
Tool(name=FileEditorTool.name),
101+
],
102+
tool_concurrency_limit=4,
103+
agent_context=AgentContext(
104+
skills=[
105+
Skill(
106+
name="dependency_check",
107+
content=(
108+
"You analyze project dependencies. Read pyproject.toml, "
109+
"requirements files, and package configs. Summarize key "
110+
"dependencies, their purposes, and any version constraints. "
111+
"Run multiple commands at once."
112+
),
113+
trigger=None,
114+
)
115+
],
116+
system_message_suffix="Be concise. Report findings in bullet points.",
117+
),
118+
)
119+
120+
121+
# Register sub-agents
122+
register_agent(
123+
name="code_analyst",
124+
factory_func=create_code_analyst,
125+
description="Analyzes code structure, file counts, and directory layout.",
126+
)
127+
register_agent(
128+
name="doc_reviewer",
129+
factory_func=create_doc_reviewer,
130+
description="Reviews documentation quality and completeness.",
131+
)
132+
register_agent(
133+
name="dependency_checker",
134+
factory_func=create_dependency_checker,
135+
description="Checks and summarizes project dependencies.",
136+
)
137+
# --- Orchestrator agent with parallel execution ---
138+
main_agent = Agent(
139+
llm=llm,
140+
tools=[
141+
Tool(name=TaskToolSet.name),
142+
Tool(name=TerminalTool.name),
143+
Tool(name=FileEditorTool.name),
144+
],
145+
tool_concurrency_limit=8,
146+
)
147+
148+
persistence_dir = Path(tempfile.mkdtemp(prefix="parallel_example_"))
149+
150+
conversation = Conversation(
151+
agent=main_agent,
152+
workspace=Path.cwd(),
153+
visualizer=DelegationVisualizer(name="Orchestrator"),
154+
persistence_dir=persistence_dir,
155+
)
156+
157+
print("=" * 80)
158+
print("Parallel Tool Execution Stress Test")
159+
print("=" * 80)
160+
161+
conversation.send_message("""
162+
Analyze the current project by delegating to ALL THREE sub-agents IN PARALLEL:
163+
164+
1. code_analyst: Analyze the project structure (file counts, key directories)
165+
2. doc_reviewer: Review documentation quality (README, docstrings)
166+
3. dependency_checker: Check dependencies (pyproject.toml, requirements)
167+
168+
IMPORTANT: Delegate to all three agents at the same time using parallel tool calls.
169+
Do NOT delegate one at a time - call all three delegate tools in a single response.
170+
171+
Once all three have reported back, write a consolidated summary to
172+
project_analysis_report.txt in the working directory. The report should have
173+
three sections (Code Structure, Documentation, Dependencies) with the key
174+
findings from each sub-agent.
175+
""")
176+
conversation.run()
177+
178+
# --- Analyze persisted events for parallelism ---
179+
#
180+
# Walk the persistence directory to find all conversations (main + sub-agents).
181+
# Each conversation stores events as event-*.json files under an events/ dir.
182+
# We parse ActionEvent entries and group by llm_response_id — batches with 2+
183+
# actions sharing the same response ID prove the LLM requested parallel calls
184+
# and the executor handled them concurrently.
185+
186+
print("\n" + "=" * 80)
187+
print("Parallelism Report")
188+
print("=" * 80)
189+
190+
191+
def _analyze_conversation(events_dir: Path) -> dict[str, list[str]]:
192+
"""Return {llm_response_id: [tool_name, ...]} for multi-tool batches."""
193+
batches: dict[str, list[str]] = defaultdict(list)
194+
for event_file in sorted(events_dir.glob("event-*.json")):
195+
data = json.loads(event_file.read_text())
196+
if data.get("kind") == "ActionEvent" and "llm_response_id" in data:
197+
batches[data["llm_response_id"]].append(data.get("tool_name", "?"))
198+
return {rid: tools for rid, tools in batches.items() if len(tools) >= 2}
199+
200+
201+
for events_dir in sorted(persistence_dir.rglob("events")):
202+
if not events_dir.is_dir():
203+
continue
204+
# Derive a label from the path (main conv vs sub-agent)
205+
rel = events_dir.parent.relative_to(persistence_dir)
206+
is_subagent = "subagents" in rel.parts
207+
label = "sub-agent" if is_subagent else "main agent"
208+
209+
multi_batches = _analyze_conversation(events_dir)
210+
if multi_batches:
211+
for resp_id, tools in multi_batches.items():
212+
print(f"\n {label} batch ({resp_id[:16]}...):")
213+
print(f" Parallel tools: {tools}")
214+
else:
215+
print(f"\n {label}: no parallel batches")
216+
217+
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
218+
print(f"\nTotal cost: ${cost:.4f}")
219+
print(f"EXAMPLE_COST: {cost:.4f}")

0 commit comments

Comments
 (0)