Observability¶
You can't fix what you can't see, and an agent's failure modes are mid-run — the tool that took 40 seconds, the turn that burned 30k tokens. Three instruments, smallest first: hooks (react to events), tracing (timed spans), logging (the built-in narrative).
Hooks¶
AgentHooks is a subscriber: attach handlers per event type, and the
runner dispatches every event through them — the same typed events
streaming yields, so instrumentation works even when nobody
consumes the stream.
from lovia import Agent, RunContext, events
from lovia.hooks import AgentHooks
hooks = AgentHooks()
@hooks.on(events.ToolCallStarted)
async def log_tool(ev: events.ToolCallStarted, ctx: RunContext):
print("→", ev.call.name, "in session", ctx.session_id)
@hooks.on((events.RunCompleted, events.RunFailed)) # a tuple registers both
def at_end(ev, ctx):
metrics.count("runs", tags={"ok": isinstance(ev, events.RunCompleted)})
@hooks.on_any
def firehose(ev, ctx):
audit_log.write(type(ev).__name__)
agent = Agent(..., hooks=hooks)
The contract:
- Every handler is called as
handler(event, ctx)— the event plus the run's liveRunContext(session_id, active agent, cumulative usage, transcript, cancel token, mailbox). Handlers may be sync or async. - Registration is by concrete type with
isinstancematching, so subscribing to a base class (events.ToolEvent) catches the family; multiple handlers per type run in registration order, catch-alls first. - Fail-open: a raising handler is logged (warning, with traceback) and skipped — broken metrics must not abort the run they watch.
- Ordering guarantee: events reach hooks in emission order, at the loop's single dispatch point — the same order the stream consumer sees.
- Hooks aren't observers only:
ctxis live, so a handler can push intoctx.mailbox(steering) or tripctx.cancel_token.
Plugins can contribute their own AgentHooks, dispatched
alongside the agent's — how Memory triggers its run-end
curation.
Tracing¶
Hooks tell you what happened; spans tell you what took how long, inside
what. The Tracer protocol is one method — span(name, **attributes) as
a context manager — and the runner emits four span types when a tracer is
passed to a run:
| Span | Attributes |
|---|---|
run |
agent, run_id (+ turns, total_tokens, resumed at end) |
model_call |
model, turn |
tool_call |
name, call_id |
handoff |
from_agent, to_agent |
from lovia import Runner
from lovia.tracing import ConsoleTracer
result = await Runner.run(agent, "...", tracer=ConsoleTracer(min_duration_ms=5))
Three implementations ship: NoopTracer (the default — instrumentation
stays free), ConsoleTracer (indented tree via logging, for local
debugging), and InMemoryTracer (records RecordedSpans for test
assertions). For production, adapt your backend — OpenTelemetry, Logfire —
by implementing the two-method protocol (span() returning something with
set_attribute / record_exception).
The tracer is a run-level knob, not an agent field: it applies across handoffs to whichever agent is active, and agent-as-tool sub-runs inherit it so their spans join the parent's trace.
Logging¶
lovia logs a structured narrative under the lovia logger —
run.start, model.done: turn=2 tokens=1841(in=1520 out=321) …,
tool.start/tool.error, run.handoff, context.overflow,
run.done — with a NullHandler attached by default (a library must stay
silent until asked). For scripts and notebooks:
from lovia import enable_logging
enable_logging() # INFO on stderr, color when a TTY
enable_logging("DEBUG", color=False) # more detail, no ANSI
enable_logging is idempotent (re-calling replaces its own handler), honors
NO_COLOR, and doesn't propagate to the root logger by default (no double
printing under uvicorn — propagate=True opts back in). Production apps
should configure logging themselves and ignore this helper.
Usage accounting¶
Every run accumulates a Usage — on result.usage, live on ctx.usage,
and folded upward from sub-runs:
| Field | Meaning |
|---|---|
input_tokens |
full prompt size, cached tokens included |
output_tokens |
completion tokens |
cache_read_tokens / cache_write_tokens |
the prompt-cache breakdown of the input |
total_tokens |
input + output |
The cache fields subdivide input_tokens, they don't add to it — cost
formulas are (input - cache_read) * rate_in + cache_read * rate_cached + ….
For per-turn deltas, hook RunCompleted/TurnEnded and diff, or read each
model turn's own usage from the model.done log line.
Sharp edges¶
- Hooks run inline on the loop. A slow handler delays the run — dispatch is awaited between events. Ship metrics async (queue + worker), not with a blocking HTTP call per event.
- Hook mutations are real.
ctx.entriesis the live transcript; treat it read-only. The safe mutations are the designed ones — mailbox, cancel. ConsoleTraceris for humans, not for parsing — its format is unversioned. Structured needs → implementTraceragainst your backend.- Replays are quiet. A checkpoint replay of a completed run re-emits terminal events only — per-turn hooks and spans don't fire again; usage still folds into the caller.
See also¶
- Streaming — the full event catalog hooks receive
- Cancellation & steering — the control half of
ctx - Example:
11_hooks.py