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:

Listing 29 02_multi_agent_dag.py: Multi-agent DAG with function nodes
  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:

  1. Direct callable: registry.register(existing_function)

  2. Async decorator: @registry.tool async def ...

  3. 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)