Tool-Call Steering & Guards¶
The on_before_tool_call hook decides, per call, whether a tool actually
runs — and when it shouldn't, what the model sees instead. This is the
self-correction primitive: instead of a tool blindly failing or a guard
silently dropping a call, you hand the model a corrective message and it fixes
itself on the next turn.
Where it sits¶
symfonic has four single-purpose tool seams. Steering is the third:
| Seam | Hook | Does |
|---|---|---|
| Routing | on_tool_routing_decision |
which tools are visible this turn |
| Rewrite | on_tool_call_dispatch |
rewrite a call's name / args |
| Steering | on_before_tool_call |
run it, or refuse and feed the model a result |
| Result | on_tool_result |
observe what a tool returned |
on_before_tool_call fires at the tool node, after any rewrite and
before execution. Because LangGraph always routes the tool node back to
the model, a synthesized result flows straight into the model's next turn.
The contract¶
Implement it on a CallbackHandler (subclass BaseCallbackHandler for no-op
defaults):
from symfonic.core.callbacks import BaseCallbackHandler
from symfonic.core.contracts.callbacks import BeforeToolCallEvent
class SqlGuard(BaseCallbackHandler):
async def on_before_tool_call(self, event: BeforeToolCallEvent, state) -> dict | None:
if event.tool_name == "run_query" and "WHERE" not in event.args["sql"].upper():
return {
"action": "skip",
"content": "Add a WHERE clause before running this query.",
"is_error": True,
}
return None # proceed — run the tool normally
Return values:
| Return | Effect |
|---|---|
None or {"action": "proceed"} |
Execute the tool normally. |
{"action": "skip", "content": str, "is_error": True} |
Steer. Don't run it; give the model corrective feedback as an error result so it retries. |
{"action": "skip", "content": str, "is_error": False} |
Cancel. Don't run it; give the model a benign success result it moves past. |
is_error defaults to True (the common steering case). Malformed skip
verdicts (missing/empty content) are dropped with a warning and treated as
proceed — a buggy guard can't silently swallow a call.
Steer vs. cancel¶
The mechanism is identical; only intent differs:
- Steer (
is_error=True) — "you did it wrong, here's how to fix it." The model sees an error tool result and self-corrects. This is what makes an agent recover from a bad tool call without a human in the loop. - Cancel (
is_error=False) — "that action isn't allowed." The model sees a normal result explaining the refusal and continues without retrying.
Chaining & scope¶
- Veto chain. Handlers are consulted in registration order; the first
skipwins and short-circuits the rest. Handlers returningNonedefer. - Per call. In a multi-call turn, each call is judged independently — you can veto one and let the others run. Vetoed calls are answered with synthesized results; the executed subset dispatches normally.
- Error isolation. A handler that raises is logged and skipped; dispatch proceeds.
- Scope. Fires from
run(),stream(), andstream_typed()— every entry point that drives the React loop.
Registering the handler¶
Related¶
- Tool-Call Dispatch Rewriter — the name/args rewrite seam that runs before steering
- Streaming & Callbacks — the callback surface
- Writing Tools — authoring the tools guards protect