Nested HITL

Combine step-level and executor-level HITL on the same step. The workflow pauses twice: once before the step runs, once when the agent calls a HITL tool.

A single step can use step-level HITL (gate the step itself) and executor-level HITL (gate a tool call inside the agent) together. The workflow pauses twice in sequence: once before the step runs, then again during the step when the agent's HITL tool is called.

1from kern.agent import Agent
2from kern.db.sqlite import SqliteDb
3from kern.models.openai import OpenAIResponses
4from kern.tools import tool
5from kern.workflow.step import Step
6from kern.workflow.workflow import Workflow
7
8
9@tool(requires_confirmation=True)
10def send_alert(city: str, message: str) -> str:
11 return f"Alert sent for {city}: {message}"
12
13
14alert_agent = Agent(
15 name="AlertAgent",
16 model=OpenAIResponses(id="gpt-5.4"),
17 tools=[send_alert],
18 db=SqliteDb(db_file="workflow.db"),
19)
20
21workflow = Workflow(
22 name="DualConfirmation",
23 db=SqliteDb(db_file="workflow.db"),
24 steps=[
25 Step(
26 name="send_alert",
27 agent=alert_agent,
28 requires_confirmation=True, # step-level gate
29 confirmation_message="Proceed with sending the alert?",
30 ),
31 ],
32)

When this workflow runs:

  1. Pause 1 (step-level): user sees confirmation_message, calls req.confirm().
  2. Pause 2 (executor-level): agent calls send_alert, user approves the specific tool call.
  3. Step finishes.

The Active-Requirement Pattern

step_requirements accumulates across pauses within a single run. The first pause adds the step-level requirement. After resolution and continue, a second pause adds the executor-level requirement on top of it. To detect the current pause type, always look at the last entry.

1# Only the LAST requirement reflects the current pause state.
2_active = (run_output.step_requirements or [])[-1:]
3has_executor = any(r.requires_executor_input for r in _active)
4
5if has_executor:
6 resolve_executor_pause(run_output)
7else:
8 resolve_step_pause(run_output)
Warning

Iterating over the full step_requirements list re-reads requirements that were already resolved in earlier pauses of the same run. Two concrete failures:

  • Wrong pause type detected. If an earlier entry was an executor requirement and the current pause is step-level, any(r.requires_executor_input for r in step_requirements) is still True, so you run the executor branch and skip the step the workflow is actually waiting on. continue_run then raises because the active requirement is unresolved.
  • Stale decisions reapplied. A route selection or confirmation from a prior pause gets re-applied over the current one. For example, re-confirming an old router selection overrides the user's new choice, sending the workflow down the previous branch.

Scope to the active pause with (run_output.step_requirements or [])[-1:], or use the filter properties (steps_requiring_confirmation, steps_requiring_executor_resolution, ...) which already exclude resolved entries. See Pause Anatomy.

Resolution Loop

Wrap continue calls in a while is_paused: loop. Each pause resolves one gate; the workflow either pauses again or completes.

1def resolve_step_pause(run_output):
2 for req in (run_output.step_requirements or [])[-1:]:
3 if req.requires_confirmation and not req.requires_executor_input:
4 req.confirm() # or req.reject()
5
6
7def resolve_executor_pause(run_output):
8 for req in (run_output.step_requirements or [])[-1:]:
9 if req.requires_executor_input:
10 for executor_req in req.executor_requirements or []:
11 executor_req.confirm() # or .reject(note=...) / .provide_user_input(...)
12
13
14run_output = workflow.run("Send a weather alert for Tokyo about heavy rain")
15
16while run_output.is_paused:
17 _active = (run_output.step_requirements or [])[-1:]
18 if any(r.requires_executor_input for r in _active):
19 resolve_executor_pause(run_output)
20 else:
21 resolve_step_pause(run_output)
22
23 run_output = workflow.continue_run(run_output)
24
25print(run_output.content)

Cookbooks

Runnable examples in cookbook/04_workflows/08_human_in_the_loop/dual_level_hitl/:

FileStep-Level GateExecutor-Level Gate
01_step_confirmation_and_tool_confirmation.pyStep confirmationTool confirmation
02_step_user_input_and_tool_confirmation.pyStep user inputTool confirmation
03_condition_and_tool_confirmation.pyCondition confirmationTool confirmation
04_router_selection_and_tool_confirmation.pyRouter route selectionTool confirmation
05_output_review_and_tool_confirmation.pyStep output reviewTool confirmation
06_loop_confirmation_and_tool_confirmation.pyLoop start confirmationTool confirmation
07_router_confirmation_and_tool_confirmation.pyRouter confirmationTool confirmation
09_multi_step_mixed_hitl.pyMultiple steps with mixed gatesTool confirmation

Developer Resources