symfonic.core.callbacks¶
callbacks ¶
Callback system -- protocol, base class, manager, and NoOp default.
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
¶
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
on_response_render
async
¶
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
on_tool_call_dispatch
async
¶
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
on_tool_result
async
¶
CallbackHandler ¶
Bases: Protocol
Async callback protocol for agent graph lifecycle events.
Implement this protocol to receive notifications about agent start/end, node start/end/error, LLM invocation events, and token composition breakdowns during graph execution.
CallbackManager ¶
Fan-out dispatcher for multiple CallbackHandler instances.
Satisfies the CallbackHandler protocol (composite pattern). When the handler list is empty, all methods return immediately (zero-cost).
Source code in src/symfonic/core/callbacks/manager.py
add ¶
close_open_scopes
async
¶
Close every lifecycle pair run_id left open. Returns the count.
TA8.29. Exactly one termination per open scope: the ledger removes an entry before its close is dispatched, so a second call closes nothing and a close-then-cancel race cannot double-count a span or a token bill. Zero is the other failure -- a scope left open is the unbalanced-pair defect this method exists to remove.
Every close goes through :meth:_dispatch, which is where the two
error families stay separate. A handler that raises here is logged and
isolated, and the remaining handlers still get their close: one
raising observer silently cancelling everyone else's teardown would be
a new defect in the shape of the old one.
Source code in src/symfonic/core/callbacks/manager.py
describe_hook_handlers ¶
Introspection helper for adopter triage (v7.18.1).
Returns [(handler_qualname, implements_hook), ...] for every
registered handler. Adopters debugging "which of my handlers is
responsible for has_hook returning True?" call this to
verify the registration without diffing internal state.
Example::
>>> mgr.describe_hook_handlers("on_response_render")
[('myadopter.SlackRenderCallback', True),
('symfonic.telemetry.MetricsHandler', False)]
Zero-cost when called outside a hot path -- intended for diagnostic logs and ad-hoc REPL introspection, not steady-state dispatch. The handler descriptor includes module + qualname so a forked CallbackManager handler shows up immediately as a non-symfonic module path.
Source code in src/symfonic/core/callbacks/manager.py
finish_turn
async
¶
finish_turn(*, run_id: str, duration_ms: float, outcome: TerminationOutcome = 'completed', final_response: str | None = None, node_count: int | None = None) -> bool
Terminate run_id once: close open scopes, then end the turn.
Returns True for the caller that actually terminated the run and False
for every later one. Two exit paths can reach a turn's teardown -- a
consumer's aclose and a task cancellation can arrive in either
order -- and both must produce one termination.
The outcome is never collapsed. "completed" is the only value
that means the turn answered; "failed", "cancelled" and
"abandoned" each say why it did not, and an incomplete turn never
gets the terminal a completed one gets.
Source code in src/symfonic/core/callbacks/manager.py
has_hook ¶
True when at least one handler implements the given hook.
Used at emission sites to skip event construction entirely for
OPTIONAL hooks (e.g. on_llm_pre_call, on_user_correction)
when no registered handler implements them. Preserves the
zero-cost guarantee for optional extensions.
Source code in src/symfonic/core/callbacks/manager.py
merge ¶
Return a new CallbackManager combining both handler lists.
The merged manager gets a fresh scope ledger, which is correct
because the merged object is what the run's emission sites are handed:
every node_start and llm_start of that run is recorded on it,
and every close is dispatched from it.
Source code in src/symfonic/core/callbacks/manager.py
on_agent_end
async
¶
Dispatch AgentEndEvent to all handlers.
on_agent_start
async
¶
Dispatch AgentStartEvent to all handlers, and arm the turn.
TA8.29. The termination latch is per TURN, not per run id for this
manager's lifetime. SymfonicAgent.resume and resume_interrupt
re-enter stream_events with the PAUSED run's id, and a
deps-registered CallbackManager is long-lived, so without this the
resumed turn would dispatch on_agent_start and then nothing at all
-- no AgentEndEvent, and no close for the node and LLM scopes that
turn opened.
Source code in src/symfonic/core/callbacks/manager.py
on_before_tool_call
async
¶
Consult guard handlers before a tool executes; return a verdict.
The steering / guard seam (companion to the on_tool_call_dispatch
rewriter). Handlers are consulted in registration order and the FIRST
handler that returns a skip verdict wins — a guard veto
short-circuits the chain. Handlers that return None /
{"action": "proceed"} defer to the next handler.
Return value delivered to :class:InstrumentedToolNode:
None-> no guard skipped the call; execute normally.{"action": "skip", "content": str, "is_error": bool}-> the tool node synthesizes aToolMessagefromcontentinstead of executing.is_errordefaults toTrue.
Malformed verdicts (missing/empty content on a skip) are
dropped with a WARNING and treated as proceed so a buggy guard
cannot silently swallow a call. A handler that raises is logged and
skipped (the chain proceeds) — error isolation matches the other
optional hooks.
Source code in src/symfonic/core/callbacks/manager.py
524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 | |
on_fabrication_detected
async
¶
Dispatch FabricationDetectedEvent to handlers that implement it.
on_fabrication_detected is an OPTIONAL hook (the dispatch
was implicit in v7.0.6 via raw getattr; v7.0.7 promotes it
to a typed event with proper manager-level fan-out). Handlers
that do not implement the method are silently skipped.
Preserves the zero-cost invariant: callers gate construction
of :class:~symfonic.core.contracts.callbacks.FabricationDetectedEvent
on :meth:has_hook ("on_fabrication_detected") so no
event objects are built when no handler listens.
Source code in src/symfonic/core/callbacks/manager.py
on_llm_end
async
¶
Dispatch LLMEndEvent to all handlers, and close its scope.
on_llm_pre_call
async
¶
Dispatch LLMPreCallEvent to handlers that implement it.
on_llm_pre_call is an OPTIONAL hook (added in v6.1.5); not
every handler implements it, so we fan out only to handlers that
define the attribute. Preserves the zero-cost invariant: if no
handler implements the hook the loop is a no-op.
Source code in src/symfonic/core/callbacks/manager.py
on_llm_start
async
¶
Dispatch LLMStartEvent to all handlers, and open its scope.
on_node_end
async
¶
Dispatch NodeEndEvent to all handlers, and close its scope.
on_node_error
async
¶
Dispatch NodeErrorEvent to all handlers, and close its scope.
An error closes the pair exactly as an end does: the node is no longer running, so a later teardown must not close it a second time.
Source code in src/symfonic/core/callbacks/manager.py
on_node_start
async
¶
Dispatch NodeStartEvent to all handlers, and open its scope.
on_procedure_selection
async
¶
Dispatch ProcedureSelectionEvent to handlers that implement it (v7.20.0 T-7.20.0.9).
on_procedure_selection is an OPTIONAL hook; fans out only
to handlers that define the attribute. Preserves the zero-
cost invariant: the engine's force-resolver gates event
construction on has_hook("on_procedure_selection") so no
event objects are built when no handler listens.
Adopters subscribe to capture per-procedure billing
attribution, A/B-test bucket assignment, audit trail entries,
and production routing telemetry. See
:class:~symfonic.core.contracts.callbacks.ProcedureSelectionEvent.
Source code in src/symfonic/core/callbacks/manager.py
on_react_loop_end
async
¶
Dispatch ReactLoopEndEvent to handlers that implement it (v7.17.0).
on_react_loop_end is an OPTIONAL hook fired by the React node
when the loop terminates with an empty final_response AFTER
the v7.17.0 content-block salvage attempt. Handlers that do not
implement it are silently skipped. Preserves the zero-cost
invariant: the react node gates event construction on
:meth:has_hook ("on_react_loop_end") so no event objects
are built when no handler listens.
Source code in src/symfonic/core/callbacks/manager.py
on_response_render
async
¶
Dispatch ResponseRenderEvent to handlers that implement it.
Unique among optional hooks: on_response_render RETURNS
the rewritten string. The runtime substitutes the returned
value for the original final_response BEFORE on_agent_end
dispatch.
Chaining semantics for multiple handlers (registration order):
- First handler receives
content=original_content. - Each subsequent handler receives the previous handler's return value.
- The final return value is delivered to the runtime.
Error isolation: if a handler raises, the exception is logged
and that handler's contribution is SKIPPED (the chain proceeds
with the previous content). This matches the error-isolation
contract of :meth:_dispatch_optional -- a broken rewriter
must not stall the runtime.
Handlers that do not implement the method are silently skipped.
Preserves the zero-cost invariant: emission sites gate
construction of :class:~symfonic.core.contracts.callbacks.
ResponseRenderEvent on :meth:has_hook
("on_response_render") so no event objects are built when
no handler listens.
v7.18.0 (Option B; adopter Slack-formatting ask).
Source code in src/symfonic/core/callbacks/manager.py
on_token_composition
async
¶
on_tool_call_dispatch
async
¶
Dispatch ToolCallDispatchEvent to handlers that implement it.
Unique among optional hooks (alongside on_response_render):
on_tool_call_dispatch RETURNS the rewritten {"tool_name",
"args"} dict. The React node mutates ai_message.tool_calls
in place before the AIMessage returns to LangGraph for dispatch.
Return-value semantics:
None-> pass-through (no rewrite contribution).dict[str, Any]with keys{"tool_name": str, "args": dict}-> rewrite. The dict MUST NOT containcall_id-- the engine owns it.tool_nameMUST be a string;argsMUST be a dict. Malformed returns are dropped with a structured WARNING (see the type-validation block below).
Chaining semantics for multiple handlers (registration order):
- First handler receives the original event (original
tool_name/args). - Each handler that returns a dict re-emits a SHALLOW-COPIED
event with the rewritten
tool_name/argsso the next handler sees the chain's current state. - Handlers that return
Noneare pass-throughs (no contribution; the next handler sees the previous state). - The final return value is delivered to the React node.
Error isolation: a handler that raises is logged and SKIPPED
(the chain proceeds with the previous state). Matches the
error-isolation contract of _dispatch_optional -- a broken
rewriter must not stall the React loop.
Validation gate: the React node verifies the final
tool_name against event.available_tools and DROPS
rewrites that target unregistered tools. This method does
NOT enforce the gate -- it lives at the call site so the
dropped-rewrite WARNING carries the React node context.
Handlers that do not implement the method are silently
skipped. Preserves the zero-cost invariant: emission sites
gate construction of :class:~symfonic.core.contracts.callbacks.
ToolCallDispatchEvent on :meth:has_hook
("on_tool_call_dispatch") so no event objects are built
when no handler listens.
v7.19.0 (an adopter CSV-export ask -> slack_upload_file + a per-user memory title shape).
Source code in src/symfonic/core/callbacks/manager.py
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 | |
on_tool_result
async
¶
Dispatch ToolResultCallbackEvent to handlers that implement it (v8.6.4).
on_tool_result is an OPTIONAL hook fired by the tool node
(InstrumentedToolNode) AFTER a tool returns, carrying the
produced result, call_id, and a deferred flag. Handlers that
do not implement it are silently skipped via
:meth:_dispatch_optional. Preserves the zero-cost invariant:
the emit site gates event construction on
:meth:has_hook ("on_tool_result") so no event objects are
built when no handler listens.
Source code in src/symfonic/core/callbacks/manager.py
on_tool_routing_decision
async
¶
Dispatch ToolRoutingDecisionEvent to handlers that implement it.
on_tool_routing_decision is an OPTIONAL hook (added in
v7.0.1); fans out only to handlers that define the attribute.
Preserves the zero-cost invariant: when tool_routing_mode
is off the engine never constructs the event OR calls
this method.
Source code in src/symfonic/core/callbacks/manager.py
open_scope_count ¶
NoOpCallbackHandler ¶
Zero-overhead default callback handler. All methods are no-ops.
Satisfies CallbackHandler protocol via structural typing.