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
BatchHow a shared
DDictacts as a two-way message board for live supervisionHow 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 |
|---|---|
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. |
|
A separate Dragon scheduler that runs the experiment tasks across the allocation. |
|
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
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 import Batch
138from dragon.workflows.batch import TaskNotReadyError
139
140# -- Dragon Inference (on-cluster vLLM behind a queue) ------------------------
141from dragon.ai.inference.config import (
142 BatchingConfig,
143 HardwareConfig,
144 InferenceConfig,
145 ModelConfig,
146)
147from dragon.ai.inference.inference_utils import Inference
148from dragon.ai.inference.llm_proxy import DragonQueueLLMProxy
149from dragon.native.queue import Queue
150
151# -- Dragon placement ---------------------------------------------------------
152from dragon import (
153 Policy,
154 System
155)
156from dragon.native.machine import Node
157from dragon.native.process import current as current_process
158
159# =============================================================================
160# Lab configuration
161# =============================================================================
162
163N_CANDIDATES = 6 # experiments launched per round
164MAX_ITERATIONS = 3 # outer optimization rounds
165TARGET_FREQ = 1.0 # frequency the campaign is optimizing toward
166STABLE_FREQ = 1.5 # experiments with freq above this resonate & diverge
167DIVERGE_THRESHOLD = 50.0 # supervisor aborts a run once its amplitude exceeds this
168TOTAL_STEPS = 60 # iterations per experiment
169REPORT_EVERY = 5 # write a progress event every N steps
170POLL_INTERVAL = 0.2 # how often the supervisor polls DDict progress (s)
171DDICT_MEM = 1 * 1024**3 # 1 GiB managed heap (waveforms are small)
172USE_LLM_JUDGE = os.environ.get("USE_LLM_JUDGE") == "1"
173
174
175# =============================================================================
176# Graph state -- only lightweight handles cross the graph, never bulk data.
177# =============================================================================
178
179class LabState(TypedDict):
180 iteration: int
181 candidates: list[dict] # params proposed this round
182 outcomes: list[dict] # per-experiment summaries
183 best_score: float
184 best_params: dict
185 history: Annotated[list[float], operator.add]
186 aborted_count: int # interventions this round
187 done: bool
188
189
190# =============================================================================
191# DDict attach helper (attach-once-and-cache inside long-lived host threads).
192# =============================================================================
193
194_DDICT_CACHE: dict[bytes, Any] = {}
195
196
197def _get_ddict(serialized: bytes) -> Any:
198 d = _DDICT_CACHE.get(serialized)
199 if d is None:
200 from dragon import DDict
201 d = DDict.attach(serialized)
202 _DDICT_CACHE[serialized] = d
203 return d
204
205
206# =============================================================================
207# Placement helper -- resolve the caller's host process into a readable label.
208# ``current().node`` is only a numeric index (0..#nodes-1); ``h_uid`` maps to
209# the actual hostname via ``Node``, so we print both to make placement obvious.
210# =============================================================================
211
212def _where() -> str:
213 me = current_process()
214 hostname = Node(me.h_uid).hostname
215 return f"puid={me.puid} node={me.node} host={hostname}"
216
217
218# =============================================================================
219# THE EXPERIMENT (runs as a Dragon Batch task, scheduled onto the allocation).
220#
221# A tiny sinusoidal solver: value(step) = amp * sin(freq * step). A stable run
222# is a clean sine wave (amp stays 1); a run with freq > STABLE_FREQ resonates
223# and its amplitude blows up. It is iterative, STREAMS its progress into the
224# DDict, and is INTERRUPTIBLE: it checks its abort flag (also in the DDict)
225# every reporting interval. Replace the body with a real PDE/MD/CFD step; the
226# DDict streaming + abort contract is identical.
227# =============================================================================
228
229def _run_experiment(exp_id: str, params: dict, ddict_ser: bytes,
230 total_steps: int) -> dict:
231 """One sinusoidal experiment, run as a Dragon Batch task. Streams progress
232 into the DDict and obeys a mid-flight abort flag read from the DDict."""
233 print(f" [experiment {exp_id}] {_where()} started")
234 ddict = _get_ddict(ddict_ser)
235 ddict[f"control:{exp_id}"] = "run"
236
237 freq = params["freq"]
238 unstable = freq > STABLE_FREQ
239 waveform = np.zeros(total_steps, dtype=np.float32)
240
241 amp = 1.0
242 for step in range(1, total_steps + 1):
243 if unstable:
244 amp *= 1.15 # resonance blow-up
245 waveform[step - 1] = amp * float(np.sin(freq * step))
246 time.sleep(0.02) # stand-in for real solver flops
247
248 if step % REPORT_EVERY == 0 or step == total_steps:
249 # Stream a live progress event INTO the DDict.
250 ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": step,
251 "metric": amp, "status": "running"}
252 # Read our abort flag FROM the DDict.
253 if ddict.get(f"control:{exp_id}") == "abort":
254 ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": step,
255 "metric": amp, "status": "aborted"}
256 return {"id": exp_id, "status": "aborted"}
257
258 # --- completed: store the full waveform (bulk) and score vs target freq --
259 ddict[f"wave:{exp_id}"] = waveform # zero-copy bulk artifact
260 score = -abs(freq - TARGET_FREQ) # closeness to the target frequency
261 ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": total_steps,
262 "metric": amp, "status": "done",
263 "score": float(score),
264 "wave_key": f"wave:{exp_id}"}
265 return {"id": exp_id, "status": "done", "score": float(score)}
266
267
268# =============================================================================
269# Divergence judgment -- the "decision" the supervisor makes in real time.
270# Rule-based by default; optional local-LLM judge to show inference-in-the-loop.
271# =============================================================================
272
273def judge_divergence(event: dict, llm: Any | None) -> bool:
274 """Return True if this *running* experiment should be aborted now."""
275 metric = event["metric"]
276 if not np.isfinite(metric) or metric > DIVERGE_THRESHOLD:
277 if llm is not None:
278 # Inference-in-the-loop: let a cluster-served LLM make the call.
279 # A real prompt would include recent metric history / physics context.
280 import asyncio
281 verdict = asyncio.run(llm.chat([
282 {"role": "system", "content": "You are an experiment supervisor."},
283 {"role": "user", "content":
284 f"Experiment {event['id']} metric={metric:.2f} at step "
285 f"{event['step']}. Diverging? Answer ABORT or CONTINUE."},
286 ]))
287 return "ABORT" in str(verdict).upper()
288 return True # rule-based: clear divergence
289 return False
290
291
292# =============================================================================
293# Agent 1 -- PLANNER (optionally LLM-driven, on cluster-local inference)
294# =============================================================================
295
296def planner_node(state: LabState, *, llm: Any | None = None) -> dict:
297 """Propose the next batch of experiments (one frequency each). Deliberately
298 seeds some unstable (freq > STABLE_FREQ) candidates so aborts are visible."""
299 it = state.get("iteration", 0)
300 rng = np.random.default_rng(seed=100 + it)
301
302 if it == 0:
303 center, spread = TARGET_FREQ, 0.8 # straddle the stability edge
304 else:
305 center = state["best_params"]["freq"]
306 spread = 0.5 / (it + 1)
307
308 # (Optional) ask a cluster-served LLM to nudge the search -- here we just
309 # show the call site; the numeric proposal below is the executable default.
310 if llm is not None:
311 import asyncio
312 _ = asyncio.run(llm.chat([
313 {"role": "system", "content": "You are an experiment design AI."},
314 {"role": "user", "content":
315 f"Round {it}; best params {state.get('best_params', {})}. "
316 "Suggest exploration spread."},
317 ]))
318
319 candidates = [{
320 "id": f"it{it}-e{i}",
321 "freq": float(center + rng.normal(0, spread)),
322 } for i in range(N_CANDIDATES)]
323
324 print(f"[planner] {_where()} round {it}: "
325 f"proposed {len(candidates)} experiments")
326 return {"iteration": it, "candidates": candidates}
327
328
329# =============================================================================
330# Agent 2 -- LAB SUPERVISOR: submit to Batch, MONITOR LIVE, and INTERVENE.
331#
332# This is the real-time core. It submits each experiment as a Dragon Batch
333# task, then polls the DDict for the progress each task streams into it. The
334# moment a progress entry shows divergence, it flips that experiment's abort
335# flag in the DDict -- reclaiming the slot without waiting for the run to finish.
336# =============================================================================
337
338def supervisor_node(state: LabState, *, ddict_ser: bytes, batch: Any,
339 llm: Any | None = None) -> dict:
340 """Submit each experiment as a Dragon Batch task, then supervise LIVE by
341 polling the DDict. The instant a run diverges, flip its abort flag in the
342 DDict; the task sees it and self-terminates within one reporting interval."""
343 ddict = _get_ddict(ddict_ser)
344 candidates = state["candidates"]
345
346 # -- Submit every experiment as an independent Batch task (non-blocking) --
347 # Batch schedules these across the allocation; submission returns at once.
348 tasks: dict[str, Any] = {}
349 for c in candidates:
350 eid = c["id"]
351 ddict[f"control:{eid}"] = "run"
352 ddict[f"progress:{eid}"] = {"id": eid, "step": 0, "metric": 0.0,
353 "status": "running"}
354 tasks[eid] = batch.function(_run_experiment, eid, c, ddict_ser, TOTAL_STEPS)
355 print(f"[supervisor] {_where()} submitted "
356 f"{len(tasks)} Batch experiments; supervising via DDict...")
357
358 # -- Poll the DDict for live progress and intervene in real time ---------
359 pending = set(tasks)
360 aborting: set[str] = set()
361 outcomes: list[dict] = []
362 while pending:
363 time.sleep(POLL_INTERVAL)
364 for eid in list(pending):
365 prog = ddict.get(f"progress:{eid}")
366
367 # (1) live intervention while the task is still running
368 if prog and prog["status"] == "running" and eid not in aborting:
369 if judge_divergence(prog, llm):
370 ddict[f"control:{eid}"] = "abort" # mid-flight, via DDict
371 aborting.add(eid)
372 print(f"[supervisor] ! ABORT {eid} at step {prog['step']} "
373 f"(metric={prog['metric']:.1f} diverging) -> reclaiming slot")
374
375 # (2) has the Batch task finished? (authoritative completion signal)
376 try:
377 tasks[eid].get(block=False) # non-blocking reap
378 except TaskNotReadyError:
379 continue # still running
380 except Exception: # worker raised
381 outcomes.append({"id": eid, "status": "error", "score": None})
382 pending.discard(eid)
383 continue
384
385 # (3) task done -- read its final outcome from the DDict
386 final = ddict.get(f"progress:{eid}") or {"status": "done"}
387 if final["status"] == "aborted":
388 outcomes.append({"id": eid, "status": "aborted", "score": None})
389 else:
390 outcomes.append({"id": eid, "status": "done",
391 "score": final.get("score"),
392 "wave_key": final.get("wave_key")})
393 print(f"[supervisor] + DONE {eid} score={final.get('score'):.4f}")
394 pending.discard(eid)
395
396 batch.fence() # ensure this round's Batch DAG is fully drained
397 n_done = sum(1 for o in outcomes if o["status"] == "done")
398 print(f"[supervisor] round complete: {n_done} finished, "
399 f"{len(aborting)} aborted early")
400 return {"outcomes": outcomes, "aborted_count": len(aborting)}
401
402
403# =============================================================================
404# Agent 3 -- ANALYZER: rank survivors (zero-copy reads), update incumbent,
405# decide convergence, and free spent artifacts.
406# =============================================================================
407
408def analyzer_node(state: LabState, *, ddict_ser: bytes) -> dict:
409 ddict = _get_ddict(ddict_ser)
410
411 survivors = [o for o in state["outcomes"] if o["status"] == "done"]
412 if not survivors:
413 # Everything diverged -- widen the search next round.
414 print(f"[analyzer] {_where()} no survivors this round")
415 it = state["iteration"]
416 return {"iteration": it + 1, "history": [state.get("best_score", -1e9)],
417 "done": it + 1 >= MAX_ITERATIONS}
418
419 # Rank by the figure of merit; the winner needs its full waveform read back.
420 survivors.sort(key=lambda o: o["score"], reverse=True)
421 champ = survivors[0]
422 champ_wave = ddict[champ["wave_key"]] # zero-copy bulk read
423 rms = float(np.sqrt(np.mean(champ_wave ** 2))) # a "needs whole array" metric
424 best_params = next(c for c in state["candidates"] if c["id"] == champ["id"])
425
426 prev_best = state.get("best_score", float(-np.inf))
427 improved = champ["score"] - prev_best
428
429 # User-managed cleanup: keep the champion, free the rest.
430 for o in survivors[1:]:
431 if o["wave_key"] in ddict:
432 del ddict[o["wave_key"]]
433
434 it = state["iteration"]
435 converged = (it + 1 >= MAX_ITERATIONS) or (it > 0 and abs(improved) < 1e-3)
436 print(f"[analyzer] {_where()} round {it}: "
437 f"champion={champ['id']} score={champ['score']:.4f} "
438 f"(wave rms={rms:.3f}) -> {'CONVERGED' if converged else 'refine'}")
439
440 return {
441 "best_score": max(prev_best, champ["score"]),
442 "best_params": best_params,
443 "history": [champ["score"]],
444 "iteration": it + 1,
445 "done": converged,
446 }
447
448
449# =============================================================================
450# Routing
451# =============================================================================
452
453def route_after_analysis(state: LabState) -> str:
454 return END if state["done"] else "planner"
455
456
457# =============================================================================
458# Optional: build a cluster-local LLM proxy (Dragon Inference / vLLM).
459# Returns (llm_proxy, inference_handle). The handle is kept alive for the
460# campaign and destroyed in main()'s finally block; it is None when the
461# rule-based judge is used.
462# =============================================================================
463
464def _maybe_build_llm() -> tuple[Any | None, Any | None]:
465 if not USE_LLM_JUDGE:
466 return None, None
467
468 # Configure a small on-cluster vLLM pipeline. Adjust the model and GPU
469 # counts to match your allocation; set HF_TOKEN in the environment for
470 # gated models (e.g. Llama).
471 config = InferenceConfig(
472 model=ModelConfig(
473 model_name="meta-llama/Llama-3.1-8B-Instruct",
474 hf_token=os.environ.get("HF_TOKEN", ""),
475 tp_size=1, # GPUs per inference worker (tensor parallel)
476 max_tokens=64, # the judge only needs ABORT / CONTINUE
477 max_model_len=4096,
478 ),
479 hardware=HardwareConfig(
480 num_nodes=1, # inference on one node
481 num_gpus=1, # one GPU on that node
482 ),
483 batching=BatchingConfig(
484 enabled=True,
485 batch_wait_seconds=0.05,
486 max_batch_size=32,
487 ),
488 )
489
490 # Launch the pipeline and hand the agents a queue-backed proxy.
491 input_queue = Queue(maxsize=256)
492 inference = Inference(config=config, input_queue=input_queue)
493 inference.initialize()
494 print("[setup] on-cluster vLLM inference pipeline ready")
495
496 llm = DragonQueueLLMProxy(input_queue, max_concurrent_requests=16)
497 return llm, inference
498
499
500# =============================================================================
501# Main
502# =============================================================================
503
504def main() -> None:
505 from dragon import DDict
506
507 print(f"[orchestrator] {_where()}")
508
509 ddict = DDict(managers_per_node=1, n_nodes=1, total_mem=DDICT_MEM)
510 ddict_ser = ddict.serialize()
511
512 llm, inference = _maybe_build_llm()
513
514 # A Batch runtime schedules the experiment tasks across the allocation. The
515 # supervisor submits into it; the experiments stream progress via the DDict.
516 # It is created with managed_lifecycle=True because the supervisor runs in a
517 # *different* process (its agent host) and receives its own Batch client via
518 # cloudpickle -- that copy is torn down with the host, so we shut the shared
519 # runtime down explicitly from here with a forced grace period.
520 batch = Batch(managed_lifecycle=True)
521 print(f"[setup] {batch.topology()}")
522
523 # -- Discover the allocation and build one Policy per host ----------------
524 # Two hosts are placed on two nodes: the control plane (planner + analyzer)
525 # on the head node, and the supervisor -- which submits and monitors the
526 # experiment tasks -- on a compute node. If the allocation has only one
527 # node, both policies resolve to it (the demo still runs).
528 nodes = System().nodes
529 head_hostname = Node(nodes[0]).hostname
530 compute_hostname = Node(nodes[1 % len(nodes)]).hostname
531 head_policy = Policy(placement=Policy.Placement.HOST_NAME,
532 host_name=head_hostname)
533 compute_policy = Policy(placement=Policy.Placement.HOST_NAME,
534 host_name=compute_hostname)
535 print(f"[setup] head node={head_hostname}, compute node={compute_hostname}")
536
537 try:
538 with DragonExecutor() as executor:
539 # Host 0 (head node): control-plane agents -- planning and ranking.
540 executor.launch_host(
541 agents={
542 "planner": partial(planner_node, llm=llm),
543 "analyzer": partial(analyzer_node, ddict_ser=ddict_ser),
544 },
545 policy=head_policy,
546 )
547 # Host 1 (compute node): the supervisor, which submits the experiment
548 # tasks to Batch and polls the DDict live. It needs extra threads to
549 # run its monitoring loop alongside the submitted work.
550 executor.launch_host(
551 agents={
552 "supervisor": partial(supervisor_node, ddict_ser=ddict_ser,
553 batch=batch, llm=llm),
554 },
555 max_threads=8,
556 policy=compute_policy,
557 )
558
559 # Equivalent one-call form via launch_hosts(): pass a list of agent
560 # dicts and a matching list of policies (applied in order). Note that
561 # max_threads here would apply to every host, so the two-call form
562 # above is used when hosts need different max_threads.
563 #
564 # executor.launch_hosts(
565 # hosts=[
566 # {"planner": partial(planner_node, llm=llm),
567 # "analyzer": partial(analyzer_node, ddict_ser=ddict_ser)},
568 # {"supervisor": partial(supervisor_node, ddict_ser=ddict_ser,
569 # batch=batch, llm=llm)},
570 # ],
571 # policies=[head_policy, compute_policy],
572 # )
573
574 builder = StateGraph(LabState)
575 builder.add_node("planner", executor.node("planner"))
576 builder.add_node("supervisor", executor.node("supervisor"))
577 builder.add_node("analyzer", executor.node("analyzer"))
578
579 builder.add_edge(START, "planner")
580 builder.add_edge("planner", "supervisor")
581 builder.add_edge("supervisor", "analyzer")
582 builder.add_conditional_edges("analyzer", route_after_analysis,
583 ["planner", END])
584
585 graph = builder.compile()
586
587 config = {"recursion_limit": 100}
588 initial = {"iteration": 0, "outcomes": [], "best_score": float(-np.inf),
589 "history": [], "aborted_count": 0, "done": False}
590
591 print("\n=== Self-driving lab (Dragon + LangGraph) ===")
592 final = graph.invoke(initial, config)
593
594 print("\n=== Lab campaign complete ===")
595 print(f" rounds run : {final['iteration']}")
596 print(f" best params : {final.get('best_params')}")
597 print(f" best score : {final['best_score']:.4f}")
598 print(f" score history : {[round(h, 4) for h in final['history']]}")
599 finally:
600 # Force the shared Batch runtime down after a short grace period: the
601 # supervisor's cloudpickled Batch client lived in the (now stopped) agent
602 # host and can never detach on its own, so we don't wait on it forever.
603 batch.destroy(force_timeout=30)
604 if inference is not None:
605 inference.destroy()
606 ddict.destroy()
607
608
609if __name__ == "__main__":
610 mp.set_start_method("dragon", force=True)
611 main()
See Also
LangGraph Integration — internals and code paths.
LangGraph Integration — API reference.