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.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
LangGraph Integration — internals and code paths.
LangGraph Integration — API reference.