Skip to content

symfonic.core.callbacks.base

base

BaseCallbackHandler -- public no-op base for partial callback handlers.

Subclass this and override ONLY the hooks you need. Every CallbackHandler protocol method -- REQUIRED (the runtime_checkable Protocol body) AND OPTIONAL (the documented extension hooks) -- has an async def ...: return None default here, so a partial subclass NEVER raises AttributeError when the :class:~symfonic.core.callbacks.manager.CallbackManager dispatches an event the subclass did not override.

Why this exists (architect verdict, 2026-06-19)::

CallbackManager._dispatch (manager.py) resolves REQUIRED hooks via
``getattr(handler, method)`` with NO default.  A duck-typed handler that
implements only some required hooks raises AttributeError on every
other one -- the error is caught + logged by ``_dispatch`` (so the
per-turn log fills with stack traces) and the handler's intended side
effect for the overridden hooks still fires, but the partial handler
spams logs every turn.  The fix is THIS base class, NOT a tolerant
``_dispatch`` (a tolerant dispatch would mask genuine typos on
lifecycle hooks).

Usage::

from symfonic.core.callbacks import BaseCallbackHandler

class MyHandler(BaseCallbackHandler):
    async def on_agent_end(self, event):
        await my_sink.record(event.final_response)
    # All other hooks inherit the no-op default -- no boilerplate,
    # no AttributeError spam.

This is distinct from :class:NoOpCallbackHandler, which covers only the 8 REQUIRED hooks and is used internally as the zero-overhead default. BaseCallbackHandler is the PUBLIC subclassing surface and covers the full required + optional hook set.

BaseCallbackHandler

Public no-op base for CallbackHandler implementations.

Subclass and override only the hooks you need. Satisfies the :class:~symfonic.core.callbacks.protocol.CallbackHandler protocol via structural typing (every required method is present and async).

on_before_tool_call async

on_before_tool_call(
    event: BeforeToolCallEvent, state: dict[str, Any]
) -> dict[str, Any] | None

Tool-call guard / steering hook. Returns None (proceed).

Returning None executes the tool normally. Override and return {"action": "skip", "content": str, "is_error": bool} to refuse the call and hand the model a synthetic result instead (corrective feedback or a benign cancellation).

Source code in src/symfonic/core/callbacks/base.py
async def on_before_tool_call(
    self,
    event: BeforeToolCallEvent,
    state: dict[str, Any],
) -> dict[str, Any] | None:
    """Tool-call guard / steering hook.  Returns ``None`` (proceed).

    Returning ``None`` executes the tool normally. Override and return
    ``{"action": "skip", "content": str, "is_error": bool}`` to refuse
    the call and hand the model a synthetic result instead (corrective
    feedback or a benign cancellation).
    """
    return None

on_response_render async

on_response_render(
    content: str,
    state: dict[str, Any],
    metadata: ResponseRenderEvent,
) -> str

Content-rewriter hook (v7.18.0). Returns content unchanged.

Unlike the fire-and-forget hooks, on_response_render RETURNS the (possibly rewritten) content string. The no-op default returns content verbatim so a subclass that does not override it is a pass-through in the rewriter chain.

Source code in src/symfonic/core/callbacks/base.py
async def on_response_render(
    self,
    content: str,
    state: dict[str, Any],
    metadata: ResponseRenderEvent,
) -> str:
    """Content-rewriter hook (v7.18.0).  Returns content unchanged.

    Unlike the fire-and-forget hooks, ``on_response_render`` RETURNS
    the (possibly rewritten) content string.  The no-op default
    returns ``content`` verbatim so a subclass that does not override
    it is a pass-through in the rewriter chain.
    """
    return content

on_tool_call_dispatch async

on_tool_call_dispatch(
    event: ToolCallDispatchEvent, state: dict[str, Any]
) -> dict[str, Any] | None

Tool-call rewriter hook (v7.19.0). Returns None (no rewrite).

Returning None is the pass-through contract -- the no-op default never rewrites a dispatched tool call.

Source code in src/symfonic/core/callbacks/base.py
async def on_tool_call_dispatch(
    self,
    event: ToolCallDispatchEvent,
    state: dict[str, Any],
) -> dict[str, Any] | None:
    """Tool-call rewriter hook (v7.19.0).  Returns ``None`` (no rewrite).

    Returning ``None`` is the pass-through contract -- the no-op
    default never rewrites a dispatched tool call.
    """
    return None

on_tool_result async

on_tool_result(event: ToolResultCallbackEvent) -> None

Fired AFTER a tool returns (v8.6.4). No-op by default.

Source code in src/symfonic/core/callbacks/base.py
async def on_tool_result(self, event: ToolResultCallbackEvent) -> None:
    """Fired AFTER a tool returns (v8.6.4).  No-op by default."""
    return None