Guide 13 — Tool-Call Dispatch Rewriter (v7.19.0)¶
Version: v7.19.0+ Status: Stable Related: Guide 03 — Streaming & Callbacks, Guide 12 — State-Predicate Preconditions
on_tool_call_dispatch is an optional callback hook that lets adopters
rewrite a tool call between the time the LLM emits it and the
time LangGraph dispatches it. The hook runs inside the React node
(not at the engine post-chain layer) and mutates
ai_message.tool_calls in place before the AIMessage returns to
LangGraph's ToolNode.
Two production patterns motivated v7.19.0 (both from a production adopter):
- SL-02 — CSV export rerouting. The model calls
export_csv, but on Slack the result must arrive as aslack_upload_filewith the rows attached. Rewrite the call before dispatch. - SL-04 — Per-user memory titles. The model calls
save_memorywith a generic title; the adopter injects[user=<slack_user_name>] ...into the title so two users in the same channel don't mix memories.
Architectural asymmetry from on_response_render¶
on_response_render (v7.18.0) fires at the engine post-chain
layer — after the transformation chain, before on_agent_end. The
engine owns the seam.
on_tool_call_dispatch (v7.19.0) fires inside the React node —
after the LLM returns, before the AIMessage flows to LangGraph's
dispatcher.
This asymmetry is intentional:
| Layer | Owner | Seam available? |
|---|---|---|
on_response_render |
engine | Yes — engine intercepts final_response before delivery. |
on_tool_call_dispatch |
React node | LangGraph owns dispatch (prebuilt.ToolNode). There is no symfonic-owned seam between react and the dispatcher. Mutating one node earlier IS the right seam. |
Firing post-react in the engine would defeat the purpose: LangGraph already dispatched the (un-rewritten) call by then.
Handler signature¶
from typing import Any
from symfonic.core.contracts.callbacks import ToolCallDispatchEvent
class SlackCallbacks:
async def on_tool_call_dispatch(
self,
event: ToolCallDispatchEvent,
state: dict[str, Any],
) -> dict[str, Any] | None:
...
Two-arg signature (event + state). tool_name and args already
live on the event; no need to duplicate them as separate parameters.
Event shape¶
@dataclass(frozen=True)
class ToolCallDispatchEvent:
run_id: str
iteration_index: int
node_name: str # always "react" in v7.19.0
call_id: str # LangChain tool_call["id"]
tool_name: str # LangChain tool_call["name"]
args: dict[str, Any] # LangChain tool_call["args"]
available_tools: tuple[str, ...] # post-routing palette
Return-value semantics¶
| Return | Effect |
|---|---|
None |
Pass-through. The call dispatches unchanged. Pythonic default. |
{"tool_name": str, "args": dict} |
Rewrite. The call dispatches with the new name + args. The call_id is preserved verbatim (engine owns it). |
No cancellation in v1. If you need to drop a call, route it to a no-op tool — don't introduce a third return state.
The dict MUST NOT contain call_id. If it does, the engine logs
a WARNING and strips it. The original call_id is preserved.
Validation gate (load-bearing)¶
After all handlers run, the React node verifies the final tool_name
against event.available_tools (the post-routing palette, sourced
from state["resolved_tools"] or the static tools list). If the
rewrite targets an unregistered tool:
- A structured WARNING is logged.
- The rewrite is dropped.
- The original call is dispatched instead.
This closes the "rewrite to unregistered tool → hard LangGraph error several frames later" footgun. Zero silent corruption.
SL-02 example — CSV export → Slack file upload¶
from typing import Any
from symfonic import SymfonicAgent
from symfonic.core.callbacks.manager import CallbackManager
from symfonic.core.contracts.callbacks import ToolCallDispatchEvent
class CsvToSlackUpload:
"""Rewrite export_csv -> slack_upload_file on Slack."""
async def on_tool_call_dispatch(
self,
event: ToolCallDispatchEvent,
state: dict[str, Any],
) -> dict[str, Any] | None:
if event.tool_name != "export_csv":
return None # not our concern
platform = (state.get("page_context") or {}).get("platform")
if platform != "slack":
return None # web download path is fine
channel = state["slack_channel_id"]
rows = event.args.get("rows", 0)
return {
"tool_name": "slack_upload_file",
"args": {
"channel": channel,
"file": f"rows.csv",
"rows": rows,
},
}
agent = SymfonicAgent(
...,
callbacks=CallbackManager([CsvToSlackUpload()]),
)
SL-04 example — Per-user memory title shape¶
class PerUserMemoryTitle:
"""Scope save_memory titles by Slack user so two users sharing
a channel don't cross-pollinate memories."""
async def on_tool_call_dispatch(
self,
event: ToolCallDispatchEvent,
state: dict[str, Any],
) -> dict[str, Any] | None:
if event.tool_name != "save_memory":
return None
slack_user = (state.get("page_context") or {}).get("slack_user_name")
if not slack_user:
return None # no Slack context; default scope applies
existing_title = event.args.get("title", "")
return {
"tool_name": "save_memory",
"args": {
**event.args,
"title": f"[user={slack_user}] {existing_title}".strip(),
},
}
Chaining¶
Handlers compose in registration order. Each handler receives an event reflecting the previous handler's rewrite (if any):
CallbackManager([
CsvToSlackUpload(), # may rewrite tool_name
PerUserMemoryTitle(), # sees the rewritten tool_name + args
])
A handler that returns None is a pass-through (no contribution).
A handler that raises is logged and skipped — the chain proceeds
with the previous state. A broken rewriter never stalls the React
loop.
Type safety¶
The dispatcher validates handler returns. Malformed payloads are silently dropped with a structured WARNING:
| Malformed return | Effect |
|---|---|
Non-dict (e.g. "rewrite") |
Dropped; WARNING logged. |
Dict missing tool_name or args |
Dropped; WARNING logged. |
tool_name not a string OR args not a dict |
Dropped; WARNING logged. |
Includes call_id |
call_id stripped; WARNING logged. Rewrite proceeds with the engine-owned call_id. |
The runtime never forwards a malformed rewrite.
Scope¶
agent.run()— yes.agent.stream()/agent.stream_typed()— yes.
Unlike on_response_render (which fires from the engine post-chain
layer and is therefore run()-only), on_tool_call_dispatch fires
inside the React node and runs on every entry point that drives the
React loop. Streaming adopters get coverage too.
Zero-cost invariant¶
Event construction is gated by CallbackManager.has_hook(
"on_tool_call_dispatch"). When no registered handler implements
the hook, the React node never builds a ToolCallDispatchEvent and
never enters the rewrite block — zero overhead.
class MetricsOnly:
async def on_llm_end(self, event):
...
# No on_tool_call_dispatch.
mgr = CallbackManager([MetricsOnly()])
assert mgr.has_hook("on_tool_call_dispatch") is False
Composability with other rewriters¶
on_tool_call_dispatch runs at a different layer than
on_response_render:
| Hook | Layer | Mutates |
|---|---|---|
on_tool_call_dispatch |
React node, per-iteration, per-call | ai_message.tool_calls |
on_response_render |
Engine post-chain, once per turn | final_response |
They don't conflict and can be wired together in the same
CallbackManager.
Debugging¶
Enable DEBUG logging on the React node to trace per-call dispatch:
The validation-gate WARNING and handler-exception logs surface at
WARNING / ERROR level and are emitted from
symfonic.core.callbacks.manager and symfonic.core.nodes.react.
Migration notes¶
- v7.18.x adopters wiring
on_response_renderneed no migration.on_tool_call_dispatchis a new optional hook; existing handlers that don't implement it are silently skipped. - Forked CallbackManager adopters — verify your fork's optional
dispatch path matches the v7.19.0 shape. The diagnostic helper
CallbackManager.describe_hook_handlers("on_tool_call_dispatch")(introduced in v7.18.1) returns[(handler_qualname, implements_hook), ...]for triage.
References¶
- Adopter request — SL-02 CSV export + SL-04 per-user memory title
src/symfonic/core/contracts/callbacks.py—ToolCallDispatchEventsrc/symfonic/core/callbacks/manager.py—on_tool_call_dispatchsrc/symfonic/core/callbacks/protocol.py— optional-hook doc-commentsrc/symfonic/core/nodes/react.py:920— emission sitetests/core/callbacks/test_tool_call_dispatch_event.py— manager contracttests/agent/callbacks/test_tool_call_dispatch_rewrite.py— React-node integration