This supersedes the restriction stated in Hooks: “Hooks
can only be called from workflow-level code, not from within
@step
functions.” That is true of the workflow-level hook(). The
step_hook() primitive documented here exists precisely to give a step
the same durable suspend/resume, by re-executing the step and replaying its
state from a checkpoint. Use it when the thing that must wait for an external
event lives deep inside a step (e.g. a tool call inside an agent loop) and
cannot be lifted to the workflow level.The problem this solves
A step runs to completion on one worker. If it needs to wait for an external event — a human approval, a webhook, a visitor answering a UI prompt — the naive implementations are all wrong:- Block the worker (
while not done: sleep, or a RedisBLPOP): the worker thread/process is pinned for the entire wait. If the external party never responds, the resource is held until a timeout — and nothing survives a worker crash. The in-memory state (the agent’s message history, the pending tool call) is gone; on restart the step re-runs from scratch and re-prompts. - Lift the wait to the workflow with
hook(): correct and durable, but only possible when you can restructure the code so the suspend point is at workflow level. An LLM agent loop that decides mid-stream to call a human-in-the-loop tool cannot be lifted — the decision is non-deterministic and happens inside the step.
step_hook() + the step checkpoint API give you durable suspend/resume without
leaving the step. The worker is freed on suspend. On resume the step
re-executes from the top, but reads its prior state back from a checkpoint
instead of recomputing it.
The API
Three checkpoint calls and one hook call, all imported frompyworkflow:
How step_hook() works (mechanics)
Source: pyworkflow/primitives/step_hook.py, pyworkflow/primitives/step_checkpoint.py.
- First call records a
HOOK_CREATEDevent, creates aHookrow (status=PENDING,expires_atif a timeout was given), fireson_created(token), and raisesSuspensionSignal. The step wrapper (pyworkflow/core/step.py:309) re-raises it, suspending the workflow and freeing the worker. resume_hook(token, payload)records aHOOK_RECEIVEDevent and schedules the run to resume.- On resume the step re-executes from the top.
step_hook()scans the event log for itshook_id; findingHOOK_RECEIVED, it returns the payload instead of suspending again. Thehook_idis deterministic (step_hook_{name}_{counter}— a per-context counter,step_hook.py:121-125), so the same call site gets the same id across re-executions. - Everything before the
step_hook()call runs again. That is why state you don’t want to recompute (LLM turns already taken, expensive setup) must be read back fromload_step_checkpoint(), not recomputed.
The idempotency contract
A step that uses step_hook() MUST be idempotent up to each suspend point.
Re-execution is the mechanism, not a bug. Anything non-deterministic or
side-effecting that ran before the hook (LLM calls, external writes, streamed
output) will run again on resume unless you gate it on a checkpoint. The
checkpoint is your “already did this” record. This is the single most important
thing to get right — see the worked example.
Durable timeouts: on_timeout="return"
BLPOP’s only redeeming feature is its timeout. step_hook gives you a
durable one. With on_timeout="return" (requires pyworkflow ≥ 0.3.7,
#549), the runtime schedules a resume at expires_at and, when the deadline
passes with no resume_hook, the call returns the STEP_HOOK_TIMEOUT sentinel
on re-execution instead of hanging forever. Unlike a BLPOP timeout, this
survives a worker crash — the deadline lives in the event log, not in a parked
thread.
Where it’s wired
set_step_execution_context() is installed on both execution paths, so
checkpointing works whether the step runs dispatched or inline:
- Celery step worker:
pyworkflow/celery/tasks.py:270 - Local runtime:
pyworkflow/core/step.py:277
Worked example: refactoring the FlowHunt “hook-as-tool” (issue #5485)
This is the concrete migration the rest of this page exists for. It takes theHookToolAdapter dispatch off its ad-hoc Redis BLPOP and onto step_hook() +
save_step_checkpoint().
What exists today (the “random implementation”)
FlowHunt exposes a flow hook component (e.g.EvaluateClientJs) to the AI
agent as a LangChain tool (HookToolAdapter). When the agent calls it, today’s
code (in the flowhunt repo):
| Concern | Current mechanism | File |
|---|---|---|
| Wait for the visitor | BLPOP on a Redis list, on a 64-thread pool | flow_engine_v3/internal/tools/hook_tool_adapter.py (_HOOK_WAIT_EXECUTOR, await_tool_call_result) |
| Route the response | resume_hook checks a Redis “pending” marker and PUBLISHes, bypassing pyworkflow | app/application/flow_session/flow_session_management_service.py (the is_pending_tool_call branch) |
| Redis plumbing | register_/is_pending_/publish_/await_/try_pop_/clear_tool_call_result | app/infrastructure/flows/repository/runtime_cache_repository.py |
hook_tool_adapter.py, “Known limitation (replay)”): “the dispatch lives only
in Redis and not in pyworkflow’s event log, it is intentionally not durable
across a worker crash… the step re-executes from the top and the LLM may
re-emit the tool call, re-prompting the visitor.”
Why it’s wrong, precisely:
- Resource leak under the normal case. Every in-flight hook-tool call parks
a thread on
BLPOPfor up tohook_timeout_seconds(default 300s). Visitors routinely abandon chats. The pool caps concurrency at 64 abandoned conversations before new hook tools cannot dispatch at all. - No durability. A worker restart (deploy, OOM, spot reclaim) loses the in-memory agent state and the pending Redis wait. The run either strands or re-prompts the visitor. This is exactly the failure class checkpointing was built to eliminate.
- Two protocols through one endpoint.
resume_hookhas to sniff Redis to decide whether an incominghook_idis a real pyworkflow hook or a tool-call, because the tool-call path deliberately never entered pyworkflow.
Why you can’t just “call step_hook() inside the tool”
The tempting one-liner — replace the BLPOP in HookToolAdapter._arun with
await step_hook(...) — does not work, for two structural reasons. Both must
be handled by the refactor:
- The agent middleware swallows the suspension. The tool runs inside a
LangGraph graph built by
create_agent(...)withToolErrorIgnoreMiddlewarein the stack (ai_agent/step.py:721-731). That middleware catches exceptions raised by a tool and rewrites them into aToolMessage. ASuspensionSignalraised from inside the tool would be caught and turned into a tool error, never reachingpyworkflow/core/step.py:309. Suspension has to be raised outside the graph, at the step boundary. astreamcan’t be resumed mid-tool without a checkpointer. The step drives the agent withagent_executor.astream({"messages": messages})(ai_agent/step.py:774) and no LangGraph checkpointer is configured. On step re-execution the graph would re-run from the first message, re-issuing every prior (non-deterministic) LLM turn. The transcript must be captured and fed back so the graph continues from where it left off.
The pattern: checkpoint the transcript, suspend at the step boundary
The suspension lives inside the step but outside the graph. The tool’s job shrinks to “surface a request and the args”; the step owns suspend/resume.GraphRecursionError handler at ai_agent/step.py:781-810 already
performs exactly this kind of transcript surgery — appending synthetic
ToolMessages to satisfy tool_call/tool_result pairing and re-invoking.
Seeding astream with a transcript whose last AI tool_calls are already
answered by ToolMessages makes LangGraph continue from the next agent node —
it does not re-call the LLM for the turn that’s already answered. That is
what makes the replay deterministic without a LangGraph checkpointer.
Concrete change list
A. Surface the request out of the graph instead of blocking. InHookToolAdapter._arun (hook_tool_adapter.py), stop calling
register_pending_tool_call / await_tool_call_result. Instead the tool emits
its request (the existing emit_tool_request) and hands control back to the
step. Two viable seams:
- Preferred (LangGraph-native): return a
Command/ raise a whitelistedHookInterruptthat the step catches around theastreamloop. The runner already threadsToolMessage | Commandthrough its handlers (agent_runner.py:709-905), so the type plumbing exists. - Whichever seam: it must carry
hook_name, the toolargs, and the pendingtool_call_idup to the step.
ai_agent (ai_agent/step.py), wrap the astream loop so that when a hook
interrupt surfaces:
save_step_checkpoint({"messages": messages_to_dict(accumulated), "pending_tool_call_id": id, "hook_args": args}).payload = await step_hook(hook_name, on_created=<emit to visitor>, timeout=<hook_timeout>, on_timeout="return").- On re-execution, first thing:
checkpoint = await load_step_checkpoint(); if present, rebuildmessagesfrom it, appendToolMessage(content=<post_hook(payload)>, tool_call_id=…), and resumeastreamfrom that transcript.delete_step_checkpoint()once the agent produces its final answer. - If
payload is STEP_HOOK_TIMEOUT, inject a “visitor did not respond”ToolMessageand let the agent finish — the durable equivalent of today’sTimeoutError.
on_created(token) replaces emit_tool_request(... request_id ...): emit the
pyworkflow token (run_id:hook_id) to the widget. Keep the hook’s existing
post_hook(payload, params, ctx) call to shape the tool result.
C. Delete the Redis bypass in resume_hook.
In flow_session_management_service.py, remove the entire
if runtime_cache.is_pending_tool_call(hook_id): … branch. With the tool now a
real step_hook, hook_id is step_hook_<name>_<n> and the existing
pyworkflow.resume_hook(token, payload_data) path (already in that method)
handles it. No sniffing, one protocol. Token formats already match:
create_hook_token(run_id, hook_id) == the f"{run_id}:{hook_id}" that method
already builds.
D. Delete the dead Redis plumbing.
Remove from runtime_cache_repository.py (and its interface
app/domain/flows/runtime_cache/repository.py):
register_pending_tool_call, is_pending_tool_call,
unregister_pending_tool_call, publish_tool_call_result,
await_tool_call_result, try_pop_tool_call_result,
clear_tool_call_result, and the _tool_call_pending_key /
_tool_call_result_key helpers. Remove _HOOK_WAIT_EXECUTOR from the adapter.
(The credits and pending_ui_outputs namespaces in that repo are unrelated —
keep them.)
E. Re-evaluate the force-local variant.
ai_agent_force_local (ai_agent/step.py:900) exists because a dispatched
step that blocks for minutes made suspend/resume racy (#5485). With a real
step_hook, the step no longer blocks — it suspends and frees the worker, which
is the dispatched-worker case step_hook is explicitly wired for
(celery/tasks.py:270). The force-local workaround is likely removable, but
treat that as a follow-up to validate, not an assumption — verify a
dispatched hook-tool suspends and resumes cleanly before deleting it.
What this buys
- No parked threads. A suspended run costs storage, not a worker slot. 64 abandoned chats is no longer a ceiling.
- Crash-durable. A deploy or OOM mid-wait resumes from the checkpoint; the visitor is not re-prompted. The adapter’s “Known limitation (replay)” disclaimer is deleted, not merely documented.
- One resume protocol. Design-time hooks and hook-tools both flow through
pyworkflow.resume_hook.
Gotchas specific to this step
- Generator step.
ai_agentis@step(..., is_generator=True)and streams partial output before the hook. Re-executed output must not be re-emitted to the visitor — gate already-streamed chunks on the checkpoint, or only stream deltas produced after the restored transcript. - Credits. The current adapter validates credits at dispatch
(
validate_credits_for_step). Keep that on the first pass only; on checkpoint-resume the credit was already charged — don’t double-charge. messages_to_dict/messages_from_dict. Use LangChain’s serialization for the transcript so tool-call ids and content blocks round-trip; ad-hoc dict-building dropstool_calls.- Idempotency of side-effecting tools. Any tool that ran before the hook and had side effects will not re-run (it’s in the restored transcript), which is correct — but make sure your checkpoint captures the transcript including those tool results, or they’ll re-execute.
See also
- Hooks — workflow-level
hook()(lift the wait out of the step when you can). - Fault Tolerance — how suspended runs recover after a worker crash.
- Steps — retry semantics and the step lifecycle.