Skip to content

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 skip wins and short-circuits the rest. Handlers returning None defer.
  • 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(), and stream_typed() — every entry point that drives the React loop.

Registering the handler

resp = await agent.run(query, callbacks=[SqlGuard()])