Human-in-the-Loop Approval
Building on Examples 01–02, this example demonstrates the human-in-the-loop (HITL) approval system. A human operator reviews and approves or rejects tool calls before they execute. This is essential for high-stakes operations: deleting data, deploying to production, running expensive jobs, or any action requiring oversight.
Prerequisites: Read 02_multi_agent_dag.py first.
What you’ll learn:
How to configure an
ApprovalFilterto specify which tools need reviewHow to run the HITL approval client in a separate terminal
How operators approve, reject, or provide feedback on tool calls
How the agent adjusts its approach based on rejections
How to audit and replay HITL decisions
Architecture:
The agent calls the LLM, receives tool proposals, sends dangerous ones to the HITL gateway (TCP), and awaits human decision before executing. Safe tools execute immediately.
Main Code
Below is the complete example:
1"""03 — Human-in-the-Loop (HITL) Approval Gates.
2
3**Prerequisites:** Read ``02_multi_agent_dag.py`` first.
4
5**What's new:**
6
7* **Approval filters** — ``approval_filter`` on ``AgentConfig`` selectively
8 gates tool calls for human review before execution
9* **Three-way operator decisions** — approve, reject (with reason), or
10 provide feedback (agent retries incorporating the feedback)
11* **HITL TCP bridge** — orchestrator auto-starts a TCP server; a standalone
12 CLI client connects from another terminal to review requests
13* **HITL audit log** — all decisions auto-saved to JSONL for compliance
14
15Architecture (same DAG as 02, with HITL gates added)::
16
17 planner_agent ──► runner_agent ──► analyzer_agent ──┐
18 [HITL gate] [HITL gate] ├──► reporter_agent
19 │ │ │
20 └────────────────┴─────────────────────────────┘
21
22 Terminal 2: python -m dragon.ai.agent.hitl --tcp HOST:PORT
23
24HITL gates:
25 * planner_agent → ``propose_experiment`` requires approval
26 * runner_agent → ``launch_experiment`` requires approval
27
28Usage::
29
30 # Terminal 1:
31 dragon 03_hitl_approval.py
32
33 # Terminal 2 (HITL approval client):
34 python -m dragon.ai.agent.hitl --tcp HOST:PORT
35"""
36
37import asyncio
38from typing import Any
39
40import dragon
41import multiprocessing as mp
42
43from dragon import System, Policy, Batch
44from dragon.ai.agent.core import create_sub_agent
45from dragon.ai.agent.config import (
46 AgentConfig,
47 OrchestratorConfig,
48 Pipeline,
49 PipelineNode,
50)
51from dragon.ai.agent.tools import ToolRegistry
52from dragon.ai.agent.orchestrator import DAGOrchestrator
53from dragon.native.event import Event
54from dragon.native.machine import Node
55from dragon.native.process import Process
56from dragon.native.queue import Queue
57
58from dragon.ai.inference import (
59 BatchingConfig,
60 HardwareConfig,
61 InferenceConfig,
62 ModelConfig,
63 Inference,
64)
65
66from tools import (
67 propose_experiment,
68 launch_experiment,
69 check_progress,
70 collect_results,
71 analyze_convergence,
72 format_results_table,
73)
74from tools.runner import cleanup_experiment_state
75
76
77# ===========================================================================
78# User-configurable constants
79# ===========================================================================
80
81MODEL_NAME = "/path/to/your/model"
82HF_TOKEN = ""
83
84
85# ===========================================================================
86# Inference Pipeline Configuration
87# ===========================================================================
88
89INFERENCE_CONFIG = InferenceConfig(
90 model=ModelConfig(
91 model_name=MODEL_NAME,
92 hf_token=HF_TOKEN,
93 tp_size=2,
94 max_tokens=8192,
95 max_model_len=32768,
96 ),
97 hardware=HardwareConfig(
98 num_nodes=1,
99 num_gpus=2,
100 num_inf_workers_per_cpu=1,
101 ),
102 batching=BatchingConfig(
103 batch_wait_seconds=0.1,
104 max_batch_size=32,
105 ),
106)
107
108
109# ===========================================================================
110# Tool registries
111# ===========================================================================
112
113planner_registry = ToolRegistry()
114planner_registry.register(propose_experiment)
115
116runner_registry = ToolRegistry()
117runner_registry.register(launch_experiment)
118runner_registry.register(check_progress)
119runner_registry.register(collect_results)
120
121analyzer_registry = ToolRegistry()
122analyzer_registry.register(analyze_convergence)
123
124reporter_registry = ToolRegistry()
125reporter_registry.register(format_results_table)
126
127
128# ===========================================================================
129# DAG pipeline
130# ===========================================================================
131
132pipeline = Pipeline(nodes=[
133 PipelineNode(
134 agent_id="planner_agent",
135 task_description=(
136 "You are a scientific experiment planner. The user wants to study "
137 "Monte Carlo convergence for estimating π.\n\n"
138 "Propose an experiment plan by calling propose_experiment with:\n"
139 " - description, sample_sizes, convergence_target, methodology\n\n"
140 "A human operator will review your plan. If they provide "
141 "feedback, revise and resubmit. Once approved, report verbatim."
142 ),
143 depends_on=[],
144 ),
145 PipelineNode(
146 agent_id="runner_agent",
147 task_description=(
148 "You manage parallel Monte Carlo simulations on an HPC cluster.\n\n"
149 "Tools:\n"
150 " 1. launch_experiment(sample_sizes, seeds) — launches ALL "
151 "simulations in parallel. Operator must approve.\n"
152 " 2. check_progress() — shows done/running/pending status.\n"
153 " 3. collect_results() — call ONLY when all_done=true.\n\n"
154 "STRICT workflow:\n"
155 " 1. Call launch_experiment.\n"
156 " 2. Call check_progress until all_done=true.\n"
157 " 3. Call collect_results — REQUIRED before final answer.\n"
158 " 4. Report collect_results output verbatim."
159 ),
160 depends_on=["planner_agent"],
161 ),
162 PipelineNode(
163 agent_id="analyzer_agent",
164 task_description=(
165 "Call analyze_convergence with a list of dicts (keys: "
166 "'n_samples', 'absolute_error'). Report all metrics verbatim."
167 ),
168 depends_on=["runner_agent"],
169 ),
170 PipelineNode(
171 agent_id="reporter_agent",
172 task_description=(
173 "Write a structured report.\n\n"
174 "STRICT workflow:\n"
175 " 1. Call format_results_table to get a Markdown table.\n"
176 " 2. COPY-PASTE the actual table into your final answer.\n"
177 " 3. Include sections: Plan, Results Table, Parallel Execution,\n"
178 " Convergence Analysis, Quality Assessment, Recommendations.\n\n"
179 "Never invent data — use only data from upstream agents."
180 ),
181 depends_on=["planner_agent", "runner_agent", "analyzer_agent"],
182 ),
183])
184
185
186# ===========================================================================
187# Helpers
188# ===========================================================================
189
190def _make_agent_kwargs(agent_id, name, role, registry, inference_queue,
191 approval_filter=None,
192 max_tool_call_iterations=20):
193 """Build the kwargs dict for create_sub_agent().
194
195 NEW in this example: ``approval_filter`` — a callable that returns True
196 for tool calls that need human approval. The dispatcher pauses the
197 agentic loop and sends the request to the HITL bridge.
198 """
199 return {
200 "config": AgentConfig(
201 agent_id=agent_id, name=name, role=role,
202 inference_queue=inference_queue,
203 approval_filter=approval_filter,
204 max_tool_call_iterations=max_tool_call_iterations,
205 ),
206 "tool_registry": registry,
207 "shutdown_event": Event(),
208 "reply_queue": Queue(),
209 }
210
211
212def _start_agents(specs, policies):
213 procs = []
214 for spec, policy in zip(specs, policies):
215 p = Process(target=create_sub_agent, kwargs=spec, policy=policy)
216 p.start()
217 procs.append(p)
218 queues = {}
219 for spec in specs:
220 aid = spec["config"].agent_id
221 queues[aid] = spec["reply_queue"].get()
222 print(f"[startup] Agent '{aid}' ready.", flush=True)
223 return procs, queues
224
225
226# ===========================================================================
227# Main
228# ===========================================================================
229
230async def main():
231 input_queue = Queue()
232
233 print("[startup] Initializing inference pipeline...", flush=True)
234
235 inference_pipeline = None
236 try:
237 inference_pipeline = Inference(INFERENCE_CONFIG, input_queue)
238 inference_pipeline.initialize()
239 except Exception as exc:
240 import traceback
241 print(f"\n[FATAL] Inference pipeline failed to initialize: {exc}", flush=True)
242 traceback.print_exc()
243 if inference_pipeline is not None:
244 inference_pipeline.destroy()
245 return
246 print("[startup] Inference pipeline ready.\n", flush=True)
247
248 my_alloc = System()
249 node_list = my_alloc.nodes
250 compute_host = (
251 Node(node_list[1]).hostname if len(node_list) > 1
252 else Node(node_list[0]).hostname
253 )
254 compute_policy = Policy(
255 placement=Policy.Placement.HOST_NAME,
256 host_name=compute_host,
257 )
258
259 procs, agent_specs = [], []
260 try:
261 agent_specs = [
262 _make_agent_kwargs(
263 "planner_agent", "Experiment Planner",
264 "You are an experiment planner for Monte Carlo convergence "
265 "studies on an HPC cluster. Propose plans via "
266 "propose_experiment. Revise on feedback. Simulations will "
267 "run IN PARALLEL via Dragon ProcessGroup.",
268 planner_registry, input_queue,
269 # ← NEW: gate propose_experiment for human review
270 approval_filter=lambda n, a: n == "propose_experiment",
271 max_tool_call_iterations=60,
272 ),
273 _make_agent_kwargs(
274 "runner_agent", "Parallel Simulation Runner",
275 "You manage parallel Monte Carlo simulations.\n"
276 "You MUST call all three tools in order: "
277 "launch_experiment → check_progress → collect_results.\n"
278 "NEVER give a final answer without calling collect_results first.",
279 runner_registry, input_queue,
280 # ← NEW: gate launch_experiment for human review
281 approval_filter=lambda n, a: n == "launch_experiment",
282 max_tool_call_iterations=60,
283 ),
284 _make_agent_kwargs(
285 "analyzer_agent", "Convergence Analyzer",
286 "Analyse convergence. Call analyze_convergence with "
287 "n_samples + absolute_error dicts. Report verbatim.",
288 analyzer_registry, input_queue,
289 # No approval filter — analyzer runs unattended
290 ),
291 _make_agent_kwargs(
292 "reporter_agent", "Report Writer",
293 "Write a structured report with Markdown tables. "
294 "Include parallel execution details (which node ran what). "
295 "Always include tool output verbatim — "
296 "never use placeholder variables.",
297 reporter_registry, input_queue,
298 # No approval filter — reporter runs unattended
299 ),
300 ]
301 policies = [None, compute_policy, None, None]
302
303 procs, queues = _start_agents(agent_specs, policies)
304 for spec in agent_specs:
305 spec["config"].input_queue = queues[spec["config"].agent_id]
306
307 orchestrator = DAGOrchestrator(
308 config=OrchestratorConfig(
309 agents=[s["config"] for s in agent_specs],
310 poll_interval=0.5,
311 poll_timeout=14400.0,
312 ),
313 pipeline=pipeline,
314 )
315
316 user_input = (
317 "Design and run a Monte Carlo convergence experiment for π. "
318 "I want to review the plan first, then approve the parallel "
319 "launch. Monitor progress and report which nodes ran what."
320 )
321
322 batch = Batch()
323 try:
324 print("=" * 60, flush=True)
325 print("Dragon AI — 03 HITL Approval Gates", flush=True)
326 print("=" * 60, flush=True)
327 print(f"Request: {user_input}\n", flush=True)
328
329 # ← NEW: Print HITL client connection instructions
330 if orchestrator.hitl_address:
331 h, p = orchestrator.hitl_address
332 print(
333 f"HITL approval client — start in another terminal:\n"
334 f" python -m dragon.ai.agent.hitl --tcp {h}:{p}\n"
335 f" Options:\n"
336 f" --jsonl PATH Custom JSONL path (default: auto-generated)\n"
337 f" --report PATH Custom .txt report path\n"
338 f" --no-color Disable ANSI colours\n",
339 flush=True,
340 )
341
342 result = orchestrator.run(
343 user_input=user_input,
344 batch=batch,
345 )
346
347 print("\n" + "=" * 60, flush=True)
348 print("FINAL RESULT", flush=True)
349 print("=" * 60, flush=True)
350 print(result, flush=True)
351
352 except Exception as exc:
353 import traceback
354 print(f"\n[error] Pipeline failed: {exc}", flush=True)
355 traceback.print_exc()
356 finally:
357 orchestrator.destroy()
358 batch.join()
359
360 except Exception as exc:
361 import traceback
362 print(f"\n[error] Fatal: {exc}", flush=True)
363 traceback.print_exc()
364 finally:
365 cleanup_experiment_state()
366 for spec in agent_specs:
367 try:
368 spec["shutdown_event"].set()
369 except Exception:
370 pass
371 for p in procs:
372 try:
373 p.join()
374 except Exception:
375 pass
376 print("\n[teardown] All agents stopped.", flush=True)
377 try:
378 inference_pipeline.destroy()
379 except Exception:
380 pass
381 print("[teardown] Inference pipeline stopped.", flush=True)
382
383
384if __name__ == "__main__":
385 mp.set_start_method("dragon")
386 asyncio.run(main())
Key Concepts
Approval Filter:
Define a function that returns True for tool names that need review:
DANGEROUS_TOOLS = {"delete_experiment", "launch_expensive_job"}
def approval_filter(tool_name: str, tool_args: dict) -> bool:
return tool_name in DANGEROUS_TOOLS
Pass it to AgentConfig(approval_filter=approval_filter).
HITL Client:
The operator runs a separate Python process that connects via TCP and displays pending approvals with:
Tool name and arguments
Agent’s reasoning
Options to approve, reject, or add feedback
Audit Log:
Every HITL decision is logged. You can replay sessions for training, auditing, or testing recovery paths.
Installation
See Example 01 (same dependencies).
System Description
Tested on HPE Cray EX:
1 node, 1 GPU (vLLM backend)
Can run on single development machine
How to Run
Terminal 1 — Launch the pipeline:
dragon 03_hitl_approval.py
The pipeline will output something like:
Agent 'manager' started
Task received: 'Delete failed experiments and launch new batch'
LLM proposing: delete_experiment(experiment_id='exp-001')
Waiting for approval at 127.0.0.1:9000
...
Terminal 2 — Start the approval client:
python -m dragon.ai.agent.hitl_client --tcp HOST:PORT
Replace HOST:PORT with the address shown by the pipeline (e.g., localhost:9000).
The client presents pending requests. The operator can:
Approve (A) — tool executes
Reject (R) — tool is skipped, LLM receives rejection feedback
Feedback (F) — add a message the LLM sees, e.g., “Insufficient permissions”
Audit log (optional):
After a run, replay decisions for testing or training:
python -m dragon.ai.agent.hitl_client --replay audit_log.jsonl
Next Steps
04 — Memory Management (history strategies for long-running agents)