symfonic.agent.cutover.observability¶
observability ¶
The migrated path's observability: one sink, bound at the compile seam.
TA8.20 implements the MIGRATE verdict for the observability envelope
guard. TA8.15's decision-callbacks.md calls the implementing task TA8.21,
which in the ledger is different work entirely; see
RET-PREP/observability-migration.md §0 for the mapping.
Before it, an agent constructed with a metrics_collector or with
otel_enabled was refused by the envelope and every one of its turns ran the
legacy body: the migrated path emitted no callback events, so admitting the
turn would have left the collector silent, and silence is invisible to
cutover.fallbacks().
The metrics half of that is what TA8.20 changed. The OTEL half stayed wired and
unreachable: an otel_enabled agent was still refused, one frame later, at
the config guard, because the field was not on ALLOWED_FRAMEWORK_FIELDS.
TA8.40 closed that gap and paid what it owed. All four otel_* fields are on
the allowlist, so the observer this module composes now runs on an admitted
turn — on run, stream and stream_typed alike — and the span
reduction that admission asserts is enumerated in
tests/agent/cutover/test_otel_fields_reach_the_kernel.py rather than read
off the wiring. The distinction between "bound" and "migrated" is what that file
measures; policy.py's allowlist comment states the adopter-visible change.
What replaces the refusal is a consumer, not a tolerance. The kernel already
carries the whole run as KernelEvents and already reserves
ServiceBindings.event_sink for a consumer of them;
symfonic.services.observability already ships the projection from that
stream onto the same two objects the constructor injected —
ObservabilityBridge -> MetricsObserver / OTelObserver ->
CallbackHandlerObserver -> the shipped CallbackHandler dataclasses. Both
halves were built, tested, and joined to nothing: before this module
compose_event_sink had no production caller outside its own package. This
module is the join, and it is deliberately the only thing here — the
projection is not re-implemented, and no observer is written.
Why the sink is per run and the suite is per agent. RunScope is the
identity every observation is attributed to, and three of its five fields
(run_id, session_id, tenant_id) are facts about one turn. An agent
that built one sink at construction would attribute every later run to the
first one's ids. The inputs to that decision — the config, the collector, the
exporter handles — are agent-lifetime, so they live on :class:ObservabilitySuite
and the per-run half is :meth:ObservabilitySuite.for_run.
Why the exporter handles are passed in rather than resolved here. The
engine already builds them once (build_if_enabled at construction) and
already registers their framework bridge into its hook list. Resolving a second
set through :func:~symfonic.services.observability.otel.build_otel_observer's
default path would give one agent two TracerProviders, two batch workers and
two channels, and spans opened against one would be flushed by the other's
shutdown. Passing the engine's handles in as the builder is what keeps "one
agent, one exporter" true across both routes.
What this module does not do. It does not pass callback_handlers= to
observers_from_config. That parameter exists and would expose a four-hook
subscription surface to adopter handlers; TA8.15 §6.3 rejected it by name,
because an eight-hook adopter handler admitted through it would silently lose
four of its hooks — the exact failure the retired callbacks argument is
refused to prevent, merely relocated. The parameter stays unreachable from the
agent path.
ObservabilitySuite ¶
The agent-lifetime half: what this configuration buys, decided once.
inactive is the ordinary case and costs nothing: no collector, no OTEL,
so :meth:compose returns None before it imports anything, the plan's
event_sink stays unbound, and InvocationRunner never constructs a
CallbackEventAdapter. That is the same zero-overhead guarantee
compose_event_sink expresses in its return type, kept one level up so a
disabled configuration does not pay for the import either.
Source code in src/symfonic/agent/cutover/observability.py
active
property
¶
Whether anything is watching. Mirrors the envelope's observability.
The two must agree: the envelope reads
SymfonicAgent._with_metrics_callbacks(None), which is truthy when a
collector or the OTEL bridge is present, and admits the turn on the
strength of this suite serving them. An agent admitted on a truthy
observability whose suite was inactive would be the exact silence
the refusal used to prevent, reached through the admission instead.
compose ¶
compose(*, run_id: str, session_id: str | None, tenant_id: str | None, entry_point: str, model: Any, prompt: str = '', lineage: tuple[str, str | None] = ('', None)) -> Any
This run's sink, or None when nobody is watching. Never raises.
session_id=None is correct here, not merely tolerated, and it is
checked rather than inherited. TA8.15 recorded the claim as an
INFERENCE from services/observability/metrics.py:39-47 and asked
the implementing task to confirm it. Confirmed, and the evidence is
stronger than the inference: MetricsObserver.on_run_started calls
set_conversation(scope.run_id, scope.session_id or scope.run_id),
and ConversationMetricsCollector.on_llm_end independently defaults
the same lookup to the run id
(core/observability/metrics.py:203/:235 —
self._current_conversation.get(event.run_id, event.run_id)). So a
session-less run is filed under its own run id by both halves rather
than being bucketed with unrelated runs, which is what the legacy body
does for the same turn. The premise the inference was drawn under has
since moved — session_id is an admitted argument as of TA8.19, so an
admitted scoped turn now carries a resolved id and reaches this
argument with a value — and None survives exactly where the legacy
body also has none: an unscoped turn whose caller supplied no id, for
which SymfonicAgent._resolve_session returns the caller's None
unchanged.
model is the plan's ModelResolution. RunScope.model is a
str, and an unnamed model resolves to "" rather than to the
string "None": provider_family is carried alongside it because
the composer already knows it and re-deriving it downstream would create
a second answer.
Failure to build the sink is not failure to run. Telemetry that cannot
be composed is telemetry that reports nothing — the same rule
ObservabilityBridge applies per event, applied once to the wiring.
Source code in src/symfonic/agent/cutover/observability.py
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | |
for_run ¶
for_run(*, run_id: str, session_id: str | None, tenant_id: str | None, entry_point: str, prompt: str = '', request: Any = None) -> RunObservability
The handle the delegate hands to the compile seam and then releases.
session_id is str | None and None is a supported value, not
a degraded one — see :meth:compose.
prompt is the turn's query and it is carried rather than defaulted,
because AgentStartEvent.query is rendered from it
(ObservabilityBridge builds RunStarted(query=capture.prompt_text(
scope.prompt)), services/observability/bridge.py:140-141). Leaving
it at "" would have made one of the four rendered hooks carry an
empty field on the migrated route while the legacy body carried the
query — a silent field-level divergence inside a hook this migration
claims parity for, which is worse than a reduction that is written down.
Source code in src/symfonic/agent/cutover/observability.py
RunObservability ¶
RunObservability(suite: ObservabilitySuite, *, run_id: str, session_id: str | None, tenant_id: str | None, entry_point: str, prompt: str = '', request: Any = None)
One run's event sink: built when the plan compiles, released with it.
Instances are callables so they can be handed to
AgentPlanFactory.compile(..., sink_factory=...): the factory calls this
object once, with the ModelResolution it just computed, and binds the
return value as ServiceBindings.event_sink. Taking the resolution as an
argument rather than re-deriving the model here is what keeps RunScope
naming the model the plan actually runs — a second resolution is a second
answer that can disagree with the first.
:attr:sink is None until the plan compiles, and stays None when
nothing is watching. :meth:release is safe in either state and safe after
a normal terminal, which is why the caller may put it in a bare finally.
Source code in src/symfonic/agent/cutover/run_observability.py
release
async
¶
Release what an abandoned run left held. Idempotent, never raises.
Source code in src/symfonic/agent/cutover/run_observability.py
for_turn ¶
for_turn(suite: ObservabilitySuite | None, *, run_id: str, session_id: str, tenant_id: str | None, entry_point: str, prompt: str, request: Any = None) -> RunObservability | None
This turn's observability handle, or None when nobody is watching.
The delegate's whole share of the migration is this call and the
release that pairs with it, which is why the decision lives here rather
than as a private method there: what makes a handle worth building is a fact
about the suite, and a caller that re-derived it would be a second reader
of active that can disagree with the first.
session_id arrives as str because that is the shape the facade
echoes, and "" is normalised back to None rather than carried:
RunScope.session_id is str | None and None is what its consumers
test for. Passing "" would be a session id that is falsy but present,
which reads as a session in every log line that renders it and as no session
in every branch that checks it.
request is the TurnRequest this turn enters the kernel with, read
rather than re-derived: telemetry computing its own lineage from the ambient
identity is the second-reader problem this module already avoids for
active. Absent, the turn reports a root run (issue #131).