Multi-Agent DAG Orchestration
Building on Example 01, this example demonstrates a multi-agent pipeline with a directed acyclic graph (DAG) topology. Multiple agents are wired together so upstream results automatically flow into downstream agents’ context. It also shows function nodes (pure Python execution without LLM) and all three tool registration styles.
Prerequisites: Read 01_single_agent.py first.
What you’ll learn:
How to build a multi-agent DAG with multiple upstream dependencies per node
How to wire results from upstream agents into downstream agent context
How to create function nodes (pure Python, no LLM) using
PipelineNode(fn=...)All three tool registration styles: direct callable, async decorator, sync decorator
How downstream agents automatically receive upstream results
Architecture:
Four agents form a linear pipeline (planner → runner → analyzer → reporter), plus a pure Python function node to save the final report.
Main Code
Below is the complete example:
1"""02 — Multi-Agent DAG + Function Nodes + Tool Registration Styles.
2
3**Prerequisites:** Read ``01_single_agent.py`` first.
4
5**What's new:**
6
7* **Multi-agent DAG** — four agents wired as a directed acyclic graph
8 (planner → runner → analyzer → reporter)
9* **Function node** — ``save_report`` runs as plain Python in Dragon Batch
10 (no LLM), demonstrating ``PipelineNode(fn=...)``
11* **All three tool registration styles:**
12
13 1. ``registry.register(fn)`` — direct callable (sync)
14 2. ``@registry.tool`` decorator — inline async tool
15 3. ``@registry.tool`` decorator — inline sync tool
16
17* **Async tools** — ``analyze_convergence`` is an async function
18* **:param: annotations** — auto-extracted into JSON schemas for the LLM
19
20Architecture::
21
22 planner_agent ──► runner_agent ──► analyzer_agent ──┐
23 │ │ ├──► reporter_agent
24 └────────────────┴─────────────────────────────┘ │
25 save_report (fn)
26
27Usage::
28
29 dragon 02_multi_agent_dag.py
30"""
31
32import asyncio
33import json
34import math
35import os
36import time
37from typing import Any
38
39import dragon
40import multiprocessing as mp
41
42from dragon.ai.agent.core import create_sub_agent
43from dragon.ai.agent.config import (
44 AgentConfig,
45 OrchestratorConfig,
46 Pipeline,
47 PipelineNode,
48 TaskResult,
49 TaskStatus,
50 DISPATCH_ID_KEY,
51 RESULT_KEY,
52 STATUS_KEY,
53)
54
55from dragon import DDict, System, Batch, Policy
56from dragon.ai.agent.tools import ToolRegistry
57from dragon.ai.agent.orchestrator import DAGOrchestrator
58from dragon.native.event import Event
59from dragon.native.machine import Node
60from dragon.native.process import Process
61from dragon.native.queue import Queue
62
63from dragon.ai.inference import (
64 BatchingConfig,
65 HardwareConfig,
66 InferenceConfig,
67 ModelConfig,
68 Inference,
69)
70
71# ---------------------------------------------------------------------------
72# Style 1: Import existing sync tools, register via registry.register(fn)
73# ---------------------------------------------------------------------------
74from tools import propose_experiment
75from tools.runner import (
76 launch_experiment,
77 check_progress,
78 collect_results,
79 cleanup_experiment_state,
80)
81
82
83# ===========================================================================
84# User-configurable constants
85# ===========================================================================
86
87MODEL_NAME = "/path/to/your/model"
88HF_TOKEN = ""
89
90
91# ===========================================================================
92# Inference Pipeline Configuration
93# ===========================================================================
94
95INFERENCE_CONFIG = InferenceConfig(
96 model=ModelConfig(
97 model_name=MODEL_NAME,
98 hf_token=HF_TOKEN,
99 tp_size=2,
100 max_tokens=8192,
101 max_model_len=32768,
102 ),
103 hardware=HardwareConfig(
104 num_nodes=1,
105 num_gpus=2,
106 num_inf_workers_per_cpu=1,
107 ),
108 batching=BatchingConfig(
109 batch_wait_seconds=0.1,
110 max_batch_size=32,
111 ),
112)
113
114
115# ===========================================================================
116# Tool registries — demonstrating all registration styles
117# ===========================================================================
118
119# --- Style 1: registry.register(fn) — sync tools from tools/ package ------
120planner_registry = ToolRegistry()
121planner_registry.register(propose_experiment)
122
123runner_registry = ToolRegistry()
124runner_registry.register(launch_experiment)
125runner_registry.register(check_progress)
126runner_registry.register(collect_results)
127
128# --- Style 2: @registry.tool — inline async tool --------------------------
129# The decorator detects async callables automatically. :param: annotations
130# are extracted into the JSON schema the LLM receives.
131analyzer_registry = ToolRegistry()
132
133
134@analyzer_registry.tool
135async def analyze_convergence(results: list) -> dict:
136 """Analyse Monte Carlo convergence from experiment results.
137
138 Computes convergence rate (slope of log-log fit) and determines
139 whether the experiment meets the theoretical 1/sqrt(n) rate.
140
141 :param results: List of dicts with keys 'n_samples' and 'absolute_error'.
142 """
143 await asyncio.sleep(0.01) # simulate async I/O
144
145 if not results or len(results) < 2:
146 return {"error": "Need at least 2 data points."}
147
148 sorted_r = sorted(results, key=lambda r: r["n_samples"])
149 errors = [r["absolute_error"] for r in sorted_r]
150 samples = [r["n_samples"] for r in sorted_r]
151
152 log_n = [math.log10(n) for n in samples]
153 log_e = [math.log10(max(e, 1e-12)) for e in errors]
154 slope = (log_e[-1] - log_e[0]) / (log_n[-1] - log_n[0])
155
156 return {
157 "convergence_rate": round(slope, 4),
158 "expected_rate": -0.5,
159 "best_error": min(errors),
160 "worst_error": max(errors),
161 "n_experiments": len(results),
162 "meets_theory": abs(slope - (-0.5)) < 0.15,
163 }
164
165
166# --- Style 3: @registry.tool — inline sync tool ---------------------------
167reporter_registry = ToolRegistry()
168
169
170@reporter_registry.tool
171def format_results_table(
172 results: list,
173 convergence_info: dict,
174 plan_summary: str,
175) -> dict:
176 """Format experiment results into a Markdown table for the final report.
177
178 :param results: List of result dicts from collect_results.
179 :param convergence_info: Dict from analyze_convergence.
180 :param plan_summary: One-line summary of the experiment plan.
181 """
182 lines = [
183 f"## Monte Carlo π Estimation — {plan_summary}",
184 "",
185 "| Samples | π Estimate | Abs Error | Wall Time (s) | Node |",
186 "|--------:|-----------:|----------:|--------------:|------|",
187 ]
188 for r in sorted(results, key=lambda x: x.get("n_samples", 0)):
189 lines.append(
190 f"| {r.get('n_samples', '?'):>7,} "
191 f"| {r.get('pi_estimate', 0):.6f} "
192 f"| {r.get('absolute_error', 0):.6f} "
193 f"| {r.get('wall_time_s', 0):>13.3f} "
194 f"| {r.get('hostname', '?')} |"
195 )
196 conv = convergence_info or {}
197 lines += [
198 "",
199 f"**Convergence rate:** {conv.get('convergence_rate', 'N/A')} "
200 f"(expected ≈ {conv.get('expected_rate', -0.5)})",
201 f"**Meets theoretical rate:** {conv.get('meets_theory', 'N/A')}",
202 ]
203 return {"markdown_table": "\n".join(lines)}
204
205
206# ===========================================================================
207# Function node: save_report
208#
209# A PipelineNode with fn= runs as a plain Python function in Dragon Batch.
210# No LLM, no agent queue. Receives TaskResult tokens from upstream nodes,
211# reads from DDict, writes files, returns a TaskResult.
212# ===========================================================================
213
214REPORT_DIR = os.environ.get("DRAGON_REPORT_DIR", os.getcwd())
215
216
217def save_report(*upstreams: TaskResult) -> TaskResult:
218 """Write the reporter's output to disk as Markdown and JSON."""
219 upstream = upstreams[0]
220 task_id = upstream.task_id
221 serialized_ddict = upstream.serialized_ddict
222
223 print(f"\n[save_report] Function node started (task_id={task_id[:8]}...)",
224 flush=True)
225
226 ddict = DDict.attach(serialized_ddict)
227 try:
228 reporter_dispatch_key = DISPATCH_ID_KEY.format(
229 task_id=task_id, agent_id="reporter_agent"
230 )
231 reporter_dispatch_id = ddict[reporter_dispatch_key]
232 reporter_result_key = RESULT_KEY.format(
233 task_id=task_id,
234 agent_id="reporter_agent",
235 dispatch_id=reporter_dispatch_id,
236 )
237 reporter_result = ddict[reporter_result_key]
238
239 report_text = (
240 reporter_result.get("response", str(reporter_result))
241 if isinstance(reporter_result, dict)
242 else str(reporter_result)
243 )
244
245 md_path = os.path.join(REPORT_DIR, "monte_carlo_report.md")
246 with open(md_path, "w") as f:
247 f.write(report_text)
248 print(f"[save_report] Written: {md_path}", flush=True)
249
250 json_path = os.path.join(REPORT_DIR, "monte_carlo_report.json")
251 artifact = {
252 "task_id": task_id,
253 "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
254 "report": report_text,
255 "source_agent": "reporter_agent",
256 }
257 with open(json_path, "w") as f:
258 json.dump(artifact, f, indent=2)
259 print(f"[save_report] Written: {json_path}", flush=True)
260
261 own_dispatch_id = f"fn-save-report-{task_id[:8]}"
262 ddict[DISPATCH_ID_KEY.format(task_id=task_id, agent_id="save_report")] = own_dispatch_id
263 ddict[RESULT_KEY.format(task_id=task_id, agent_id="save_report", dispatch_id=own_dispatch_id)] = {
264 "response": f"Report saved to:\n - {md_path}\n - {json_path}"
265 }
266 ddict[STATUS_KEY.format(task_id=task_id, agent_id="save_report", dispatch_id=own_dispatch_id)] = TaskStatus.DONE
267 finally:
268 ddict.detach()
269
270 print("[save_report] Function node complete.\n", flush=True)
271 return TaskResult(
272 task_id=task_id,
273 agent_id="save_report",
274 status=TaskStatus.DONE,
275 serialized_ddict=serialized_ddict,
276 )
277
278
279# ===========================================================================
280# DAG pipeline
281# ===========================================================================
282
283pipeline = Pipeline(nodes=[
284 PipelineNode(
285 agent_id="planner_agent",
286 task_description=(
287 "You are a scientific experiment planner. The user wants to study "
288 "Monte Carlo convergence for estimating π.\n\n"
289 "Propose an experiment plan by calling propose_experiment with:\n"
290 " - description, sample_sizes, convergence_target, methodology\n\n"
291 "Report the approved plan verbatim as your final answer."
292 ),
293 depends_on=[],
294 ),
295 PipelineNode(
296 agent_id="runner_agent",
297 task_description=(
298 "You manage parallel Monte Carlo simulations on an HPC cluster.\n\n"
299 "Tools:\n"
300 " 1. launch_experiment(sample_sizes, seeds) — launches ALL "
301 "simulations in parallel.\n"
302 " 2. check_progress() — shows done/running/pending status.\n"
303 " 3. collect_results() — call ONLY when all_done=true.\n\n"
304 "STRICT workflow:\n"
305 " 1. Call launch_experiment.\n"
306 " 2. Call check_progress until all_done=true.\n"
307 " 3. Call collect_results — REQUIRED before final answer.\n"
308 " 4. Report collect_results output verbatim."
309 ),
310 depends_on=["planner_agent"],
311 ),
312 PipelineNode(
313 agent_id="analyzer_agent",
314 task_description=(
315 "Call analyze_convergence with a list of dicts (keys: "
316 "'n_samples', 'absolute_error'). Report all metrics verbatim."
317 ),
318 depends_on=["runner_agent"],
319 ),
320 PipelineNode(
321 agent_id="reporter_agent",
322 task_description=(
323 "Write a structured report.\n\n"
324 "STRICT workflow:\n"
325 " 1. Call format_results_table to get a Markdown table.\n"
326 " 2. COPY-PASTE the actual table into your final answer.\n"
327 " 3. Include sections: Plan, Results Table, Parallel Execution,\n"
328 " Convergence Analysis, Quality Assessment, Recommendations.\n\n"
329 "Never invent data — use only data from upstream agents."
330 ),
331 depends_on=["planner_agent", "runner_agent", "analyzer_agent"],
332 ),
333 # Function node — no LLM, runs as plain Python
334 PipelineNode(
335 agent_id="save_report",
336 fn=save_report,
337 depends_on=["reporter_agent"],
338 ),
339])
340
341
342# ===========================================================================
343# Helpers
344# ===========================================================================
345
346def _make_agent_kwargs(agent_id, name, role, registry, inference_queue,
347 max_tool_call_iterations=20):
348 return {
349 "config": AgentConfig(
350 agent_id=agent_id, name=name, role=role,
351 inference_queue=inference_queue,
352 max_tool_call_iterations=max_tool_call_iterations,
353 ),
354 "tool_registry": registry,
355 "shutdown_event": Event(),
356 "reply_queue": Queue(),
357 }
358
359
360def _start_agents(specs, policies):
361 procs = []
362 for spec, policy in zip(specs, policies):
363 p = Process(target=create_sub_agent, kwargs=spec, policy=policy)
364 p.start()
365 procs.append(p)
366 queues = {}
367 for spec in specs:
368 aid = spec["config"].agent_id
369 queues[aid] = spec["reply_queue"].get()
370 print(f"[startup] Agent '{aid}' ready.", flush=True)
371 return procs, queues
372
373
374# ===========================================================================
375# Main
376# ===========================================================================
377
378async def main():
379 input_queue = Queue()
380
381 print("[startup] Initializing inference pipeline...", flush=True)
382
383 inference_pipeline = None
384 try:
385 inference_pipeline = Inference(INFERENCE_CONFIG, input_queue)
386 inference_pipeline.initialize()
387 except Exception as exc:
388 import traceback
389 print(f"\n[FATAL] Inference pipeline failed to initialize: {exc}", flush=True)
390 traceback.print_exc()
391 if inference_pipeline is not None:
392 inference_pipeline.destroy()
393 return
394 print("[startup] Inference pipeline ready.\n", flush=True)
395
396 my_alloc = System()
397 node_list = my_alloc.nodes
398 compute_host = (
399 Node(node_list[1]).hostname if len(node_list) > 1
400 else Node(node_list[0]).hostname
401 )
402 compute_policy = Policy(
403 placement=Policy.Placement.HOST_NAME,
404 host_name=compute_host,
405 )
406
407 procs, agent_specs = [], []
408 try:
409 agent_specs = [
410 _make_agent_kwargs(
411 "planner_agent", "Experiment Planner",
412 "You are an experiment planner for Monte Carlo convergence "
413 "studies on an HPC cluster.",
414 planner_registry, input_queue,
415 ),
416 _make_agent_kwargs(
417 "runner_agent", "Parallel Simulation Runner",
418 "You manage parallel Monte Carlo simulations.\n"
419 "You MUST call all three tools in order: "
420 "launch_experiment → check_progress → collect_results.\n"
421 "NEVER give a final answer without calling collect_results first.",
422 runner_registry, input_queue,
423 max_tool_call_iterations=60,
424 ),
425 _make_agent_kwargs(
426 "analyzer_agent", "Convergence Analyzer",
427 "Analyse convergence. Call analyze_convergence with "
428 "n_samples + absolute_error dicts. Report verbatim.",
429 analyzer_registry, input_queue,
430 ),
431 _make_agent_kwargs(
432 "reporter_agent", "Report Writer",
433 "Write a structured report with Markdown tables. "
434 "Always include tool output verbatim — "
435 "never use placeholder variables.",
436 reporter_registry, input_queue,
437 ),
438 ]
439 policies = [None, compute_policy, None, None]
440
441 procs, queues = _start_agents(agent_specs, policies)
442 for spec in agent_specs:
443 spec["config"].input_queue = queues[spec["config"].agent_id]
444
445 orchestrator = DAGOrchestrator(
446 config=OrchestratorConfig(
447 agents=[s["config"] for s in agent_specs],
448 poll_interval=0.5,
449 poll_timeout=14400.0,
450 ),
451 pipeline=pipeline,
452 )
453
454 user_input = (
455 "Design and run a Monte Carlo convergence experiment for π "
456 "and save the report to disk."
457 )
458
459 batch = Batch()
460 try:
461 print("=" * 60, flush=True)
462 print("Dragon AI — 02 Multi-Agent DAG + Function Node", flush=True)
463 print("=" * 60, flush=True)
464 print(f"Request: {user_input}\n", flush=True)
465
466 result = orchestrator.run(
467 user_input=user_input,
468 batch=batch,
469 )
470
471 print("\n" + "=" * 60, flush=True)
472 print("FINAL RESULT", flush=True)
473 print("=" * 60, flush=True)
474 print(result, flush=True)
475
476 except Exception as exc:
477 import traceback
478 print(f"\n[error] Pipeline failed: {exc}", flush=True)
479 traceback.print_exc()
480 finally:
481 orchestrator.destroy()
482 batch.join()
483
484 except Exception as exc:
485 import traceback
486 print(f"\n[error] Fatal: {exc}", flush=True)
487 traceback.print_exc()
488 finally:
489 cleanup_experiment_state()
490 for spec in agent_specs:
491 try:
492 spec["shutdown_event"].set()
493 except Exception:
494 pass
495 for p in procs:
496 try:
497 p.join()
498 except Exception:
499 pass
500 print("\n[teardown] All agents stopped.", flush=True)
501 try:
502 inference_pipeline.destroy()
503 except Exception:
504 pass
505 print("[teardown] Inference pipeline stopped.", flush=True)
506
507
508if __name__ == "__main__":
509 mp.set_start_method("dragon")
510 asyncio.run(main())
Key Concepts
DAG Topology:
Each PipelineNode can list multiple upstream agent IDs. The framework
waits for all upstream nodes to complete, then passes their results to the
downstream agent’s context automatically.
Function Nodes:
A PipelineNode(fn=...) runs pure Python (no LLM). Useful for:
Aggregating/reporting results
Data transformation
Writing to disk or database
Any non-agentic computation
All Three Registration Styles:
Direct callable:
registry.register(existing_function)Async decorator:
@registry.tool async def ...Sync decorator:
@registry.tool def ...
All generate JSON schemas automatically from parameter annotations.
Installation
See Example 01 (same dependencies).
System Description
Tested on HPE Cray EX:
1–2 nodes sufficient
Main vLLM backend: 1–2 GPUs
Agents: any available CPU
How to Run
dragon 02_multi_agent_dag.py
Example output:
$ dragon 02_multi_agent_dag.py
Planner: Proposing strategy...
Runner: Executing strategy...
Analyzer: Reviewing execution...
Reporter: Generating final report...
Report saved to: report.txt
Next Steps
03 — Human-in-the-Loop (add approval gates before tool execution)