Chapter 17 — Human-in-the-Loop
Pause a workflow mid-run, ask a human, resume with their answer. Two calls to workflow.run(), one request_id tying them together — the caller’s perspective is the whole story.
Why this chapter
Some decisions shouldn’t be autonomous. Approving a refund over $500. Confirming a return shipping label. Selecting which of three draft emails to send. Workflows need a way to pause for a human and resume seamlessly when the answer arrives, without losing whatever state the run had accumulated so far.
MAF provides ctx.request_info() in Python and RequestPort in .NET. The workflow suspends, emits a request event, and waits for the caller to supply a response before continuing. This chapter demonstrates the pattern with a refund-approval gate: a workflow holds a proposed refund and pauses to ask a human approver to approve or deny it — minimal domain logic, maximum focus on the pause/resume mechanics.
Prerequisites
- Completed Chapter 16 — Magentic Orchestration
- No LLM needed — HITL is framework plumbing, not model behavior
- Environment variables: none required for this chapter (the demo runs entirely offline)
The concept
- An executor calls
await ctx.request_info(request_data, response_type). - The workflow emits a
request_infoevent containing a uniquerequest_idand the request payload, then suspends. - The caller’s streaming loop over the first
workflow.run(..., stream=True)call sees that event but never sees anoutputevent — the run has paused, not finished. - The caller pairs the
request_idwith a human-supplied response and callsworkflow.run(responses={request_id: value}, stream=True)again. - A method decorated with
@response_handleron the same executor receives(request, response, ctx)and continues the workflow from where it left off.
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af',
'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981',
'background': 'transparent'}}}%%
sequenceDiagram
accTitle: The concept
participant Caller
participant Workflow as RefundApprovalGate executor
Caller->>Workflow: run(RefundApprovalRequest(order_id, amount), stream=True)
Workflow->>Workflow: ctx.request_info(refund, response_type=bool)
Workflow-->>Caller: request_info event (request_id, order_id, amount)
Note over Caller,Workflow: first stream ends here — no output event yet
Caller->>Workflow: run(responses={request_id: approved}, stream=True)
Workflow->>Workflow: response_handler check(request, approved, ctx)
Workflow-->>Caller: output event ("refund approved / denied")
The diagram shows the two separate run() calls a caller makes: the first pauses on request_info, the second resumes with responses={...} and drives the workflow to an output event.
Python
Source: python/main.py.
uv sync --project tutorials
uv run --project tutorials python tutorials/17-human-in-the-loop/python/main.py
uv run --project tutorials pytest tutorials/17-human-in-the-loop/python/tests -v
The executor pauses on request_info and resumes via a @response_handler:
class RefundApprovalGate(Executor):
def __init__(self) -> None:
super().__init__(id="refund-approval-gate")
@handler
async def start(self, refund: RefundApprovalRequest, ctx: WorkflowContext[None, str]) -> None:
await ctx.request_info(request_data=refund, response_type=bool)
@response_handler
async def check(
self,
request: RefundApprovalRequest,
approved: bool,
ctx: WorkflowContext[None, str],
) -> None:
if approved:
await ctx.yield_output(f"refund approved for order {request.order_id}: ${request.amount:.2f}")
else:
await ctx.yield_output(f"refund denied for order {request.order_id}")
run_with_response() (used by the tests) shows the caller side — two workflow.run() calls tied together by pending_request_id:
pending_request_id: str | None = None
async for event in workflow.run(RefundApprovalRequest(order_id=order_id, amount=amount), stream=True):
if pending_request_id is None and getattr(event, "type", None) == "request_info":
pending_request_id = getattr(event, "request_id", None)
outputs: list[str] = []
async for event in workflow.run(responses={pending_request_id: approved}, stream=True):
if getattr(event, "type", None) == "output":
outputs.append(event.data)
.NET
cd tutorials/17-human-in-the-loop/dotnet
dotnet run -- y # scripted, non-interactive: approves the refund
dotnet run -- n # scripted, non-interactive: denies the refund
.NET’s RequestPort folds request and response into a single StreamingRun loop instead of two separate calls — the port is wired into the graph as both the workflow’s start node and the upstream source of the decision executor:
RequestPort approvalPort = RequestPort.Create<RefundRequest, bool>("ApproveRefund");
RefundDecisionExecutor decision = new(refund);
Workflow workflow = new WorkflowBuilder(approvalPort)
.AddEdge(approvalPort, decision)
.WithOutputFrom(decision)
.Build();
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, refund);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case RequestInfoEvent request:
bool approved = scriptedApproval ?? ReadApprovalFrom(request);
await run.SendResponseAsync(request.Request.CreateResponse(approved));
break;
case WorkflowOutputEvent output:
Console.WriteLine(output.Data);
return 0;
}
}
RefundDecisionExecutor receives the routed bool decision directly via HandleAsync and reports the outcome — the refund’s order_id/amount were captured at construction time, since the same run already knows what it’s asking approval for.
Side-by-side differences
| Aspect | Python | .NET |
|---|---|---|
| Request | ctx.request_info(request_data, response_type) | RequestPort.Create<TRequest, TResponse>(id) wired into the graph |
| Response handler | @response_handler method with (self, request, response, ctx) | Downstream executor receives the routed value directly via HandleAsync |
| Resume | A second, separate workflow.run(responses={request_id: value}, stream=True) call | run.SendResponseAsync(...) inside the same StreamingRun/WatchStreamAsync() loop |
| Caller shape | Two async generators, correlated by request_id | One await foreach handling every pause inline |
Gotchas
- Draining the first stream matters. This repo hit a real bug: resuming a workflow only correctly re-enters its
@response_handlerif the firstworkflow.run(..., stream=True)stream is drained to completion. Breaking out of the loop as soon as arequest_infoevent arrives — the obvious move if you want to return an HTTP response to the user right away — left the workflow’s internal_is_runningflag stuckTrue, so the resumingrun()raisedRuntimeError: Workflow is already running.tutorials/pyproject.toml’s dependency comment documents this explicitly: it was a bug inagent-framework-core==1.0.0, fixed upstream incore>=1.11.0(this repo now pins1.14.0).python/main.py’srun_with_response()(used by the test suite) still drains the full first stream defensively before resuming — worth keeping that habit even on a fixed core, since a caller can’t assume every consumer of this pattern is on a patched version. Note thatmain()’s interactive driver doesbreakout of the first loop early (it only needs therequest_id, not every event) — that now works because of the 1.11.0 fix, not because it was ever the safe pattern. - Two structurally different HITL mechanisms exist in this repo — don’t conflate them. This chapter (and the production
_HitlGateExecutorbelow) teaches the in-workflowctx.request_info/@response_handlerpattern: the workflow graph itself pauses and resumes.agents/python/shared/hitl.pyis a completely different mechanism — function-invocation middleware that intercepts a gated tool call (cancel_order,process_refund,initiate_return,modify_order,place_backorder) before it runs and simply never executes it until an admin approves it out-of-band, via a separateresolve_hitl_request()+execute_approved_action()call path that never re-enters the workflow or LLM loop. There’s no workflow pause/resume involved; the tool call just doesn’t happen. If you’re looking for one and find the other, you’re not in the wrong file — they solve adjacent but different problems, and this chapter’s ownRefundApprovalGateuses the pause-and-resume mechanism, notshared/hitl.py’s middleware-interception one, even though both happen to gate a refund-shaped decision. - The old “MAF v1.0 wheel has an empty
__init__.py” packaging bug is fixed, and no longer needs the file layout this chapter used to suggest.agents/python/patch_maf.pystill exists but is a documented no-op now thatagent-framework-coreis pinned to1.14.0(the bug only affected1.0.0); it’s kept only as a defensive fallback. The tutorials use their own bootstrap,tutorials/_shared/maf_bootstrap.py, which both patches an empty__init__.pyif one is ever encountered and loads the repo-root.env— bothpython/main.pyandpython/tests/test_hitl.pycallmaf_bootstrap.bootstrap()before importingagent_framework. WorkflowContext[T, U]type parameters are required on both the request-emitting handler’s and the response handler’sctxargument — a bareWorkflowContextisn’t enough for MAF to validate the request/response types.
Tests
uv sync --project tutorials
uv run --project tutorials pytest tutorials/17-human-in-the-loop/python/tests -v
tutorials/17-human-in-the-loop/python/tests/test_hitl.py covers, with no LLM involved:
- Happy path per outcome —
test_approved_refund_reports_approvedandtest_denied_refund_reports_deniedeach driverun_with_response()with a different decision and assert on the resulting message. - Workflow construction —
test_workflow_buildsassertsbuild_workflow()returns a workflow. - The concept assertion —
test_workflow_pauses_for_human_before_first_responsedrains the firstworkflow.run(..., stream=True)and asserts it emits arequest_infoevent but nooutputevent, proving the pause actually happens rather than the workflow completing immediately.
No dotnet test project exists for this chapter — verify the .NET side with dotnet build plus the scripted dotnet run -- y / dotnet run -- n runs shown above.
How this shows up in the capstone
The production analog of this chapter’s own mechanism — ctx.request_info/@response_handler pausing and resuming a workflow graph — is agents/python/workflows/return_replace.py’s _HitlGateExecutor. When a return’s order total exceeds RETURN_HITL_THRESHOLD (agents/python/shared/config.py:209, default $500), the gate executor calls ctx.request_info(...) at agents/python/workflows/return_replace.py:172, pausing the sequential return/replace workflow. A @response_handler-decorated method, on_approval(), resumes it at agents/python/workflows/return_replace.py:185 — rebuilding a minimal WorkflowState from the original request snapshot (since the paused run’s in-memory state doesn’t survive to the resuming request) and either continuing the chain on approval or yielding a rejection output. This is the same ctx.request_info/@response_handler pair this chapter teaches, just applied inside a multi-step sequential workflow instead of a single-executor gate.
The web app surfaces the pause as a pending approval row in /runs (web/src/app/(app)/runs/page.tsx), and POST /api/orchestration/{run_id}/resume drives ReturnReplaceMode.resume(), which rebuilds a fresh Workflow object and resumes purely from checkpoint_id + responses={request_id: approved} — because the process that paused the original run may not be the process handling the resume request.
Separately — and structurally unrelated despite the similar name — agents/python/shared/hitl.py (358 lines) is the other production HITL mechanism: HITLFunctionMiddleware intercepts a gated tool call (cancel_order, modify_order, process_refund, initiate_return, place_backorder — see HITL_GATED_TOOLS) before it executes, writes a tool_approval_requests row, and returns a pending_approval result without ever calling call_next() — the tool simply never runs until an admin approves it later through resolve_hitl_request() + execute_approved_action(), a completely separate code path. No workflow pause/resume is involved there at all. Don’t confuse the two: this chapter and _HitlGateExecutor teach/use pause-and-resume; shared/hitl.py teaches intercept-and-never-execute.
What’s next
- Next chapter: Chapter 18 — State and Checkpoints
- Full source:
python/·dotnet/ - Shared: Mermaid style guide · Jargon glossary
Source: tutorials/17-human-in-the-loop/README.md — this page is generated from the repository.