Human-in-the-Loop Hooks
AgentHooks intercepts and can alter execution — unlike the observe-only event stream. approve_tool returning False denies a tool call outright: the callable never runs, the tool result becomes "Tool call denied by user.", and a TOOL_DENIED event is emitted.
from deepcrew import Agent, AgentHooks, run_agent
async def approve(tool_name: str, args: dict) -> bool:
return tool_name != "delete_file" # block one specific tool
agent = Agent(
name="assistant",
model="openai/gpt-4o",
tools=[delete_file, read_file],
hooks=AgentHooks(approve_tool=approve),
)
False denies the call. A raising hook is treated as denial.approve_tool, where a raise is treated the same as returning False.Execution order, per tool call
For each tool call the model requests, hooks fire in this exact order:
approve_tool(name, args)— if it returnsFalse(or raises), the callable never runs. The tool's result becomes the literal string"Tool call denied by user.", appended to conversation history as a normal tool message, and aTOOL_DENIEDevent fires. Execution moves straight to step 4 —on_tool_start/on_tool_endare both skipped entirely for a denied call.on_tool_start(name, args)— fires only if the call was approved (or ifapprove_toolis unset).- The tool actually executes.
on_tool_end(name, result)— fires only if execution succeeded without raising. If the tool itself raises an exception,on_tool_endis skipped, and the exception is folded into the tool's result content as an error string instead (unrelated to hooks — this is the same error-folding behavior that happens whether or not any hooks are configured).
on_agent_start is separate from all of this — it fires exactly once per run_agent() call, before the first LLM request, regardless of how many turns or tool calls follow.
Hooks vs. events — pick the right one
These solve different problems and are easy to reach for interchangeably by mistake:
- Hooks (
AgentHooks) run inside the agent's execution, synchronously, and can change what happens —approve_toolis the only hook with the power to prevent a tool call outright. - Events (
StreamEventvia aqueue, filtered by StreamPolicy) only ever describe what already happened, for a UI or logging pipeline — nothing consuming the event queue can stop or alter execution, no matter how fast it reacts.
If you need a human to approve a specific tool call before it runs, that's approve_tool — there's no way to build that with events alone.
A realistic human-approval pattern
approve_tool is awaited, so it can genuinely pause execution on something slower than a synchronous check — a database lookup, a Slack approval button, a queued request to a human reviewer.
import asyncio
from deepcrew import Agent, AgentHooks, run_agent
pending_approvals: dict[str, asyncio.Future] = {}
async def ask_a_human(tool_name: str, args: dict) -> bool:
request_id = f"{tool_name}:{id(args)}"
future = asyncio.get_event_loop().create_future()
pending_approvals[request_id] = future
# e.g. post a Slack message with Approve/Deny buttons here, then wait:
send_approval_request(request_id, tool_name, args) # your integration
return await future # resolved elsewhere when the human responds
agent = Agent(
name="ops_assistant",
model="openai/gpt-4o",
tools=[restart_service, delete_records],
hooks=AgentHooks(approve_tool=ask_a_human),
)
# Elsewhere, e.g. in a webhook handler for the Slack button click:
# pending_approvals[request_id].set_result(True) # or False to deny
Common pitfalls
- A denied call still appears in
AgentResult.tool_calls. Tool calls are recorded from the model's request, beforeapprove_toolis ever consulted — you can't distinguish a denied call from an executed one by looking attool_callsalone; check for aTOOL_DENIEDevent or the literal"Tool call denied by user."content instead. on_tool_enddoesn't fire on a tool exception. Only a successful, non-denied execution reaches it — don't rely on it for cleanup that must run unconditionally.- Hooks intercept; events only observe. Don't reach for a
StreamPolicy-filtered event consumer when you actually need to block a tool call — onlyapprove_toolcan do that. - Hooks apply to one
Agent, not globally. There's no process-wide hook registry — sethooks=on every agent instance that needs interception, including spawned sub-agents, which do not inherit the parent's hooks automatically.
See also
- StreamPolicy — the observe-only counterpart to hooks; both can be used together.
- Agent Spawning — spawned sub-agents don't inherit the parent's
AgentHooks.