Skip to content

Commit 809ca3a

Browse files
committed
feat(workflow): support directed acyclic graph (DAG) task execution via run_dag
Enables workflow scripts to execute heterogeneous sub-agent tasks as a directed acyclic graph (DAG) with dependency barriers and concurrency throttling: - Validates graph topology and detects cycles pre-flight using Kahn's algorithm - Concurrently dispatches unblocked ready tasks across the shared semaphore - Resolves upstream node outputs into downstream prompt templates ({parent_node}) or callable prompt handlers - Automatically prunes downstream dependent tasks when an upstream task fails, preventing wasteful LLM calls on invalidated branches - Adds comprehensive unit test coverage (linear, diamond concurrency, cycle detection, callable prompts, fail-fast pruning, and script integration)
1 parent df2ea8f commit 809ca3a

3 files changed

Lines changed: 318 additions & 0 deletions

File tree

openhands-tools/openhands/tools/workflow/definition.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ async def main(wf):
8585
the previous result. Stages may be sync or async. A stage that raises drops that
8686
item to `None`. Prefer this over chained `map_agents` calls when per-item stages
8787
are independent, since `map_agents` fully drains each stage before the next.
88+
- `await wf.run_dag(nodes, max_concurrency=None)` — run a directed acyclic
89+
graph (DAG) of sub-agent tasks with dependency resolution and concurrency
90+
control. `nodes` maps node IDs to specs (`prompt`, `depends_on`,
91+
`subagent_type`, `description`). Tasks execute as soon as their upstream
92+
dependencies finish. Upstream results are interpolated into `{parent_node_id}`
93+
prompt placeholders. If an upstream task fails, downstream dependent tasks
94+
are pruned automatically.
8895
- `wf.flatten(values)` — flatten one level of nesting (not recursive)
8996
9097
`subagent_type` must be a sub-agent type registered in the parent application.

openhands-tools/openhands/tools/workflow/impl.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,147 @@ async def reduce_agent(
241241
description=description,
242242
)
243243

244+
async def run_dag(
245+
self,
246+
nodes: dict[str, dict[str, Any]],
247+
*,
248+
max_concurrency: int | None = None,
249+
) -> dict[str, str]:
250+
"""Execute a directed acyclic graph (DAG) of sub-agent tasks.
251+
252+
Tasks are dispatched concurrently as soon as their upstream dependencies
253+
complete. If an upstream dependency fails, downstream dependent tasks
254+
are pruned without executing to avoid wasting agent calls.
255+
256+
Args:
257+
nodes: Mapping of node ID to specification dict:
258+
- ``prompt``: Required prompt string or callable
259+
``(results) -> str``. When a string, occurrences of
260+
``{parent_id}`` are substituted with the parent node's result.
261+
- ``depends_on``: Optional sequence of parent node IDs
262+
(default: ``[]``).
263+
- ``subagent_type``: Optional subagent type
264+
(default: ``"general-purpose"``).
265+
- ``description``: Optional human-readable description.
266+
max_concurrency: Optional per-call concurrency limit (capped at
267+
context limit).
268+
269+
Returns:
270+
Mapping of node ID to string result for completed nodes.
271+
272+
Raises:
273+
ValueError: If ``nodes`` is empty, contains unknown dependencies,
274+
contains self-dependencies, or contains cycles.
275+
ExceptionGroup: If one or more tasks fail during execution.
276+
"""
277+
if not nodes:
278+
raise ValueError("run_dag requires at least one node")
279+
if max_concurrency is not None and max_concurrency < 1:
280+
raise ValueError("max_concurrency must be at least 1")
281+
282+
# 1. Structural validation
283+
for node_id, spec in nodes.items():
284+
if not isinstance(spec, dict):
285+
raise ValueError(f"Node '{node_id}' specification must be a dict")
286+
deps = spec.get("depends_on", [])
287+
for dep in deps:
288+
if dep == node_id:
289+
raise ValueError(f"Node '{node_id}' cannot depend on itself")
290+
if dep not in nodes:
291+
raise ValueError(
292+
f"Node '{node_id}' depends on unknown node '{dep}'"
293+
)
294+
295+
# 2. Cycle detection via Kahn's topological sort
296+
in_degree = {k: len(nodes[k].get("depends_on", [])) for k in nodes}
297+
adj: dict[str, list[str]] = {k: [] for k in nodes}
298+
for k, spec in nodes.items():
299+
for dep in spec.get("depends_on", []):
300+
adj[dep].append(k)
301+
302+
queue = [k for k, deg in in_degree.items() if deg == 0]
303+
visited_count = 0
304+
while queue:
305+
curr = queue.pop(0)
306+
visited_count += 1
307+
for neighbor in adj[curr]:
308+
in_degree[neighbor] -= 1
309+
if in_degree[neighbor] == 0:
310+
queue.append(neighbor)
311+
312+
if visited_count < len(nodes):
313+
unresolved = [k for k, deg in in_degree.items() if deg > 0]
314+
raise ValueError(
315+
f"DAG contains cycles involving: {', '.join(sorted(unresolved))}"
316+
)
317+
318+
# 3. Frontier execution
319+
semaphore = (
320+
asyncio.Semaphore(min(max_concurrency, self._max_concurrency))
321+
if max_concurrency is not None
322+
else self._default_semaphore
323+
)
324+
325+
results: dict[str, str] = {}
326+
errors: dict[str, Exception] = {}
327+
failed_or_skipped: set[str] = set()
328+
events: dict[str, asyncio.Event] = {k: asyncio.Event() for k in nodes}
329+
330+
async def run_node(node_id: str, spec: dict[str, Any]) -> None:
331+
deps = spec.get("depends_on", [])
332+
# Wait for all upstream dependencies
333+
for dep in deps:
334+
await events[dep].wait()
335+
336+
# Prune if any upstream dependency failed or was skipped
337+
failed_deps = [dep for dep in deps if dep in failed_or_skipped]
338+
if failed_deps:
339+
failed_or_skipped.add(node_id)
340+
errors[node_id] = RuntimeError(
341+
f"Node '{node_id}' skipped because dependency "
342+
f"'{failed_deps[0]}' failed"
343+
)
344+
events[node_id].set()
345+
return
346+
347+
# Render prompt
348+
raw_prompt = spec.get("prompt", "")
349+
subagent_type = spec.get("subagent_type", "general-purpose")
350+
description = spec.get("description")
351+
352+
if callable(raw_prompt):
353+
rendered_prompt = str(raw_prompt(results))
354+
elif isinstance(raw_prompt, str):
355+
rendered_prompt = raw_prompt
356+
for dep in deps:
357+
val = str(results.get(dep, ""))
358+
rendered_prompt = rendered_prompt.replace(f"{{{dep}}}", val)
359+
else:
360+
rendered_prompt = str(raw_prompt)
361+
362+
async with semaphore:
363+
try:
364+
res = await self._run_agent_task(
365+
prompt=rendered_prompt,
366+
subagent_type=subagent_type,
367+
description=description,
368+
)
369+
results[node_id] = res
370+
except Exception as exc:
371+
failed_or_skipped.add(node_id)
372+
errors[node_id] = exc
373+
finally:
374+
events[node_id].set()
375+
376+
await asyncio.gather(*(run_node(k, v) for k, v in nodes.items()))
377+
378+
if errors:
379+
raise ExceptionGroup(
380+
"run_dag: one or more nodes failed", list(errors.values())
381+
)
382+
383+
return results
384+
244385
def flatten(self, values: list[Any]) -> list[Any]:
245386
"""Flatten one list level."""
246387
flattened: list[Any] = []

tests/tools/workflow/test_workflow_tool.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,3 +528,173 @@ def test_format_value_long_string_char_truncated() -> None:
528528
out = _format_value("q" * (_MAX_REDUCE_INPUT_CHARS + 100))
529529
assert out.endswith("[truncated workflow intermediate results]")
530530
assert len(out) <= _MAX_REDUCE_INPUT_CHARS + 60
531+
532+
533+
def test_run_dag_basic_linear_dependency() -> None:
534+
manager = _FakeTaskManager()
535+
ctx = _context(manager)
536+
nodes = {
537+
"step1": {
538+
"prompt": "first step",
539+
"subagent_type": "analyst",
540+
},
541+
"step2": {
542+
"prompt": "second step using {step1}",
543+
"depends_on": ["step1"],
544+
"subagent_type": "writer",
545+
},
546+
}
547+
results = asyncio.run(ctx.run_dag(nodes))
548+
assert results["step1"] == "result:first step"
549+
assert results["step2"] == "result:second step using result:first step"
550+
assert manager.prompts == [
551+
"analyst: first step",
552+
"writer: second step using result:first step",
553+
]
554+
555+
556+
def test_run_dag_diamond_concurrency() -> None:
557+
manager = _FakeTaskManager()
558+
ctx = _context(manager)
559+
nodes = {
560+
"root": {"prompt": "init"},
561+
"branch_a": {"prompt": "do A after {root}", "depends_on": ["root"]},
562+
"branch_b": {"prompt": "do B after {root}", "depends_on": ["root"]},
563+
"join": {
564+
"prompt": "merge {branch_a} and {branch_b}",
565+
"depends_on": ["branch_a", "branch_b"],
566+
},
567+
}
568+
results = asyncio.run(ctx.run_dag(nodes))
569+
assert results["root"] == "result:init"
570+
assert results["branch_a"] == "result:do A after result:init"
571+
assert results["branch_b"] == "result:do B after result:init"
572+
assert results["join"] == (
573+
"result:merge result:do A after result:init and "
574+
"result:do B after result:init"
575+
)
576+
# Root must execute first, join must execute last
577+
assert manager.prompts[0] == "general-purpose: init"
578+
assert set(manager.prompts[1:3]) == {
579+
"general-purpose: do A after result:init",
580+
"general-purpose: do B after result:init",
581+
}
582+
assert manager.prompts[3] == (
583+
"general-purpose: merge result:do A after result:init and "
584+
"result:do B after result:init"
585+
)
586+
587+
588+
def test_run_dag_callable_prompt() -> None:
589+
manager = _FakeTaskManager()
590+
ctx = _context(manager)
591+
nodes = {
592+
"prep": {"prompt": "setup data"},
593+
"process": {
594+
"prompt": lambda res: f"custom {res['prep'].upper()}",
595+
"depends_on": ["prep"],
596+
},
597+
}
598+
results = asyncio.run(ctx.run_dag(nodes))
599+
assert results["process"] == "result:custom RESULT:SETUP DATA"
600+
601+
602+
def test_run_dag_cycle_detection() -> None:
603+
ctx = _context(_FakeTaskManager())
604+
nodes = {
605+
"a": {"prompt": "a", "depends_on": ["b"]},
606+
"b": {"prompt": "b", "depends_on": ["a"]},
607+
}
608+
with pytest.raises(ValueError, match="DAG contains cycles"):
609+
asyncio.run(ctx.run_dag(nodes))
610+
611+
612+
def test_run_dag_self_dependency_raises() -> None:
613+
ctx = _context(_FakeTaskManager())
614+
nodes = {
615+
"a": {"prompt": "a", "depends_on": ["a"]},
616+
}
617+
with pytest.raises(ValueError, match="cannot depend on itself"):
618+
asyncio.run(ctx.run_dag(nodes))
619+
620+
621+
def test_run_dag_unknown_dependency_raises() -> None:
622+
ctx = _context(_FakeTaskManager())
623+
nodes = {
624+
"a": {"prompt": "a", "depends_on": ["nonexistent"]},
625+
}
626+
with pytest.raises(ValueError, match="depends on unknown node"):
627+
asyncio.run(ctx.run_dag(nodes))
628+
629+
630+
def test_run_dag_empty_nodes_raises() -> None:
631+
ctx = _context(_FakeTaskManager())
632+
with pytest.raises(ValueError, match="requires at least one node"):
633+
asyncio.run(ctx.run_dag({}))
634+
635+
636+
def test_run_dag_invalid_spec_raises() -> None:
637+
ctx = _context(_FakeTaskManager())
638+
with pytest.raises(ValueError, match="specification must be a dict"):
639+
asyncio.run(ctx.run_dag({"bad": "not-a-dict"})) # type: ignore[arg-type]
640+
641+
642+
def test_run_dag_prunes_downstream_on_failure() -> None:
643+
class FailingTaskManager(_FakeTaskManager):
644+
def start_task(
645+
self,
646+
prompt: str,
647+
subagent_type: str = "default",
648+
resume: str | None = None,
649+
description: str | None = None,
650+
conversation: LocalConversation | None = None,
651+
) -> _FakeTask:
652+
self.prompts.append(f"{subagent_type}: {prompt}")
653+
if "fail" in prompt:
654+
return _FakeTask(error="upstream task failed")
655+
return _FakeTask(result=f"result:{prompt}")
656+
657+
manager = FailingTaskManager()
658+
ctx = _context(manager)
659+
nodes = {
660+
"good": {"prompt": "run independent good"},
661+
"failing": {"prompt": "run failing task"},
662+
"child_of_failing": {
663+
"prompt": "should be skipped {failing}",
664+
"depends_on": ["failing"],
665+
},
666+
}
667+
668+
with pytest.raises(ExceptionGroup) as exc_info:
669+
asyncio.run(ctx.run_dag(nodes))
670+
671+
assert "run_dag" in str(exc_info.value)
672+
# The failing node raised, and child_of_failing was pruned
673+
error_messages = [str(e) for e in exc_info.value.exceptions]
674+
assert any("upstream task failed" in msg for msg in error_messages)
675+
assert any(
676+
"skipped because dependency 'failing' failed" in msg
677+
for msg in error_messages
678+
)
679+
# child_of_failing was never dispatched to manager
680+
dispatched_prompts = set(manager.prompts)
681+
assert "general-purpose: run independent good" in dispatched_prompts
682+
assert "general-purpose: run failing task" in dispatched_prompts
683+
assert not any("should be skipped" in p for p in dispatched_prompts)
684+
685+
686+
def test_run_dag_reachable_from_script() -> None:
687+
manager = _FakeTaskManager()
688+
ctx = _context(manager)
689+
script = """
690+
async def main(wf):
691+
nodes = {
692+
"spec": {"prompt": "design API"},
693+
"impl": {"prompt": "build from {spec}", "depends_on": ["spec"]},
694+
}
695+
results = await wf.run_dag(nodes)
696+
return results["impl"]
697+
"""
698+
result = execute_workflow_script(script, ctx)
699+
assert result == "result:build from result:design API"
700+

0 commit comments

Comments
 (0)