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 ApprovalFilter to specify which tools need review

  • How 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:

Listing 30 03_hitl_approval.py: Agent with human approval gate
  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)