symfonic.core.callbacks.emit¶
emit ¶
Helpers to emit LLM callback events from non-react call sites (v7.4.3).
Centralises the "mirror react.py:303-311" pattern so the seven non-react
LLM call sites (metacognition critic, context-window summariser, insight
extractor, LLM entity extractor, consolidation extractor, procedural
router, plus future siblings) all emit identical LLMEndEvent payloads
with the right node_name discriminator.
Design rules:
- Always safe with
callback_manager=None(just returns early). - Always safe with the manager's
is_noopzero-cost path. - Never raises -- any exception in a handler is swallowed by the manager's own dispatcher; this helper does not introduce a second layer of guards.
- Constructs the event eagerly only when there's at least one handler
registered -- mirrors the react.py guard at
react.py:303.
Used by:
agent/middleware/metacognitive.py(metacognition_critic)agent/engine.py(metacognition_skill_gate via middleware)core/nodes/context_window.py(context_window_summariser)core/learning/refiner.py(insight_extractor)core/learning/entity_extractor_llm.py(entity_extractor_llm)memory/_internal/extraction/service.py(consolidation_extractor)memory/layers/procedural/router.py(procedural_router)
LLMTiming
dataclass
¶
Capture window for LLM call timing (v7.15.0).
Populated by the :func:llm_timing async context manager. Passed
into :func:emit_llm_end / :func:emit_llm_end_from_result via the
timing kwarg so the resulting :class:LLMEndEvent carries
duration_ms and started_at_utc for adopter cost-over-time
aggregation and tail-latency analysis.
Attributes:
| Name | Type | Description |
|---|---|---|
started_at_utc |
str
|
ISO-8601 UTC timestamp of the LLM invocation start. Empty string when the timing context never opened. |
duration_ms |
float
|
Wall-clock duration in milliseconds. |
emit_llm_end
async
¶
emit_llm_end(
callback_manager: CallbackManager | None,
*,
model: str,
output: str,
usage: dict[str, Any],
run_id: str,
node_name: str,
timing: LLMTiming | None = None,
iteration_index: int = 0,
) -> None
Fire on_llm_end on the manager, mirroring react.py:303-311.
No-ops when the manager is None or registers zero handlers. Never
raises. The event is constructed eagerly only when a handler is present
so the empty-manager fast path stays zero-cost.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback_manager
|
CallbackManager | None
|
Merged manager from runtime, or |
required |
model
|
str
|
|
required |
output
|
str
|
Stringified LLM output. Use |
required |
usage
|
dict[str, Any]
|
Provider usage dict ( |
required |
run_id
|
str
|
Engine run identifier so OTel and metrics can correlate with the surrounding run. Empty string is acceptable for ad-hoc consolidation-phase calls. |
required |
node_name
|
str
|
Discriminator string -- one of the documented set in the module docstring. Required (no default) so callers think about it. |
required |
timing
|
LLMTiming | None
|
v7.15.0 :class: |
None
|
iteration_index
|
int
|
v7.15.0 0-indexed react loop iteration that
produced this LLM call. Defaults to |
0
|
Source code in src/symfonic/core/callbacks/emit.py
emit_llm_end_from_result
async
¶
emit_llm_end_from_result(
callback_manager: CallbackManager | None,
*,
model: str,
result: Any,
run_id: str,
node_name: str,
timing: LLMTiming | None = None,
iteration_index: int = 0,
) -> None
Convenience wrapper: extract usage/output from a model result and emit.
Equivalent to::
await emit_llm_end(
cb_mgr,
model=model,
output=_extract_output_text(result),
usage=_extract_usage(result),
run_id=run_id,
node_name=node_name,
timing=timing,
iteration_index=iteration_index,
)
Use this at sites where the caller already has the raw ainvoke
result in hand (most of the seven bypass sites).
v7.15.0 added timing and iteration_index -- both default to
None / 0 so existing callers see no behaviour change. Callers that
want the new telemetry wrap their ainvoke with
:func:llm_timing and pass the captured :class:LLMTiming here.
Source code in src/symfonic/core/callbacks/emit.py
emit_llm_start
async
¶
emit_llm_start(
callback_manager: CallbackManager | None,
*,
model: str,
messages: list,
run_id: str,
node_name: str,
system_prompt: str = "",
) -> None
Fire on_llm_start on the manager, mirroring react.py:1082-1090.
THE Jarvio fix (v8.4.0): the eight aux LLM call sites (critic,
summariser, insight extractor, consolidation extractor, procedural
router, entity/procedural extractors, synthesis fallback) historically
emitted only the callback LLMEndEvent -- never the paired
LLMStartEvent. The OTel CallbackBridge OPENS a span on
on_llm_start and CLOSES it on on_llm_end; with no start the
end_llm pops a None key and returns silently, so NO span is
ever recorded in cortex. React was the only site emitting the start,
which is why only react spans appeared.
Calling this helper BEFORE the aux ainvoke (with the SAME
node_name discriminator the site already passes to
:func:emit_llm_end, on the SAME merged manager) opens the span so the
existing end closes it. Carries node_name on the
:class:LLMStartEvent (v8.4.0 added the field) so the bridge can key
the span per node -- concurrent-safe attribution within a single turn.
No-ops when the manager is None or registers zero handlers
(is_noop). Never raises (the manager's dispatcher swallows handler
exceptions). Byte-identical to pre-v8.4.0 behaviour when no callback
manager is merged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback_manager
|
CallbackManager | None
|
Merged manager from runtime, or |
required |
model
|
str
|
|
required |
messages
|
list
|
The conversation-history message list passed to
|
required |
run_id
|
str
|
Engine turn identifier so OTel can correlate with the surrounding run. Empty string is acceptable for ad-hoc consolidation-phase calls. |
required |
node_name
|
str
|
Discriminator string -- one of the documented set in
the module docstring. Required (no default) so callers think
about it; MUST match the |
required |
system_prompt
|
str
|
Assembled system prompt string, surfaced to CallbackHandlers (e.g. prompt capture). Empty when the site uses no separate system prompt. |
''
|
Source code in src/symfonic/core/callbacks/emit.py
llm_timing
async
¶
Wrap an LLM call to capture wall-clock duration and start timestamp.
Usage::
async with llm_timing() as timing:
result = await model.ainvoke(...)
await emit_llm_end_from_result(..., timing=timing)
The context manager is exception-safe: duration_ms is populated
even when ainvoke raises. Callers that don't care about timing
can omit the timing kwarg from the emit helpers -- the defaults
preserve the pre-v7.15.0 contract (duration_ms=0.0,
started_at_utc="") so adopter handlers that ignore the new
fields see byte-identical behaviour.
Yields:
| Type | Description |
|---|---|
AsyncIterator[LLMTiming]
|
class: |
AsyncIterator[LLMTiming]
|
entry, then |
AsyncIterator[LLMTiming]
|
context exits. |
Source code in src/symfonic/core/callbacks/emit.py
resolve_model_name ¶
Extract the actual-API-call model identifier from a BaseChatModel.
The framework historically stamped LLMEndEvent.model with the
ModelConfig.model_name requested at the call site, but adopters
that route via MultiProviderRouter or a custom ModelProvider
(Jarvio's JarvioModelProvider) can return a chat model bound to
a different SKU than was requested. Reporting the requested-name
under-attributes cost to the wrong pricing row (the v7.13.6 fix
revealed this -- Jarvio's stack requested claude-sonnet-4-...
via the framework default but the provider returned a Claude Opus
4.6 chat model, which costs ~3x more).
Provider-attribute survey (2026-06-01):
============================================ ============ ==========
Concrete class .model .model_name
============================================ ============ ==========
langchain_anthropic.ChatAnthropic str None
langchain_openai.ChatOpenAI None str
langchain_google_genai.ChatGoogleGenerativeAI str None
langchain_ollama.ChatOllama str None
symfonic.core.testing.MockChatModel absent absent
langchain_core.runnables.RunnableBinding absent absent
(LangChain's bind_tools wrapper)
============================================ ============ ==========
Strategy: try .model first, then .model_name, then descend
once through .bound (the RunnableBinding wrapper exposed by
BaseChatModel.bind_tools). Empty strings are treated as
"missing" so callers don't end up stamping "" on the cost row.
The fallback argument carries the previous behaviour (the
ModelConfig.model_name string) so any unknown wrapper degrades
to the pre-v7.14.0 contract instead of crashing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
The |
required |
fallback
|
str
|
String returned when none of the candidate attributes
yield a non-empty string -- typically the requested
|
''
|
Returns:
| Type | Description |
|---|---|
str
|
The resolved model identifier (e.g. |
str
|
available, otherwise |