Control & hooks¶
Two control modes decide who may run tool calls, and three hooks give you observability and intervention points.
Control modes¶
ControlMode.BYPASS(default) — autonomous. Hooks may still veto or rewrite any call; returningNonemeans "default-allow".ControlMode.APPROVE— default-deny. Every tool call must receive an explicitDecision.allow()from anon_tool_callhook (your human-in-the-loop point). Using APPROVE without anon_tool_callhook fails fast withControlError.
Modes are set on the Agent and overridable per run():
agent = Agent(provider, tools=[...], control=ControlMode.APPROVE)
await agent.run("x", control=ControlMode.BYPASS) # one-off override
Hooks¶
| Hook | When | Powers |
|---|---|---|
on_step |
after each provider turn | audit, logging, tracing |
on_tool_call |
before each tool executes | veto, rewrite args, ask a human |
on_tool_result |
after each tool completes | metrics, logging |
from toolloop import Decision
async def gatekeeper(ctx) -> Decision:
if ctx.dangerous:
answer = input(f"allow {ctx.name}({ctx.args})? [y/N] ")
return Decision.allow() if answer == "y" else Decision.deny("no")
return Decision.allow()
agent = Agent(
provider,
tools=STD_TOOLS,
control=ControlMode.APPROVE,
on_tool_call=gatekeeper,
)
Hooks can also rewrite arguments before execution:
return Decision.allow(args={**ctx.args, "env": "staging"})
console_approver — batteries included¶
from toolloop import console_approver
agent = Agent(
provider,
tools=STD_TOOLS,
control=ControlMode.APPROVE,
on_tool_call=console_approver(), # prompt is injectable for tests
)
Safe tools pass silently; dangerous=True tools get a terminal prompt.
Note
Observability hooks (on_step, on_tool_result) fire in both modes —
including BYPASS. Only the gating default changes.