Skip to content

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 a slack_upload_file with the rows attached. Rewrite the call before dispatch.
  • SL-04 — Per-user memory titles. The model calls save_memory with 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:

import logging
logging.getLogger("symfonic.core.nodes.react").setLevel(logging.DEBUG)

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_render need no migration. on_tool_call_dispatch is 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.pyToolCallDispatchEvent
  • src/symfonic/core/callbacks/manager.pyon_tool_call_dispatch
  • src/symfonic/core/callbacks/protocol.py — optional-hook doc-comment
  • src/symfonic/core/nodes/react.py:920 — emission site
  • tests/core/callbacks/test_tool_call_dispatch_event.py — manager contract
  • tests/agent/callbacks/test_tool_call_dispatch_rewrite.py — React-node integration