Skip to content

symfonic.core.contracts.callbacks

callbacks

Callback event dataclasses -- frozen, immutable, hashable.

Typed payloads passed to CallbackHandler methods. Covers agent/node lifecycle, LLM invocation events, and token usage/composition tracking. No internal symfonic imports at module level (only stdlib + typing).

AgentEndEvent dataclass

AgentEndEvent(
    run_id: str,
    final_response: str | None,
    duration_ms: float,
    node_count: int | None,
)

Emitted when agent execution completes.

In the streaming path, final_response and node_count are None because stream() does not accumulate final state.

AgentStartEvent dataclass

AgentStartEvent(query: str, run_id: str)

Emitted when agent execution begins.

BeforeToolCallEvent dataclass

BeforeToolCallEvent(
    run_id: str,
    tool_name: str,
    call_id: str,
    args: dict[str, Any],
)

Emitted right BEFORE a tool call is executed, at the tool node.

The steering / guard seam. Where on_tool_call_dispatch (react node) REWRITES a call's name/args, on_before_tool_call decides whether the (already-rewritten) call runs at all — and, if not, what synthetic result the model sees instead. This is what lets an agent self-correct: a guard can refuse a call and hand the model a corrective message ("add a WHERE clause") so the next iteration fixes it, instead of the guard silently dropping the call or a tool blindly failing.

Fires per-call, inside InstrumentedToolNode (run() AND stream() / stream_typed()), AFTER any on_tool_call_dispatch rewrite. Because LangGraph always routes the tool node back to the model, a synthesized result flows straight into the model's next turn.

OPTIONAL hook on the CallbackHandler protocol — NOT in the runtime_checkable body. The emit site gates event construction on :meth:CallbackManager.has_hook ("on_before_tool_call") so no event is built when no handler subscribes.

Handler return contract (on_before_tool_call(event, state)):

  • None or {"action": "proceed"} -> execute the tool normally.
  • {"action": "skip", "content": str, "is_error": bool=True} -> do NOT execute; synthesize a ToolMessage carrying content (with status="error" when is_error, else "success") for this call_id. Use is_error=True for corrective feedback the model should retry against; is_error=False for a benign "not permitted" cancellation the model should simply move past.

Chaining: handlers are consulted in registration order; the FIRST handler that returns a skip verdict wins (a guard veto short-circuits the chain). Handlers returning None / proceed defer to the next.

Attributes:

Name Type Description
run_id str

The agent run identifier this turn belongs to.

tool_name str

The (post-rewrite) LangChain tool name about to run.

call_id str

The LangChain tool_call id this decision applies to.

args dict[str, Any]

The (post-rewrite) tool_call args dict.

FabricationDetectedEvent dataclass

FabricationDetectedEvent(
    run_id: str,
    mode: Literal["off", "observe", "revise", "refuse"],
    action_taken: Literal[
        "none",
        "observed",
        "revise_forced",
        "refuse_replaced",
    ],
    findings: list[dict[str, Any]],
    refuse_threshold: float,
    max_finding_confidence: float,
    original_draft_preview: str,
    replacement_preview: str | None,
    preview_truncated: bool,
)

Emitted when the fabrication check finds suspect content (v7.0.7).

Fires in observe / revise / refuse modes whenever :func:~symfonic.agent.middleware.fabrication.detect_fabrication returns at least one finding. The action_taken field discriminates whether the detector merely observed, requested a revision, or replaced the draft with the safe envelope -- so a single typed event covers the full mode matrix.

The companion ExtensionEvent(type='fabrication_detected'| 'fabrication_blocked') payload that v7.0.6 emitted is preserved one release for back-compat: handlers that expose on_fabrication_detected still receive it in v7.0.7 alongside this typed event. The untyped path is deprecated and will be removed in v7.1.

Privacy. original_draft_preview and replacement_preview contain raw LLM output text (truncated at 240 chars; the preview_truncated flag is True when the full draft was longer). PII redaction is the handler's responsibility -- the same stance documented for :class:LLMPreCallEvent (v6.1.7).

Use this event to:

  • Count detections per tenant / per mode for SRE dashboards.
  • Tune fabrication_refuse_min_confidence from telemetry by bucketing max_finding_confidence against action_taken.
  • Feed fabrication rates per mode and action_taken into production observability stacks.

Attributes:

Name Type Description
run_id str

The agent run identifier this turn belongs to.

mode Literal['off', 'observe', 'revise', 'refuse']

The effective fabrication-check mode that produced the finding ("off" only ever appears here when a future caller forces emission; the standard wiring never fires the event in "off").

action_taken Literal['none', 'observed', 'revise_forced', 'refuse_replaced']

What the wiring did with the findings. "none" is reserved for callers that bypass the dispatch (currently unused). "observed" -- telemetry only, draft untouched. "revise_forced" -- forced metacog revise verdict. "refuse_replaced" -- draft replaced with the safe envelope.

findings list[dict[str, Any]]

List of FabricationFinding.to_event_payload() dicts (JSON-serialisable shape). Same payload shape used by the deprecated ExtensionEvent path.

refuse_threshold float

The fabrication_refuse_min_confidence value in effect for this turn.

max_finding_confidence float

max(f.confidence for f in findings) -- the strongest signal in the finding list.

original_draft_preview str

First 240 characters of the draft response that produced the findings. Empty string when the draft was empty (the wiring short-circuits before that, so this is informational only).

replacement_preview str | None

First 240 characters of the replacement text used in refuse mode. None for any other action_taken value.

preview_truncated bool

True when len(original_draft) > 240.

LLMEndEvent dataclass

LLMEndEvent(
    model: str,
    output: str,
    usage: dict,
    run_id: str = "",
    node_name: str = "",
    pricing_unknown: bool = False,
    duration_ms: float = 0.0,
    started_at_utc: str = "",
    iteration_index: int = 0,
    turn_index: int = 0,
)

Fired when the LLM returns a response.

node_name (v7.4.3) discriminates between LLM call sites so consumers can attribute usage / cost / spans back to the originating subsystem (e.g. "react", "metacognition_critic", "context_window_summariser", "insight_extractor"). The default empty string preserves byte-identical payloads for pre-v7.4.3 handlers that do not inspect the field.

pricing_unknown (v7.4.5) is a programmatic discriminator that fires True when the model name had no entry in the pricing registry at typed-usage materialisation time -- letting downstream consumers tell "known model, $0 turn" apart from "unknown model, cost defaulted to $0". Default False preserves byte-identical payloads for pre-7.4.5 handlers; emitters that build the event from a raw usage dict can leave the flag alone and rely on :attr:typed_usage to surface the discriminator on demand.

duration_ms / started_at_utc (v7.15.0) carry wall-clock latency and the UTC ISO-8601 timestamp of the LLM invocation. Both default to zero / empty so pre-v7.15.0 emission paths that did not capture timing preserve byte-identical payloads. Production emitters use the :func:symfonic.core.callbacks.emit.llm_timing context manager which populates them exception-safely.

iteration_index (v7.15.0) is the 0-indexed React loop iteration that produced this LLM call. 0 is the natural sentinel for non-React sites (each fires once per consolidation cycle) and for the first React iteration. React emission sites compute it as sum(1 for m in state["messages"] if isinstance(m, AIMessage)) BEFORE the current ainvoke.

typed_usage property

typed_usage: TokenUsage

Return a typed TokenUsage built from the raw usage dict.

The returned :class:TokenUsage carries its own pricing_unknown flag derived from the live pricing registry at access time.

LLMPreCallEvent dataclass

LLMPreCallEvent(
    model: str,
    messages: list,
    tools: list,
    run_id: str,
    invocation_params: dict = dict(),
    node_name: str = "",
)

Fired right before the LLM provider call, after system-message consolidation and tool binding. Represents the exact payload the provider is about to receive.

Use this to log the wire-accurate prompt (e.g. for gateway trace events, compliance capture, replay fixtures). For the engine-view (pre-consolidation) prompt, use :class:LLMStartEvent instead.

messages here is the post-consolidation list: a single leading :class:~langchain_core.messages.SystemMessage (when any system content is present) followed by the non-system conversation messages in order.

tools is a list of {"name": str, "description": str, "schema": dict} records describing the resolved tool definitions the provider will see. Empty when no tools are bound.

invocation_params carries provider-native invocation kwargs (temperature, max_tokens, tool_choice, etc.) when the underlying LangChain chat-model adapter exposes them via _get_invocation_params. Empty {} when the adapter does not surface them.

.. note:: Payload fields may contain sensitive content (PII, credentials embedded in prompts, customer data). PII redaction is the callback author's responsibility -- this event ships the wire-accurate payload verbatim by design.

LLMStartEvent dataclass

LLMStartEvent(
    model: str,
    messages: list,
    run_id: str = "",
    system_prompt: str = "",
    node_name: str = "",
    turn_index: int = 0,
)

Fired when the LLM is invoked with the full message list.

This is the engine-view event: messages is the conversation history exactly as the react node received it, BEFORE system-message consolidation and BEFORE tool binding. Use this to debug the engine-side prompt assembly (memory hydration, context window composition, etc.).

For the wire-accurate payload the provider is about to receive, use :class:LLMPreCallEvent instead.

NodeEndEvent dataclass

NodeEndEvent(
    node_name: str, run_id: str, duration_ms: float
)

Emitted after a graph node completes successfully.

NodeErrorEvent dataclass

NodeErrorEvent(
    node_name: str, run_id: str, error: str, error_type: str
)

Emitted when a graph node raises an exception.

NodeStartEvent dataclass

NodeStartEvent(node_name: str, run_id: str)

Emitted before a graph node executes.

ProcedureSelectionEvent dataclass

ProcedureSelectionEvent(
    procedure_id: str | None,
    action_tool: str | None,
    pre_tool: str,
    force_mode: Literal["off", "soft", "hard"],
    candidate_count: int,
)

Emitted by the engine when the force-resolver picks a winning procedural skill (v7.20.0 T-7.20.0.9).

Fires inside _maybe_resolve_forced_tool_choice immediately before the audit-record stamp at engine.py::_record_force_audit, once per turn when the unambiguous-choice gate holds AND a winning candidate is selected. Does NOT fire when the resolver abstains (off mode, multi-skill ambiguity, provider refusal) — adopters who need to observe the refusal/abstain path consult the v7.19.2 per-turn refusal WARN instead.

OPTIONAL hook on the CallbackHandler protocol — not in the runtime_checkable body, dispatched via CallbackManager._dispatch_optional. Existing handlers without on_procedure_selection are silently skipped (zero-cost). Engine gates event construction on CallbackManager.has_hook("on_procedure_selection") so no event object is built when no handler subscribes.

Use cases (architect intent):

  • Per-procedure billing attribution at adopter layer.
  • A/B-test bucket assignment for procedural-rule experiments.
  • Audit trail for compliance reviews — "which procedure forced the tool call on turn N?"
  • Production routing telemetry for adopter dashboards.

Fields:

  • procedure_id — winning skill's node_id (or id) as best- effort string identifier. None when the skill has neither attribute set.
  • action_tool — the action tool the winning skill resolves to (mirrors the audit record's action_tool).
  • pre_tool — the bare-identifier pre_tool that was selected to force. This is what tool_choice will be set to on the bound runnable.
  • force_mode — the resolved force mode for this turn (cross- refs T-7.20.0.8 procedural_force_tool_choice). Lets adopters branch on strict-fail vs abstain semantics without re-reading FrameworkConfig.
  • candidate_count — how many candidate pre_tools the resolver considered before the unambiguous-choice gate selected one. Always >= 1 (event fires only on a successful pick).

ReactLoopEndEvent dataclass

ReactLoopEndEvent(
    run_id: str,
    iteration_index: int,
    content_type: str,
    has_tool_calls: bool,
    tool_call_count: int,
    termination_reason: Literal[
        "tool_calls_with_no_text",
        "no_content_no_tool_calls",
        "recursion_exhausted",
    ],
)

Emitted by the React node when the loop terminates with an empty final_response (v7.17.0).

Fires when the terminal AIMessage produces no usable text content AFTER the v7.17.0 content-block salvage attempt -- adopters debugging silent-bail incidents (Jarvio's qwen3-max followup 2026-06-03) need a structured signal that distinguishes the three forced-termination paths:

  • "tool_calls_with_no_text" -- the model emitted tool_calls but no text content even after flattening Anthropic-style content blocks. Covers the OpenAI-compatible terminal-with-tool_calls shape AND the tools_condition loop-detection cut at core/edges/tool_condition.py:38-46 AND the LangGraph recursion_limit cap.
  • "no_content_no_tool_calls" -- the model produced an AIMessage with neither text nor tool_calls. Canonical Anthropic / OpenAI shape gone wrong (provider returned an empty completion).
  • "recursion_exhausted" (v8.6.4) -- LangGraph hit recursion_limit before the loop converged. Emitted by the ENGINE (not the react node) when the runtime salvages the partial state. Distinct from the react-node cuts above so genuine runaways still alarm/observe while the engine recovers a completion from the gathered ToolMessages (gated on FrameworkConfig.synthesize_on_empty_final).

This event is OPTIONAL: handlers that do not implement on_react_loop_end are silently skipped via :meth:CallbackManager._dispatch_optional. Zero-cost when no handler registers it.

Wire this with :class:FrameworkConfig.synthesize_on_empty_final for the production salvage path: the event tells you WHY the loop bailed; the knob tells the engine to recover by issuing a follow-up no-tool synthesis call. Independent levers -- use either or both.

Attributes:

Name Type Description
run_id str

The agent run identifier this turn belongs to.

iteration_index int

0-indexed React loop iteration that produced the terminating AIMessage. Matches :attr:LLMEndEvent.iteration_index.

content_type str

Python type name of ai_message.content ("str", "list", "NoneType"). Lets adopters tell "model returned empty string" apart from "model returned a list of blocks that all got dropped by salvage".

has_tool_calls bool

True when the terminal AIMessage carried tool_calls. Combined with termination_reason this disambiguates the three forced-termination paths.

tool_call_count int

Number of tool_calls on the terminal AIMessage (0 when has_tool_calls=False).

termination_reason Literal['tool_calls_with_no_text', 'no_content_no_tool_calls', 'recursion_exhausted']

Structured reason code. See the class docstring for the supported values.

ResponseRenderEvent dataclass

ResponseRenderEvent(
    run_id: str,
    iteration_index: int,
    model: str,
    node_name: str,
    original_content: str,
)

Emitted by the engine layer when a final response is about to be returned to the adopter (v7.18.0, Option B).

Fires AFTER the full transformation chain (synthesize-on-empty- final salvage -> extraction-stripping -> citation/footer stripping -> fabrication check -> metacog reflection) and BEFORE :class:AgentEndEvent dispatch. Carries the post-transformation content alongside the adopter-visible run_id so an on_response_render handler can rewrite the content (e.g. translate Markdown to Slack mrkdwn) and return the rewritten string.

SCOPE -- agent.run() ONLY. on_response_render does NOT fire from agent.stream() or agent.stream_events(). Streaming yields TextDeltaEvent chunks at token granularity, so applying a content rewrite is fundamentally impossible without buffering the whole stream (which defeats streaming). Adopters needing streaming rewrites implement them client-side OR buffer in their adopter layer. See docs/guides/12-state-predicate-preconditions.md for rationale.

The rewriter runs ONCE per turn. on_response_render is an OPTIONAL hook -- handlers that do not implement it are silently skipped. Zero-cost when no handler registers it.

Composability rule (locked v7.18.0):

  • synthesize_on_empty_final salvage (v7.17.0) runs FIRST.
  • The transformation chain (extraction-stripping, citation stripping, activation-footer stripping, fabrication check, metacog reflection) runs NEXT.
  • The rewriter runs AFTER the transformation chain, BEFORE on_agent_end.
  • The rewriter is invoked at most ONCE per turn.

Attributes:

Name Type Description
run_id str

The agent run identifier this turn belongs to. Matches :attr:AgentStartEvent.run_id and :attr:ReactLoopEndEvent.run_id.

iteration_index int

0-indexed React loop iteration that produced the final response. -1 signals "engine-emitted, not react-loop-emitted" -- the engine post-chain layer does not have access to the react loop's local iteration counter, so the field is set to -1 for engine-emitted events. Adopters needing the exact iteration count subscribe to :class:ReactLoopEndEvent instead.

model str

Model identifier from the most recent LLM invocation (matches :attr:LLMEndEvent.model). Useful for handlers that condition rewrites on provider class.

node_name str

Source node name. Always "react" in v7.18 -- other terminal nodes can be wired in future ships.

original_content str

The post-transformation final_response text as the engine would have delivered it without the rewriter. Adopters comparing original_content to the returned string get a free A/B trace of the rewriter's impact.

TokenCompositionEvent dataclass

TokenCompositionEvent(
    system_prompt_tokens: int = 0,
    tool_definitions_tokens: int = 0,
    conversation_history_tokens: int = 0,
    memory_context_tokens: int = 0,
    run_id: str = "",
)

Breakdown of input tokens by prompt section (for the dashboard).

Emitted by the react node before each LLM call when the HMS prompt builder provides per-section token estimates.

TokenUsage dataclass

TokenUsage(
    input_tokens: int = 0,
    output_tokens: int = 0,
    reasoning_tokens: int = 0,
    cached_tokens: int = 0,
    cache_creation_tokens: int = 0,
    model: str = "",
    cost_usd: float = 0.0,
    pricing_unknown: bool = False,
    cache_ttl: str | None = None,
)

Typed token usage breakdown with cost calculation.

pricing_unknown (v7.4.5) is set True by :meth:from_dict when the supplied model was not present in the pricing registry (neither by exact match nor by strict prefix). In that case cost_usd is 0.0 and a one-time WARNING has been emitted via the pricing module. Use this flag to distinguish "known model, this turn cost $0 because there were no billable tokens" from "model name was not recognised so cost defaulted to $0" -- the two cases were indistinguishable in earlier versions and the second one silently masked stale registries.

from_dict classmethod

from_dict(usage: dict, model: str = '') -> TokenUsage

Build from a provider usage dict (Anthropic / OpenAI / etc. format).

Supports: - Anthropic: input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, and (v7.15.0) extended-thinking reasoning tokens via either top-level reasoning_tokens (forward-compat for Opus-5+ separated billing) or nested output_token_details.reasoning (canonical LangChain shape on Opus-4-x today). - OpenAI: prompt_tokens, completion_tokens

Source code in src/symfonic/core/contracts/callbacks.py
@classmethod
def from_dict(cls, usage: dict, model: str = "") -> TokenUsage:
    """Build from a provider usage dict (Anthropic / OpenAI / etc. format).

    Supports:
    - Anthropic: ``input_tokens``, ``output_tokens``,
      ``cache_read_input_tokens``, ``cache_creation_input_tokens``,
      and (v7.15.0) extended-thinking reasoning tokens via either
      top-level ``reasoning_tokens`` (forward-compat for Opus-5+
      separated billing) or nested ``output_token_details.reasoning``
      (canonical LangChain shape on Opus-4-x today).
    - OpenAI: ``prompt_tokens``, ``completion_tokens``
    """
    from symfonic.core.observability.pricing import calculate_cost_with_meta

    input_tokens = usage.get("input_tokens", usage.get("prompt_tokens", 0))
    output_tokens = usage.get(
        "output_tokens", usage.get("completion_tokens", 0)
    )
    cached = usage.get("cache_read_input_tokens", 0)
    cache_creation = usage.get("cache_creation_input_tokens", 0)
    # v7.15.0: reasoning tokens.  Priority order:
    # 1. Top-level ``reasoning_tokens`` (future-proof when Opus-5
    #    exposes a flat key).
    # 2. Nested ``output_token_details.reasoning`` (LangChain
    #    ChatAnthropic >=0.3.x canonical for Opus-4-x).
    # 3. Default 0.
    reasoning = usage.get("reasoning_tokens", 0)
    if not reasoning:
        out_details = usage.get("output_token_details")
        if isinstance(out_details, dict):
            reasoning = out_details.get("reasoning", 0)
    reasoning = int(reasoning or 0)

    # v7.20.0 §5 cache-ttl extraction.  The actual Anthropic-response
    # mining (``cache_creation.ephemeral.ttl``) lands at the provider
    # boundary in T-7.20.0.6 -- here we simply propagate whatever the
    # caller put at the top of the usage dict.  Pre-v7.20.0 callers
    # (and non-Anthropic providers) pass nothing; cache_ttl stays
    # ``None`` and cost calc uses ``cache_write`` (the 5-minute rate).
    cache_ttl = usage.get("cache_ttl")

    cost, pricing_unknown = calculate_cost_with_meta(
        model,
        input_tokens,
        output_tokens,
        cached,
        cache_creation,
        reasoning_tokens=reasoning,
        cache_ttl=cache_ttl,
    )
    return cls(
        input_tokens=input_tokens,
        output_tokens=output_tokens,
        reasoning_tokens=reasoning,
        cached_tokens=cached,
        cache_creation_tokens=cache_creation,
        model=model,
        cost_usd=cost,
        pricing_unknown=pricing_unknown,
        cache_ttl=cache_ttl,
    )

ToolCallDispatchEvent dataclass

ToolCallDispatchEvent(
    run_id: str,
    iteration_index: int,
    node_name: str,
    call_id: str,
    tool_name: str,
    args: dict[str, Any],
    available_tools: tuple[str, ...],
)

Emitted inside the React node when an AIMessage carries one or more tool_calls that LangGraph's ToolNode is about to dispatch (v7.19.0, Jarvio SL-02 / SL-04 enablement).

Fires per-call (one event per entry in ai_message.tool_calls) AFTER the LLM has returned and BEFORE the React node returns the AIMessage to LangGraph for dispatch. An on_tool_call_dispatch handler may inspect the call -- name, arguments, surrounding state (channel, tenant, user, page_context, etc.) -- and either:

  • Return None to pass the call through unchanged (no rewrite).
  • Return a dict with {"tool_name": str, "args": dict} to REWRITE the call before LangGraph dispatches it. The dict MUST NOT contain call_id -- the engine owns it.

Rewrites are validated against available_tools (the post- routing palette). A rewrite that names an unregistered tool is DROPPED (logged as WARNING) and the original call is dispatched instead. Zero silent corruption.

SCOPE -- the React node. node_name is always "react" in v7.19; other terminal nodes can be wired in future ships. The event fires once per call inside the SAME node iteration, so chained handlers (registration-order composition) compose against the per-call args dict, not against the post-LangGraph ToolMessage result.

SCOPE -- agent.run() AND agent.stream() / agent.stream_ typed(). Unlike on_response_render (which fires from the engine post-chain layer and is therefore run()-only), this hook fires inside the React node and runs on every entry point that drives the React loop. Adopters wiring CSV-export -> file-upload or per-user memory title-shape rewrites get coverage across all invocation modes.

ARCHITECTURAL ASYMMETRY (NOTED, INTENTIONAL). Pre-v7.19 the callback hooks that mutate engine output (on_response_render) fire at the engine post-chain layer. on_tool_call_dispatch fires inside the React node instead. This is deliberate: LangGraph owns the dispatch loop (prebuilt.ToolNode), so there is no symfonic-owned seam between react and LangGraph's dispatcher. Mutating one node earlier IS the right seam -- firing post-react in the engine would defeat the purpose because LangGraph already dispatched. See docs/guides/13-tool-call-dispatch-rewriter.md for the asymmetry rationale.

Composability rule (locked v7.19.0):

  • The React node emits ONE event per tool_call entry on the AIMessage.
  • Handlers chain in registration order; each receives the previous handler's rewrite (or the original args when no prior handler rewrote).
  • The CallbackManager returns the FINAL rewrite (or None if no handler rewrote).
  • Validation: the React node walks the resulting tool_calls and verifies every name is in available_tools. Mismatches are dropped with a WARNING; the original call is emitted.

Attributes:

Name Type Description
run_id str

The agent run identifier this turn belongs to. Matches :attr:AgentStartEvent.run_id and :attr:ResponseRenderEvent.run_id.

iteration_index int

0-indexed React loop iteration that produced the AIMessage carrying this tool_call. Matches :attr:LLMEndEvent.iteration_index for the same turn.

node_name str

Source node name. Always "react" in v7.19.

call_id str

The LangChain tool_call id field. Stable per call; handlers MUST NOT include this in their rewrite return value (the engine owns it).

tool_name str

The LangChain tool_call name field. A handler may rewrite this to any name present in :attr:available_tools.

args dict[str, Any]

The LangChain tool_call args dict. A handler may return a fully-replaced args dict (e.g. translate a CSV export call into a Slack file upload by remapping the payload).

available_tools tuple[str, ...]

Post-routing tool palette as a tuple of tool names. Used by the validation gate to drop rewrites that target unregistered tools. Sourced from state["resolved_tools"].

ToolResultCallbackEvent dataclass

ToolResultCallbackEvent(
    run_id: str,
    tool_name: str,
    result: Any,
    call_id: str,
    deferred: bool = False,
    is_error: bool = False,
)

Emitted AFTER a tool returns, carrying the produced result (v8.6.4).

Companion to :class:ToolCallDispatchEvent. Where on_tool_call_dispatch fires BEFORE LangGraph dispatches the call (and may rewrite it), on_tool_result fires AFTER the tool node produced the ToolMessage -- so adopters can react post-tool / post-defer (audit the result, mirror it to an external sink, trigger a follow-up workflow) without parsing the raw message stream.

Parallels the always-on ObservabilityHook.on_tool_end surface (observability/tool_span_seam.py) but lives on the user-facing CallbackHandler protocol where on_tool_call_dispatch already lives -- giving adopters a single registration surface for both the pre-dispatch and post-result seams.

OPTIONAL hook on the CallbackHandler protocol -- NOT in the runtime_checkable body, dispatched via :meth:CallbackManager._dispatch_optional. Existing handlers without on_tool_result are silently skipped (zero-cost). The emit site (InstrumentedToolNode.ainvoke) gates event construction on :meth:CallbackManager.has_hook so no event object is built when no handler subscribes.

SCOPE -- the tool node (InstrumentedToolNode). Fires once per dispatched tool call on every entry point that drives the tool node (run() AND stream() / stream_typed()).

deferred semantics (v8.6.4): False at the InstrumentedToolNode site because LangGraph dispatched the tool synchronously and the ToolMessage is in hand. The True case -- a tool whose result is filled later by an ask_user / HITL resume cycle (elicitation / interrupt nodes) -- is NOT wired in v8.6.4: those nodes do not currently thread the CallbackManager to a post-resume ToolMessage emit site. Adopters needing the deferred-result signal subscribe to the existing PauseToken / resume surface; folding deferred=True into this event is tracked as follow-up plumbing.

Attributes:

Name Type Description
run_id str

The agent run identifier this turn belongs to. Matches :attr:ToolCallDispatchEvent.run_id.

tool_name str

The LangChain tool name that produced the result.

result Any

The ToolMessage.content the tool returned. None on the framework-cancellation path (the dispatch was interrupted before a ToolMessage was produced).

call_id str

The LangChain tool_call id correlating this result to its originating :class:ToolCallDispatchEvent.

deferred bool

True when the result will be supplied later by a resume cycle rather than the synchronous tool node. Always False at the InstrumentedToolNode emit site in v8.6.4 (see the class docstring).

is_error bool

True when the ToolMessage carried status="error" (LangGraph caught a tool exception).

ToolRoutingDecisionEvent dataclass

ToolRoutingDecisionEvent(
    run_id: str,
    mode: str,
    intent_label: str,
    total_tools: int,
    narrowed_tool_names: list,
    saved_tokens_estimate: int = 0,
)

Emitted by the engine when dynamic tool routing runs (v7.0.1).

Fires in BOTH observe and enforce modes so operators can watch the narrower's decisions before flipping to enforcement. Never fires in off mode (zero-cost guarantee).

narrowed_tool_names is the list of names that would be bound to the LLM this turn. In enforce mode this IS the bound list; in observe mode the full manifest is still bound and this field documents what WOULD have been bound.

saved_tokens_estimate is a rough floor calculated as sum(chars // 4) over the dropped tools' name + description. Schemas are ignored so the real saving is usually higher.

PII note: tool names and descriptions may contain tenant- identifying substrings. Callback authors are responsible for redacting before persisting.

UserCorrectionEvent dataclass

UserCorrectionEvent(
    turn_id: str,
    original_response: str,
    correction_text: str,
    activation_log: dict,
    tenant_id: str,
    scope: Any = None,
)

Emitted when a user explicitly corrects a previous agent response.

Carries the original response, the correction text, and the activation log from that turn so that downstream handlers (e.g. NegativeReinforcementHandler) can apply targeted edge-weight decay.

T-7.21.8 -- optional scope field carries the full TenantScope (sub_tenant_id, namespace, and any future field) so handlers don't have to reconstruct it from tenant_id alone. Backward compatible: defaults to None so pre-v7.21 callers keep working. The engine dispatch site stamps the live scope; adopter call sites can do the same when they have it.