Observe a run¶
A chat() call and an Agent.run() both do more than the string they return: a generate_kwargs
merge, a thinking-effort resolution, zero-or-more tool calls, maybe a compaction pass, one or more
requests to a provider. None of that is visible in the return value. aimu.events is the
telemetry channel that makes it visible: a sink is one callable that takes one event, and you
attach it to a client, an agent, an orchestrator, or a workflow to see what actually happened.
This is a different channel from StreamChunk (stream=True), which is content — what the model
produced, for display. Events are what the library did with it. Both can be active on the same run
and neither replaces the other.
Attach log_events and watch what happened¶
The shortest path to the payoff is aimu.events.log_events, a sink that writes one line per event
to a logger you already have:
import logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
import aimu
from aimu.events import log_events
reply = aimu.chat(
"Say OK and nothing else.",
model="ollama:qwen3:8b",
events=log_events(logging.getLogger("aimu.demo")),
)
Running that against a local Ollama server prints:
ModelTurnStarted ModelTurnStarted(agent=None, iteration=0, model='qwen3:8b', message_count=1, tool_names=())
RequestPrepared RequestPrepared(agent=None, iteration=0, provider='OllamaClient', model='qwen3:8b', payload={'model': 'qwen3:8b', 'messages': [...], 'options': {'temperature': 0.6, 'top_p': 0.95, 'top_k': 20}, 'tools': [], 'think': True, 'keep_alive': 60, 'format': None})
ModelTurnFinished ModelTurnFinished(agent=None, iteration=0, model='qwen3:8b', text='OK', usage={'input_tokens': 16, ...}, duration_s=...)
(output_tokens/total_tokens/duration_s depend on how much the model reasoned before answering
and will differ on your machine; input_tokens for this exact one-message prompt won't.)
That's a bare one-shot aimu.chat() call — no agent, no tool loop — and it's already observable.
events= is accepted the same way by aimu.client(events=...), and by client.events = ... at
any point afterward.
Point the same sink at an Agent and the tool loop reports itself too:
from aimu.agents import Agent
from aimu.tools import tool
@tool
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
client = aimu.client("ollama:qwen3:8b")
agent = Agent(
client,
"You are a helpful assistant. Use tools when needed.",
tools=[add],
events=log_events(logging.getLogger("aimu.demo")),
)
agent.run("What is 12 + 30? Use the add tool.")
RunStarted RunStarted(agent='agent-679be0', iteration=0, task='What is 12 + 30? Use the add tool.')
ModelTurnStarted ModelTurnStarted(agent='agent-679be0', iteration=0, model='qwen3:8b', message_count=2, tool_names=('add',))
RequestPrepared RequestPrepared(agent='agent-679be0', iteration=0, ...)
ModelTurnFinished ModelTurnFinished(agent='agent-679be0', iteration=0, model='qwen3:8b', text='', usage={'input_tokens': 160, ...}, duration_s=...)
ToolCalled ToolCalled(agent='agent-679be0', iteration=0, name='add', arguments={'a': 12, 'b': 30}, result='42', error=None, duration_s=...)
ModelTurnStarted ModelTurnStarted(agent='agent-679be0', iteration=1, model='qwen3:8b', message_count=4, tool_names=('add',))
RequestPrepared RequestPrepared(agent='agent-679be0', iteration=1, ...)
ModelTurnFinished ModelTurnFinished(agent='agent-679be0', iteration=1, model='qwen3:8b', text=..., ...)
RunFinished RunFinished(agent='agent-679be0', iteration=1, result=..., error=None)
(duration_s is a wall-clock timing and will differ on any other machine; the final answer text
is generated and, while it reliably said 'The result of 12 + 30 is **42**.' in most of ten fresh
runs here, it varied in the rest — input_tokens, the tool arguments, and result='42' are the
parts of this block that are actually fixed.)
Every event carries agent and iteration (the same two fields StreamChunk carries), so one
sink attached to a nested workflow — a Chain step, a Router handler, every worker in a
Parallel — can still tell events apart by who emitted them. Chain.from_client(...),
Router.from_client(...), Parallel.from_client(...), and
PlanExecuteEvaluator.from_client(...) all take events= and forward it to every
step/handler/worker they build, and so do OrchestratorAgent.assemble(...) and the three
prebuilt orchestrators in aimu.agents.prebuilt (whose inner orchestrating Agent is private,
so events= is the only way to reach it).
The event types, all dataclasses in aimu.events: RunStarted, ModelTurnStarted,
RequestPrepared, ModelTurnFinished, ToolCalled, ToolDenied, ContextCompacted (see
manage context), RunFinished. A sink is a plain
Callable[[RunEvent], None]; write your own to filter, aggregate, or forward events instead of
just logging them — a sink that raises is caught and logged rather than breaking the run it's
observing (the same contract emit() documents).
Two contracts worth knowing before you write one:
- A sink must be thread-safe. With
concurrent_tool_calls=Truea turn's tool calls are dispatched from aThreadPoolExecutor(sync) or anasyncio.TaskGroup(async), soToolCalled/ToolDeniedarrive concurrently and in nondeterministic order. Turn and run events are emitted from the calling thread and stay ordered. ModelTurnFinishedfires even when the turn failed, with the exception on itserrorfield andtext/usageunset — so a sink pairingModelTurnStartedwith it (durations, a usage rollup, an OpenTelemetry span) closes its span on aContextOverflowErroror a provider 4xx instead of leaking one. It is not emitted at all on aschema=run:chat()/generate()return beforeModelTurnStartedfires whenschemais set, so there is no started turn to pair (or leave dangling) there.RunFinished.erroris the same started/finished idea a level up, andRunFinished.resultisNoneon a streamed run (the chunks went to you, so the runner never assembled a final string) and on aschema=run (the result is a typed object, not a string; it is the return value, and is also onclient.last_structured).
last_request: did the library do this, or the model?¶
RequestPrepared carries the same payload the client stores on client.last_request — the
request exactly as sent, after AIMU's own generate_kwargs merge (four tiers), the
GENERATE_KWARG_SUPPORT renames and drops, thinking-effort resolution, strip_inert_keys
(AIMU's own timestamp/thinking/provenance bookkeeping never reaches a provider),
encode_tool_call_arguments on the OpenAI-format paths (a tool call's arguments is stored parsed
and OpenAI's schema types it as a JSON string), and provider format adaptation (OpenAI-format
messages rewritten to Anthropic's block shape, and so on). It answers a question that otherwise takes source-reading to answer: when a model's behavior
looks surprising, is the surprise something the model did, or something AIMU changed on the way
out?
client = aimu.client("ollama:qwen3:8b")
client.chat("Hello")
client.last_request
# {'model': 'qwen3:8b', 'messages': [...], 'options': {'temperature': 0.6, 'top_p': 0.95,
# 'top_k': 20}, 'tools': [], 'think': True, 'keep_alive': 60, 'format': None}
No sink required — last_request is set on every request regardless of whether events= is
attached. The payload is unredacted: it contains whatever you put in the conversation, including
tool arguments and any images/audio. A sink that ships events off the machine is the right place
to filter, not this attribute.
A worked example: an OpenTelemetry-shaped sink¶
Events are plain data, so mapping them onto spans in an observability system is a matter of a
dispatch function. This is a worked example, not a dependency — AIMU does not import
opentelemetry, and the sink below uses a small stand-in tracer so the example runs without one
installed. Swap FakeTracer/FakeSpan for opentelemetry.trace.get_tracer(__name__) and its real
Span, and the mapping is unchanged.
from aimu.events import ModelTurnFinished, ModelTurnStarted, RunEvent, RunFinished, RunStarted, ToolCalled
class FakeSpan:
def __init__(self, name, attributes):
self.name, self.attributes = name, attributes
class FakeTracer:
"""Stand-in for trace.get_tracer(__name__); records spans instead of exporting them."""
def __init__(self):
self.spans = []
def start_span(self, name, attributes):
span = FakeSpan(name, attributes)
self.spans.append(span)
return span
def make_otel_sink(tracer):
def sink(event: RunEvent) -> None:
if isinstance(event, RunStarted):
tracer.start_span("agent.run", {"agent": event.agent, "task": event.task})
elif isinstance(event, ModelTurnStarted):
tracer.start_span("model.turn", {"agent": event.agent, "model": event.model})
elif isinstance(event, ToolCalled):
tracer.start_span("tool.call", {"agent": event.agent, "name": event.name})
elif isinstance(event, ModelTurnFinished):
tracer.start_span("model.turn.finished", {"agent": event.agent, "usage": event.usage})
elif isinstance(event, RunFinished):
tracer.start_span("agent.run.finished", {"agent": event.agent, "result": event.result})
return sink
tracer = FakeTracer()
agent = Agent(client, "You are terse.", events=make_otel_sink(tracer))
agent.run("Say hi in three words.")
for span in tracer.spans:
print(span.name, span.attributes)
agent.run {'agent': 'agent-089e80', 'task': 'Say hi in three words.'}
model.turn {'agent': 'agent-089e80', 'model': 'qwen3:8b'}
model.turn.finished {'agent': 'agent-089e80', 'usage': {'input_tokens': 25, ...}}
agent.run.finished {'agent': 'agent-089e80', 'result': ...}
(output_tokens/total_tokens and the reply text vary by run — how much the model reasons and
what it says are both sampled, not fixed; input_tokens for this fixed prompt is not.)
A real adapter would keep a stack of open spans (so agent.run.finished closes the span
agent.run opened, rather than opening a new one), and forward event.iteration as a span
attribute for a multi-round tool loop. It would also read event.error on both *Finished
events and mark the span accordingly: both fire on a failure too, which is what makes the
pairing safe to rely on. Note that result above is populated because this is a non-streamed,
non-schema= run; see the two contracts above for when it is None. The dispatch shape above is the whole idea; the rest is
whatever your tracer's API wants.
Gated tools: ToolDenied¶
A tool_approval policy that refuses a call emits ToolDenied (name + the model's raw
arguments) instead of ToolCalled, so a sink can distinguish "the model tried this and it ran"
from "the model tried this and a policy said no":
agent = Agent(
client,
"Always use the delete_everything tool when asked.",
tools=[delete_everything],
tool_approval=lambda name, args: False,
events=log_events(logging.getLogger("aimu.demo")),
)
agent.run("Please delete everything now.")
The tool message the model sees is the same text gate-tool-calls.md documents
("Tool 'delete_everything' was not approved."); the event is the same fact, structured for a
sink instead of the transcript. See gate tool calls for the approval hook
itself.
A shared client under concurrent workers¶
events= is delivered by installing the resolved sink as the active sink for the run's
execution context and client — a scoped contextvars.ContextVar override, not a mutation
of model_client.events (unlike tools=, which genuinely does swap model_client.tools,
since request-spec building has to read a plain attribute). Parallel.from_client(...) builds
every worker Agent over one shared model_client, and Parallel.run() really does
execute those workers concurrently — but each worker's own OS thread (or, on the async
surface, asyncio.Task) gets its own independent copy of the ContextVar, so one worker's
override cannot clobber another's, and the override only ever answers for the specific client
(and whatever it delegates to or from — see _client_family) it was installed for. Concurrent
workers on a shared client deliver every event, correctly attributed and in causal order; see
tests/test_workflow_parallel.py::test_parallel_from_client_shared_events_sink_survives_concurrent_workers
(async mirror in tests/test_aio_workflow_parallel.py), which replaced an earlier test that
pinned this as a known gap.
A client that is not part of the run's family never receives its sink, on either surface
and regardless of concurrent_tool_calls — for instance a fresh client a tool builds for
itself (the make_subagent_tool shape). Its own turns stay off the run's sink, and an
explicit events= given to that client is delivered correctly (_effective_sink falls back
to that client's own self.events once family membership fails, rather than an ambient
override winning regardless of which client is asking — an earlier version of this mechanism
had exactly that bug, folding a sub-agent's turns into its parent's sink under the parent's
name while the sub-agent's own RunStarted/RunFinished never appeared).
make_subagent_tool and make_async_subagent_tool take that explicit events= directly,
so a delegated run's usage doesn't have to stay invisible:
from aimu.tools.builtin import make_subagent_tool
spawn = make_subagent_tool("anthropic:claude-sonnet-4-6", events=my_sink)
The factory sets it on every spawned child's Agent.events field (not passed to that child's
run, which is what makes it cover a streamed spawn too). Leave it out and a spawn reports
nowhere: its fresh client has no family membership to fall back on and no self.events of its
own either, so a caller measuring a whole turn's cost would otherwise silently under-count every
delegation.
The one case that still depends on the surface is a tool that calls the same client
the run's override was installed for (e.g. a tool that reuses ctx.deps holding that client).
Sync dispatches a concurrent tool via a plain ThreadPoolExecutor.submit() (no context copy),
so that thread's empty context makes the override invisible there even though the client
matches — it falls back to that client's own self.events. Async dispatches via
asyncio.TaskGroup.create_task, which always copies the current context, so a reentrant
call to the same client from inside a concurrently dispatched async tool does see the
override there, attributed to the calling agent (the attribution wrapper stamps any event
that arrives without its own agent). Sequential tool dispatch (the default,
concurrent_tool_calls=False) sees it on both surfaces, since no thread/task boundary is
crossed at all.
self.events set directly on a client (rather than through an Agent's scoped events=)
remains genuinely shared by design — there is no execution-context boundary to isolate a plain
attribute assignment by.
Abandoning a streamed run: close it. run(stream=True, events=sink) holds the scope open
across the generator's yields, so the scope is torn down when the generator finishes or is
closed. A consumer that stops early — a UI's stop button — should close it:
stream = await agent.run("...", stream=True, events=sink)
async for chunk in stream:
if user_pressed_stop:
break
await stream.aclose() # or: async with aclosing(stream) as stream: ...
aclose() (sync: stream.close(), or just letting the generator go out of scope, which
CPython finalizes immediately on the same thread) tears the scope down synchronously, right
there. Merely dropping an async generator without closing it hands finalization to the event
loop's asyncgen finalizer, which runs a few loop iterations later in a separate task — until it
does, the abandoned run's sink is still the active one, so a call issued in that window is
reported to it. The window is bounded and self-healing (it is not the permanent leak a
Token-based teardown produced), but aclose() removes it entirely.
See also¶
- Manage context —
ContextCompactedis the event this page didn't cover; it fires when a conversation is trimmed or summarized mid-run. - Compare models —
last_usageandextract_tool_callsfor after-the-fact comparison across models, rather than a live sink. - Gate tool calls — the
tool_approvalhook that producesToolDenied.