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
¶
Emitted when agent execution begins.
BeforeToolCallEvent
dataclass
¶
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)):
Noneor{"action": "proceed"}-> execute the tool normally.{"action": "skip", "content": str, "is_error": bool=True}-> do NOT execute; synthesize aToolMessagecarryingcontent(withstatus="error"whenis_error, else"success") for thiscall_id. Useis_error=Truefor corrective feedback the model should retry against;is_error=Falsefor 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 |
call_id |
str
|
The LangChain tool_call |
args |
dict[str, Any]
|
The (post-rewrite) tool_call |
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_confidencefrom telemetry by bucketingmax_finding_confidenceagainstaction_taken. - Feed fabrication rates per
modeandaction_takeninto 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.
|
findings |
list[dict[str, Any]]
|
List of |
refuse_threshold |
float
|
The |
max_finding_confidence |
float
|
|
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 |
preview_truncated |
bool
|
|
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
¶
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
¶
Emitted after a graph node completes successfully.
NodeErrorEvent
dataclass
¶
Emitted when a graph node raises an exception.
NodeStartEvent
dataclass
¶
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.Nonewhen the skill has neither attribute set.action_tool— the action tool the winning skill resolves to (mirrors the audit record'saction_tool).pre_tool— the bare-identifier pre_tool that was selected to force. This is whattool_choicewill be set to on the bound runnable.force_mode— the resolved force mode for this turn (cross- refs T-7.20.0.8procedural_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 thetools_conditionloop-detection cut atcore/edges/tool_condition.py:38-46AND the LangGraphrecursion_limitcap."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 hitrecursion_limitbefore 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 onFrameworkConfig.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: |
content_type |
str
|
Python type name of |
has_tool_calls |
bool
|
|
tool_call_count |
int
|
Number of tool_calls on the terminal AIMessage
(0 when |
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_finalsalvage (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: |
iteration_index |
int
|
0-indexed React loop iteration that produced
the final response. |
model |
str
|
Model identifier from the most recent LLM invocation
(matches :attr: |
node_name |
str
|
Source node name. Always |
original_content |
str
|
The post-transformation final_response text
as the engine would have delivered it without the rewriter.
Adopters comparing |
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
¶
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
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
Noneto 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 containcall_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_callentry 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
Noneif no handler rewrote). - Validation: the React node walks the resulting
tool_callsand verifies every name is inavailable_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: |
iteration_index |
int
|
0-indexed React loop iteration that produced
the AIMessage carrying this tool_call. Matches
:attr: |
node_name |
str
|
Source node name. Always |
call_id |
str
|
The LangChain tool_call |
tool_name |
str
|
The LangChain tool_call |
args |
dict[str, Any]
|
The LangChain tool_call |
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
|
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: |
tool_name |
str
|
The LangChain tool |
result |
Any
|
The |
call_id |
str
|
The LangChain tool_call |
deferred |
bool
|
|
is_error |
bool
|
|
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.