06 — Self-Driving Lab

The capstone that combines the earlier abilities into one workflow: an on-cluster LLM planner + judge (03), a Dragon Batch experiment ensemble whose live progress and bulk output ride a DDict (05), and real-time mid-flight abort via a DDict flag (04) — with the agents placed across two nodes by Policy.

What you’ll learn:

  • How a LangGraph loop drives an optimization campaign end-to-end

  • How the supervisor runs an experiment ensemble via Dragon Batch

  • How a shared DDict acts as a two-way message board for live supervision

  • How a diverging run is aborted mid-flight and its compute reclaimed

Needs: 1 node (GPU optional — the LLM judge is optional).

The Flow (orchestration, not the science)

This is a self-driving optimization campaign: an AI loop that repeatedly proposes experiments, runs them, watches them live, kills the bad ones early, and refines toward the best result. The scientific kernel is a deliberately simple stand-in — the point is the orchestration.

It is an ordinary LangGraph StateGraph with three nodes wired into a cycle:

START -> planner -> supervisor -> analyzer --(refine?)--> planner
                                      |
                                   (converged?) --> END
  • planner — proposes N candidate experiments (each is one parameter).

  • supervisor — runs those N experiments and babysits them live.

  • analyzer — ranks the finished ones, updates the “best so far”, and takes the conditional edge: refine (loop) or converged (END).

Three Dragon pieces sit underneath the graph:

Piece

Role

DragonExecutor

Launches the AgentHost processes and runs each agent as a thread inside one (the LangGraph integration). The planner + analyzer are two threads in one host process; the supervisor is a thread in a second host process on another node.

Batch

A separate Dragon scheduler that runs the experiment tasks across the allocation.

DDict

A shared distributed dictionary — the “blackboard” every party reads and writes.

Placement. The planner + analyzer share one host process (head node); the supervisor runs in a second host process (compute node); Batch schedules the experiments wherever there is room. On a single-node allocation everything resolves to the one node.

One round in detail — what the supervisor actually does:

receive the candidate list from the planner
  |
  +- for each candidate: submit it to Batch as a task  --> Batch runs the
  |    (submission returns immediately; tasks run async)    experiments
  |
  +- enter a POLL LOOP (every POLL_INTERVAL seconds):
        for each still-running experiment:
           read  progress:{id}  from the DDict   <-- experiments write here
           |
           +- if it looks like it is diverging:
           |     write  control:{id} = "abort"   --> experiment reads this
           |                                          flag and stops itself
           |
           +- if the Batch task finished:
                 record its outcome; drop it from the pending set
  |
  +- when all experiments are done/aborted -> return outcomes to the analyzer

The key mechanism is the DDict used as a two-way message board:

  • experiments write their live progress → progress:{id}

  • the supervisor reads that progress and, if a run goes bad, writes an abort flag → control:{id}

  • the experiment reads its own abort flag each step and, if set, stops itself and reports aborted.

That is the whole “real-time supervision” idea: nobody waits for an experiment to finish before deciding it is a dud — a diverging run is stopped mid-flight, freeing its compute for the next round.

Note

The LLM judge is optional. With USE_LLM_JUDGE=1 (and a GPU) an on-cluster vLLM model makes the “is this diverging?” call; otherwise a simple threshold rule decides. Either way the flow above is identical.

Main Code

Listing 39 06_self_driving_lab.py
  1"""
  206 - Self-driving lab: the full agentic-AI-on-HPC workflow.
  3=============================================================================
  4A SELF-DRIVING LAB on HPC -- the capstone, built on **Dragon + LangGraph**.
  5
  6This combines the abilities from earlier examples into one workflow: an LLM
  7planner served on-cluster (03) proposes experiments, the data-plane discipline
  8from the crossover example (05) keeps bulk fields out of the coordinator, and a
  9supervisor watches runs live and aborts diverging ones mid-flight (04).
 10Experiments must be watched *while they run*, and a diverging run must be killed
 11*immediately* so its compute can be reclaimed for promising candidates.
 12
 13This example shows all three pillars of why Dragon is powerful for agentic
 14AI on HPC, in ONE workflow:
 15
 16  (A) LOCAL INFERENCE.  A planner agent calls an LLM served *on the cluster*
 17      by Dragon Inference (vLLM behind a queue -- no HTTP, no external API)
 18      to propose the next batch of experiments.  [Optional; rule-based
 19      fallback so the demo runs without GPUs.]
 20
 21  (B) SCIENTIFIC WORKFLOW.  Each experiment is a small sinusoidal solver
 22      submitted as a Dragon **Batch** task, scheduled across the allocation.
 23      It writes both its live progress and its bulk output into a user-managed
 24      DDict (zero-copy, never through the coordinator).
 25
 26  (C) REAL-TIME ACTION -- the headline.  Experiments STREAM progress into the
 27      DDict as they run.  A supervisor agent polls those DDict entries live
 28      and, the instant a run diverges, sets its abort flag in the DDict.  The
 29      experiment sees the flag and stops within one reporting interval.
 30      No waiting for completion.  No reading data after the fact.  The agent
 31      steers the lab in real time.
 32
 33Why pure LangGraph cannot do this
 34---------------------------------
 35LangGraph nodes are opaque function calls: a node blocks until it returns,
 36and the framework has NO handle to the computation running inside it.  You
 37cannot observe a half-finished experiment, and you cannot stop it.  Dragon
 38gives the agent first-class handles -- Batch tasks and a shared DDict -- so the
 39agent can supervise and intervene mid-flight.  That is
 40an architectural capability, not a feature you can bolt on.
 41
 42The flow
 43-----------------------------------------
 44This is a self-driving optimization campaign: an AI loop repeatedly (1) proposes
 45a batch of experiments, (2) runs them in parallel, (3) watches them live and
 46kills the bad ones early, and (4) inspects the survivors, keeps the best, and
 47decides whether to stop or iterate with better guesses.
 48
 49It is an ordinary LangGraph ``StateGraph`` with three nodes wired into a cycle::
 50
 51    START -> planner -> supervisor -> analyzer --(refine?)--> planner
 52                                          |
 53                                       (converged?) --> END
 54
 55  * planner    -- proposes N candidate experiments (each is one parameter).
 56  * supervisor -- runs those N experiments and babysits them LIVE.
 57  * analyzer   -- ranks the finished ones, updates the "best so far", and takes
 58                  the conditional edge: refine (loop) or converged (END).
 59
 60Three Dragon pieces sit underneath the graph:
 61
 62  * DragonExecutor -- launches the AgentHost processes and runs each agent
 63                      inside one (this integration).  planner + analyzer run in
 64                      one host process; the supervisor runs in a second host
 65                      process on another node.
 66  * Batch          -- a separate Dragon scheduler that runs the experiment tasks
 67                      across the allocation.
 68  * DDict          -- a shared distributed dictionary; the "blackboard" that
 69                      every party reads and writes.
 70
 71Placement: planner + analyzer share one host process (head node); the supervisor
 72runs in a second host process (compute node); Batch schedules the experiments
 73wherever there is room.
 74
 75One round in detail -- what the supervisor actually does::
 76
 77    receive the candidate list from the planner
 78      |
 79      +- for each candidate: submit it to Batch as a task  --> Batch runs the
 80      |    (submission returns immediately; tasks run async)    experiments
 81      |
 82      +- enter a POLL LOOP (every POLL_INTERVAL seconds):
 83            for each still-running experiment:
 84               read  progress:{id}  from the DDict   <-- experiments write here
 85               |
 86               +- if it looks like it is diverging:
 87               |     write  control:{id} = "abort"   --> experiment reads this
 88               |                                          flag and stops itself
 89               |
 90               +- if the Batch task finished:
 91                     record its outcome; drop it from the pending set
 92      |
 93      +- when all experiments are done/aborted -> return outcomes to the analyzer
 94
 95The key mechanism is the DDict used as a two-way message board:
 96
 97  * experiments WRITE their live progress   -> ``progress:{id}``
 98  * the supervisor READS that progress and, if a run goes bad, WRITES an abort
 99    flag                                     -> ``control:{id}``
100  * the experiment READS its own abort flag each step and, if set, STOPS itself
101    and reports "aborted".
102
103That is the whole "real-time supervision" idea: nobody waits for an experiment
104to finish before deciding it is a dud -- a diverging run is stopped mid-flight,
105freeing its compute for the next round.
106
107Run
108---
109    dragon examples/dragon_ai/langgraph/06_self_driving_lab.py            # rule-based judge
110    USE_LLM_JUDGE=1 dragon examples/dragon_ai/langgraph/06_self_driving_lab.py   # + local LLM
111
112Agents are placed across two hosts via ``Policy``: the control plane (planner +
113analyzer) on the head node, the supervisor on a compute node. On a single-node
114allocation both policies resolve to the same node and the demo still runs.
115=============================================================================
116"""
117
118from __future__ import annotations
119
120import dragon
121import multiprocessing as mp
122import operator
123import os
124import time
125from functools import partial
126from typing import Annotated, Any, TypedDict
127
128import numpy as np
129
130# -- Standard LangGraph imports -----------------------------------------------
131from langgraph.graph import END, START, StateGraph
132
133# -- The Dragon + LangGraph integration ---------------------------------------
134from dragon.ai.langgraph import DragonExecutor
135
136# -- Dragon Batch (schedules the experiment tasks across the allocation) -------
137from dragon.workflows.batch import Batch, TaskNotReadyError
138
139# -- Dragon Inference (on-cluster vLLM behind a queue) ------------------------
140from dragon.ai.inference.config import (
141    BatchingConfig,
142    HardwareConfig,
143    InferenceConfig,
144    ModelConfig,
145)
146from dragon.ai.inference.inference_utils import Inference
147from dragon.ai.inference.llm_proxy import DragonQueueLLMProxy
148from dragon.native.queue import Queue
149
150# -- Dragon placement ---------------------------------------------------------
151from dragon.infrastructure.policy import Policy
152from dragon.native.machine import Node, System
153from dragon.native.process import current as current_process
154
155
156# =============================================================================
157# Lab configuration
158# =============================================================================
159
160N_CANDIDATES = 6           # experiments launched per round
161MAX_ITERATIONS = 3         # outer optimization rounds
162TARGET_FREQ = 1.0          # frequency the campaign is optimizing toward
163STABLE_FREQ = 1.5          # experiments with freq above this resonate & diverge
164DIVERGE_THRESHOLD = 50.0   # supervisor aborts a run once its amplitude exceeds this
165TOTAL_STEPS = 60           # iterations per experiment
166REPORT_EVERY = 5           # write a progress event every N steps
167POLL_INTERVAL = 0.2        # how often the supervisor polls DDict progress (s)
168DDICT_MEM = 1 * 1024**3    # 1 GiB managed heap (waveforms are small)
169USE_LLM_JUDGE = os.environ.get("USE_LLM_JUDGE") == "1"
170
171
172# =============================================================================
173# Graph state -- only lightweight handles cross the graph, never bulk data.
174# =============================================================================
175
176class LabState(TypedDict):
177    iteration: int
178    candidates: list[dict]                              # params proposed this round
179    outcomes: list[dict]                               # per-experiment summaries
180    best_score: float
181    best_params: dict
182    history: Annotated[list[float], operator.add]
183    aborted_count: int                                # interventions this round
184    done: bool
185
186
187# =============================================================================
188# DDict attach helper (attach-once-and-cache inside long-lived host threads).
189# =============================================================================
190
191_DDICT_CACHE: dict[bytes, Any] = {}
192
193
194def _get_ddict(serialized: bytes) -> Any:
195    d = _DDICT_CACHE.get(serialized)
196    if d is None:
197        from dragon.data.ddict import DDict
198        d = DDict.attach(serialized)
199        _DDICT_CACHE[serialized] = d
200    return d
201
202
203# =============================================================================
204# Placement helper -- resolve the caller's host process into a readable label.
205# ``current().node`` is only a numeric index (0..#nodes-1); ``h_uid`` maps to
206# the actual hostname via ``Node``, so we print both to make placement obvious.
207# =============================================================================
208
209def _where() -> str:
210    me = current_process()
211    hostname = Node(me.h_uid).hostname
212    return f"puid={me.puid} node={me.node} host={hostname}"
213
214
215# =============================================================================
216# THE EXPERIMENT  (runs as a Dragon Batch task, scheduled onto the allocation).
217#
218# A tiny sinusoidal solver: value(step) = amp * sin(freq * step).  A stable run
219# is a clean sine wave (amp stays 1); a run with freq > STABLE_FREQ resonates
220# and its amplitude blows up.  It is iterative, STREAMS its progress into the
221# DDict, and is INTERRUPTIBLE: it checks its abort flag (also in the DDict)
222# every reporting interval.  Replace the body with a real PDE/MD/CFD step; the
223# DDict streaming + abort contract is identical.
224# =============================================================================
225
226def _run_experiment(exp_id: str, params: dict, ddict_ser: bytes,
227                    total_steps: int) -> dict:
228    """One sinusoidal experiment, run as a Dragon Batch task. Streams progress
229    into the DDict and obeys a mid-flight abort flag read from the DDict."""
230    print(f"  [experiment {exp_id}] {_where()} started")
231    ddict = _get_ddict(ddict_ser)
232    ddict[f"control:{exp_id}"] = "run"
233
234    freq = params["freq"]
235    unstable = freq > STABLE_FREQ
236    waveform = np.zeros(total_steps, dtype=np.float32)
237
238    amp = 1.0
239    for step in range(1, total_steps + 1):
240        if unstable:
241            amp *= 1.15                          # resonance blow-up
242        waveform[step - 1] = amp * float(np.sin(freq * step))
243        time.sleep(0.02)                         # stand-in for real solver flops
244
245        if step % REPORT_EVERY == 0 or step == total_steps:
246            # Stream a live progress event INTO the DDict.
247            ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": step,
248                                           "metric": amp, "status": "running"}
249            # Read our abort flag FROM the DDict.
250            if ddict.get(f"control:{exp_id}") == "abort":
251                ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": step,
252                                               "metric": amp, "status": "aborted"}
253                return {"id": exp_id, "status": "aborted"}
254
255    # --- completed: store the full waveform (bulk) and score vs target freq --
256    ddict[f"wave:{exp_id}"] = waveform            # zero-copy bulk artifact
257    score = -abs(freq - TARGET_FREQ)              # closeness to the target frequency
258    ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": total_steps,
259                                   "metric": amp, "status": "done",
260                                   "score": float(score),
261                                   "wave_key": f"wave:{exp_id}"}
262    return {"id": exp_id, "status": "done", "score": float(score)}
263
264
265# =============================================================================
266# Divergence judgment -- the "decision" the supervisor makes in real time.
267# Rule-based by default; optional local-LLM judge to show inference-in-the-loop.
268# =============================================================================
269
270def judge_divergence(event: dict, llm: Any | None) -> bool:
271    """Return True if this *running* experiment should be aborted now."""
272    metric = event["metric"]
273    if not np.isfinite(metric) or metric > DIVERGE_THRESHOLD:
274        if llm is not None:
275            # Inference-in-the-loop: let a cluster-served LLM make the call.
276            # A real prompt would include recent metric history / physics context.
277            import asyncio
278            verdict = asyncio.run(llm.chat([
279                {"role": "system", "content": "You are an experiment supervisor."},
280                {"role": "user", "content":
281                    f"Experiment {event['id']} metric={metric:.2f} at step "
282                    f"{event['step']}. Diverging? Answer ABORT or CONTINUE."},
283            ]))
284            return "ABORT" in str(verdict).upper()
285        return True            # rule-based: clear divergence
286    return False
287
288
289# =============================================================================
290# Agent 1 -- PLANNER (optionally LLM-driven, on cluster-local inference)
291# =============================================================================
292
293def planner_node(state: LabState, *, llm: Any | None = None) -> dict:
294    """Propose the next batch of experiments (one frequency each). Deliberately
295    seeds some unstable (freq > STABLE_FREQ) candidates so aborts are visible."""
296    it = state.get("iteration", 0)
297    rng = np.random.default_rng(seed=100 + it)
298
299    if it == 0:
300        center, spread = TARGET_FREQ, 0.8       # straddle the stability edge
301    else:
302        center = state["best_params"]["freq"]
303        spread = 0.5 / (it + 1)
304
305    # (Optional) ask a cluster-served LLM to nudge the search -- here we just
306    # show the call site; the numeric proposal below is the executable default.
307    if llm is not None:
308        import asyncio
309        _ = asyncio.run(llm.chat([
310            {"role": "system", "content": "You are an experiment design AI."},
311            {"role": "user", "content":
312                f"Round {it}; best params {state.get('best_params', {})}. "
313                "Suggest exploration spread."},
314        ]))
315
316    candidates = [{
317        "id": f"it{it}-e{i}",
318        "freq": float(center + rng.normal(0, spread)),
319    } for i in range(N_CANDIDATES)]
320
321    print(f"[planner] {_where()} round {it}: "
322          f"proposed {len(candidates)} experiments")
323    return {"iteration": it, "candidates": candidates}
324
325
326# =============================================================================
327# Agent 2 -- LAB SUPERVISOR: submit to Batch, MONITOR LIVE, and INTERVENE.
328#
329# This is the real-time core.  It submits each experiment as a Dragon Batch
330# task, then polls the DDict for the progress each task streams into it.  The
331# moment a progress entry shows divergence, it flips that experiment's abort
332# flag in the DDict -- reclaiming the slot without waiting for the run to finish.
333# =============================================================================
334
335def supervisor_node(state: LabState, *, ddict_ser: bytes, batch: Any,
336                    llm: Any | None = None) -> dict:
337    """Submit each experiment as a Dragon Batch task, then supervise LIVE by
338    polling the DDict. The instant a run diverges, flip its abort flag in the
339    DDict; the task sees it and self-terminates within one reporting interval."""
340    ddict = _get_ddict(ddict_ser)
341    candidates = state["candidates"]
342
343    # -- Submit every experiment as an independent Batch task (non-blocking) --
344    # Batch schedules these across the allocation; submission returns at once.
345    tasks: dict[str, Any] = {}
346    for c in candidates:
347        eid = c["id"]
348        ddict[f"control:{eid}"] = "run"
349        ddict[f"progress:{eid}"] = {"id": eid, "step": 0, "metric": 0.0,
350                                    "status": "running"}
351        tasks[eid] = batch.function(_run_experiment, eid, c, ddict_ser, TOTAL_STEPS)
352    print(f"[supervisor] {_where()} submitted "
353          f"{len(tasks)} Batch experiments; supervising via DDict...")
354
355    # -- Poll the DDict for live progress and intervene in real time ---------
356    pending = set(tasks)
357    aborting: set[str] = set()
358    outcomes: list[dict] = []
359    while pending:
360        time.sleep(POLL_INTERVAL)
361        for eid in list(pending):
362            prog = ddict.get(f"progress:{eid}")
363
364            # (1) live intervention while the task is still running
365            if prog and prog["status"] == "running" and eid not in aborting:
366                if judge_divergence(prog, llm):
367                    ddict[f"control:{eid}"] = "abort"     # mid-flight, via DDict
368                    aborting.add(eid)
369                    print(f"[supervisor]  ! ABORT {eid} at step {prog['step']} "
370                          f"(metric={prog['metric']:.1f} diverging) -> reclaiming slot")
371
372            # (2) has the Batch task finished? (authoritative completion signal)
373            try:
374                tasks[eid].get(block=False)               # non-blocking reap
375            except TaskNotReadyError:
376                continue                                  # still running
377            except Exception:                             # worker raised
378                outcomes.append({"id": eid, "status": "error", "score": None})
379                pending.discard(eid)
380                continue
381
382            # (3) task done -- read its final outcome from the DDict
383            final = ddict.get(f"progress:{eid}") or {"status": "done"}
384            if final["status"] == "aborted":
385                outcomes.append({"id": eid, "status": "aborted", "score": None})
386            else:
387                outcomes.append({"id": eid, "status": "done",
388                                 "score": final.get("score"),
389                                 "wave_key": final.get("wave_key")})
390                print(f"[supervisor]  + DONE  {eid} score={final.get('score'):.4f}")
391            pending.discard(eid)
392
393    batch.fence()   # ensure this round's Batch DAG is fully drained
394    n_done = sum(1 for o in outcomes if o["status"] == "done")
395    print(f"[supervisor] round complete: {n_done} finished, "
396          f"{len(aborting)} aborted early")
397    return {"outcomes": outcomes, "aborted_count": len(aborting)}
398
399
400# =============================================================================
401# Agent 3 -- ANALYZER: rank survivors (zero-copy reads), update incumbent,
402# decide convergence, and free spent artifacts.
403# =============================================================================
404
405def analyzer_node(state: LabState, *, ddict_ser: bytes) -> dict:
406    ddict = _get_ddict(ddict_ser)
407
408    survivors = [o for o in state["outcomes"] if o["status"] == "done"]
409    if not survivors:
410        # Everything diverged -- widen the search next round.
411        print(f"[analyzer] {_where()} no survivors this round")
412        it = state["iteration"]
413        return {"iteration": it + 1, "history": [state.get("best_score", -1e9)],
414                "done": it + 1 >= MAX_ITERATIONS}
415
416    # Rank by the figure of merit; the winner needs its full waveform read back.
417    survivors.sort(key=lambda o: o["score"], reverse=True)
418    champ = survivors[0]
419    champ_wave = ddict[champ["wave_key"]]              # zero-copy bulk read
420    rms = float(np.sqrt(np.mean(champ_wave ** 2)))     # a "needs whole array" metric
421    best_params = next(c for c in state["candidates"] if c["id"] == champ["id"])
422
423    prev_best = state.get("best_score", float(-np.inf))
424    improved = champ["score"] - prev_best
425
426    # User-managed cleanup: keep the champion, free the rest.
427    for o in survivors[1:]:
428        if o["wave_key"] in ddict:
429            del ddict[o["wave_key"]]
430
431    it = state["iteration"]
432    converged = (it + 1 >= MAX_ITERATIONS) or (it > 0 and abs(improved) < 1e-3)
433    print(f"[analyzer] {_where()} round {it}: "
434          f"champion={champ['id']} score={champ['score']:.4f} "
435          f"(wave rms={rms:.3f}) -> {'CONVERGED' if converged else 'refine'}")
436
437    return {
438        "best_score": max(prev_best, champ["score"]),
439        "best_params": best_params,
440        "history": [champ["score"]],
441        "iteration": it + 1,
442        "done": converged,
443    }
444
445
446# =============================================================================
447# Routing
448# =============================================================================
449
450def route_after_analysis(state: LabState) -> str:
451    return END if state["done"] else "planner"
452
453
454# =============================================================================
455# Optional: build a cluster-local LLM proxy (Dragon Inference / vLLM).
456# Returns (llm_proxy, inference_handle). The handle is kept alive for the
457# campaign and destroyed in main()'s finally block; it is None when the
458# rule-based judge is used.
459# =============================================================================
460
461def _maybe_build_llm() -> tuple[Any | None, Any | None]:
462    if not USE_LLM_JUDGE:
463        return None, None
464
465    # Configure a small on-cluster vLLM pipeline. Adjust the model and GPU
466    # counts to match your allocation; set HF_TOKEN in the environment for
467    # gated models (e.g. Llama).
468    config = InferenceConfig(
469        model=ModelConfig(
470            model_name="meta-llama/Llama-3.1-8B-Instruct",
471            hf_token=os.environ.get("HF_TOKEN", ""),
472            tp_size=1,               # GPUs per inference worker (tensor parallel)
473            max_tokens=64,           # the judge only needs ABORT / CONTINUE
474            max_model_len=4096,
475        ),
476        hardware=HardwareConfig(
477            num_nodes=1,             # inference on one node
478            num_gpus=1,              # one GPU on that node
479        ),
480        batching=BatchingConfig(
481            enabled=True,
482            batch_wait_seconds=0.05,
483            max_batch_size=32,
484        ),
485    )
486
487    # Launch the pipeline and hand the agents a queue-backed proxy.
488    input_queue = Queue(maxsize=256)
489    inference = Inference(config=config, input_queue=input_queue)
490    inference.initialize()
491    print("[setup] on-cluster vLLM inference pipeline ready")
492
493    llm = DragonQueueLLMProxy(input_queue, max_concurrent_requests=16)
494    return llm, inference
495
496
497# =============================================================================
498# Main
499# =============================================================================
500
501def main() -> None:
502    from dragon.data.ddict import DDict
503
504    print(f"[orchestrator] {_where()}")
505
506    ddict = DDict(managers_per_node=1, n_nodes=1, total_mem=DDICT_MEM)
507    ddict_ser = ddict.serialize()
508
509    llm, inference = _maybe_build_llm()
510
511    # A Batch runtime schedules the experiment tasks across the allocation. The
512    # supervisor submits into it; the experiments stream progress via the DDict.
513    # It is created with managed_lifecycle=True because the supervisor runs in a
514    # *different* process (its agent host) and receives its own Batch client via
515    # cloudpickle -- that copy is torn down with the host, so we shut the shared
516    # runtime down explicitly from here with a forced grace period.
517    batch = Batch(managed_lifecycle=True)
518    print(f"[setup] {batch.topology()}")
519
520    # -- Discover the allocation and build one Policy per host ----------------
521    # Two hosts are placed on two nodes: the control plane (planner + analyzer)
522    # on the head node, and the supervisor -- which submits and monitors the
523    # experiment tasks -- on a compute node. If the allocation has only one
524    # node, both policies resolve to it (the demo still runs).
525    nodes = System().nodes
526    head_hostname = Node(nodes[0]).hostname
527    compute_hostname = Node(nodes[1 % len(nodes)]).hostname
528    head_policy = Policy(placement=Policy.Placement.HOST_NAME,
529                         host_name=head_hostname)
530    compute_policy = Policy(placement=Policy.Placement.HOST_NAME,
531                            host_name=compute_hostname)
532    print(f"[setup] head node={head_hostname}, compute node={compute_hostname}")
533
534    try:
535        with DragonExecutor() as executor:
536            # Host 0 (head node): control-plane agents -- planning and ranking.
537            executor.launch_host(
538                agents={
539                    "planner": partial(planner_node, llm=llm),
540                    "analyzer": partial(analyzer_node, ddict_ser=ddict_ser),
541                },
542                policy=head_policy,
543            )
544            # Host 1 (compute node): the supervisor, which submits the experiment
545            # tasks to Batch and polls the DDict live. It needs extra threads to
546            # run its monitoring loop alongside the submitted work.
547            executor.launch_host(
548                agents={
549                    "supervisor": partial(supervisor_node, ddict_ser=ddict_ser,
550                                          batch=batch, llm=llm),
551                },
552                max_threads=8,
553                policy=compute_policy,
554            )
555
556            # Equivalent one-call form via launch_hosts(): pass a list of agent
557            # dicts and a matching list of policies (applied in order). Note that
558            # max_threads here would apply to every host, so the two-call form
559            # above is used when hosts need different max_threads.
560            #
561            #   executor.launch_hosts(
562            #       hosts=[
563            #           {"planner": partial(planner_node, llm=llm),
564            #            "analyzer": partial(analyzer_node, ddict_ser=ddict_ser)},
565            #           {"supervisor": partial(supervisor_node, ddict_ser=ddict_ser,
566            #                                  batch=batch, llm=llm)},
567            #       ],
568            #       policies=[head_policy, compute_policy],
569            #   )
570
571            builder = StateGraph(LabState)
572            builder.add_node("planner", executor.node("planner"))
573            builder.add_node("supervisor", executor.node("supervisor"))
574            builder.add_node("analyzer", executor.node("analyzer"))
575
576            builder.add_edge(START, "planner")
577            builder.add_edge("planner", "supervisor")
578            builder.add_edge("supervisor", "analyzer")
579            builder.add_conditional_edges("analyzer", route_after_analysis,
580                                          ["planner", END])
581
582            graph = builder.compile()
583
584            config = {"recursion_limit": 100}
585            initial = {"iteration": 0, "outcomes": [], "best_score": float(-np.inf),
586                       "history": [], "aborted_count": 0, "done": False}
587
588            print("\n=== Self-driving lab (Dragon + LangGraph) ===")
589            final = graph.invoke(initial, config)
590
591            print("\n=== Lab campaign complete ===")
592            print(f"  rounds run    : {final['iteration']}")
593            print(f"  best params   : {final.get('best_params')}")
594            print(f"  best score    : {final['best_score']:.4f}")
595            print(f"  score history : {[round(h, 4) for h in final['history']]}")
596    finally:
597        # Force the shared Batch runtime down after a short grace period: the
598        # supervisor's cloudpickled Batch client lived in the (now stopped) agent
599        # host and can never detach on its own, so we don't wait on it forever.
600        batch.destroy(force_timeout=30)
601        if inference is not None:
602            inference.destroy()
603        ddict.destroy()
604
605
606if __name__ == "__main__":
607    mp.set_start_method("dragon", force=True)
608    main()

See Also