aimu.events¶
Run events: a frozen-dataclass event union plus one Callable[[RunEvent], None] sink. Attach a
sink to a client, an Agent, an OrchestratorAgent (assemble(..., events=...)), or a
workflow's from_client(...) to see what a run actually did.
See how-to: observe a run.
Sink and dispatch¶
aimu.events.EventSink
module-attribute
¶
One callable taking one :class:RunEvent. Its return value is ignored and an exception
it raises is logged, not propagated (see :func:emit). It must be thread-safe: with
concurrent_tool_calls=True tool events arrive from concurrent workers in
nondeterministic order.
aimu.events.emit ¶
Deliver event to sink, logging rather than propagating its failure.
Observation must not change what it observes: a sink that raises would otherwise break
a run that was working. Same contract as the SubagentObserver display hook.
A sink must be thread-safe. With concurrent_tool_calls=True the tool-loop engine
dispatches a turn's tool calls from a ThreadPoolExecutor (sync) or an
asyncio.TaskGroup (async), so :class:ToolCalled / :class:ToolDenied are emitted
concurrently and their order is nondeterministic -- a sink that appends to a plain
list, writes a file, or accumulates per-tool state needs its own lock. Turn and run
events are emitted from the calling thread and stay ordered.
aimu.events.log_events ¶
A sink that writes one line per event.
The shortest path to the comprehension payoff -- attach it and watch what actually happened -- and the sink the docs lead with.
Events¶
aimu.events.RunEvent
dataclass
¶
RunEvent(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None)
Base for every event.
agent and iteration mirror :class:~aimu.models.StreamChunk's fields of the same
name, so one sink can attribute events correctly inside a nested workflow.
run_id identifies one run of an agent, so two concurrent runs of the same agent stay
apart; parent_run_id is the run that started it, when the caller said so (AIMU's spawn
tools always do). Both are None outside a run, e.g. a bare client.chat(), and on turn
events that reach a durable client.events sink with no agent run behind them (the same
limit as agent and iteration attribution).
aimu.events.RunStarted
dataclass
¶
RunStarted(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, task: str = '')
Bases: RunEvent
A Runner (an agent or a workflow) began a run.
Fires once at the top of run(), before the first model turn, and is paired with
exactly one :class:RunFinished. task is the task string as the caller passed it.
aimu.events.ModelTurnStarted
dataclass
¶
ModelTurnStarted(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, model: str = '', message_count: int = 0, tool_names: tuple[str, ...] = ())
Bases: RunEvent
One model request is about to be issued.
Fires inside chat() / generate(), before the provider is called, and is paired
with exactly one :class:ModelTurnFinished even when the request raises.
message_count is how many messages this request carries (including the user turn
it is about to append, and the system message when this turn seeds one); it is 1
for a stateless generate(), which sends only the prompt. tool_names are the
tools advertised on this turn, not the ones called.
aimu.events.RequestPrepared
dataclass
¶
RequestPrepared(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, provider: str = '', model: str = '', endpoint: Optional[str] = None, payload: Any = None)
Bases: RunEvent
The payload as it goes to the provider, after every adaptation AIMU applies.
This is the event that makes "when a model surprises you, the surprise should be the
model's" checkable: between a caller's chat() and the wire sit the four-tier
generate_kwargs merge, the GENERATE_KWARG_SUPPORT renames and drops, thinking
resolution, strip_inert_keys, and provider format adaptation. Without this, none of
it is visible at runtime.
The payload is the request, unredacted -- it contains whatever the caller put in the conversation. Redacting here would reintroduce the hiding this exists to remove; a sink that ships events off the machine is the right place to filter.
It is plain JSON data (see aimu.models._internal.json_safe): NOT_GIVEN keys are dropped,
since the SDK omits them from the wire, and anything with no JSON form becomes a visible marker.
client.last_request keeps the raw payload.
endpoint is the server the request went to (base URL, credentials and query stripped), so
two OpenAI-compatible servers are distinguishable and a recorded request can be re-sent. None
means there was no wire request: HuggingFace and LlamaCpp run in-process and record the nearest
equivalent, which is inspectable but not replayable.
aimu.events.ModelTurnFinished
dataclass
¶
ModelTurnFinished(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, model: str = '', text: Optional[str] = None, usage: Optional[dict] = None, duration_s: float = 0.0, error: Optional[BaseException] = None, stop_reason: Optional[str] = None, tool_calls: list = list(), thinking: Optional[str] = None)
Bases: RunEvent
The model request that a :class:ModelTurnStarted announced has ended.
Fires for every started turn, including one that failed: error is then the
exception (ContextOverflowError, ModelConnectionError, a provider 4xx, ...) and
text / usage are None. A sink pairing started/finished can therefore close
its span unconditionally.
On a streamed turn this fires when the stream is actually drained (or abandoned), not
when it was created, so usage -- which only populates at the end -- is real and
duration_s covers the whole stream. text is the concatenated GENERATING
content on a streamed turn, and the returned string on a non-streamed one.
Not emitted at all on the schema= structured-output path: chat() / generate()
return before :class:ModelTurnStarted is emitted when schema is set, since that path
makes exactly one call and reports itself a different way (see
Agent._structured_run_events / :class:RunFinished). There is therefore no started
turn to leave unpaired there either.
stop_reason is the provider's own word for how the turn ended ("end_turn",
"tool_use", "length", "refusal", ...), the same value as
client.last_stop_reason. It is here because a sink often never holds the client: one
handed a spawned sub-agent's events cannot read that sub-agent's client at all. None
means the provider said nothing.
tool_calls are the tool calls the model requested on this turn, parsed and before any
dispatch, each {"name", "arguments", "id"}: a tools-only round's text is empty, so
without them a recorded turn would show no response at all. thinking is the turn's
reasoning (client.last_thinking), None when there was none. Both are read when the
event fires, so on a streamed turn they are complete.
aimu.events.ToolCalled
dataclass
¶
ToolCalled(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, name: str = '', arguments: dict = dict(), result: Optional[str] = None, error: Optional[str] = None, duration_s: float = 0.0)
Bases: RunEvent
A tool the model asked for was dispatched.
Fires after the call returns, for every outcome the model gets to see: a normal result,
a tool that raised (error set, result carrying the message handed back to the
model), invalid arguments, and a name the model invented that matches no tool. It does
not fire for a call a tool_approval policy refused -- that is :class:ToolDenied.
arguments is what the model passed, before AIMU's coercion. With
concurrent_tool_calls=True these are emitted from worker threads (sync) or
concurrent tasks (async): see :func:emit on sink thread-safety and ordering.
aimu.events.ToolDenied
dataclass
¶
ToolDenied(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, name: str = '', arguments: dict = dict(), reason: str = '')
Bases: RunEvent
A tool_approval policy refused this call.
Fires instead of :class:ToolCalled, before the tool runs: nothing was executed. The
model still sees a tool message saying the call was not approved.
reason is what the policy gave when it returned Denied(reason), and "" when it
returned a bare False. It is the same text the model was shown, so a sink recording
denials records why without having to reproduce the policy's logic.
aimu.events.ContextCompacted
dataclass
¶
ContextCompacted(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, dropped: list = list(), before_tokens: int = 0, after_tokens: int = 0)
Bases: RunEvent
Conversation history was rewritten by an Agent's compaction= callable.
Fires once per compaction, after the rewrite, before the next model turn.
dropped is the removed messages, not a count, so a caller who wants the discarded
turns still has them. They are copies, so a later in-place edit of client.messages
(the provenance tag an agent loop adds, say) cannot mutate an already-emitted event.
before_tokens / after_tokens are AIMU's own default token estimate (see
:func:aimu.context.count_tokens), not a measurement of whatever the compaction
callable itself counted to decide what to drop. A callable that used a real tokenizer,
a word count, or any other budget will disagree with these numbers -- that is stated
rather than hidden, since AIMU cannot see inside an opaque callable to know what it
actually counted. Treat them as rough orientation, not a claim about the number that
drove the decision.
aimu.events.RunFinished
dataclass
¶
RunFinished(agent: Optional[str] = None, iteration: int = 0, run_id: Optional[str] = None, parent_run_id: Optional[str] = None, result: Optional[str] = None, error: Optional[BaseException] = None, ended_by: Optional[RunEnding] = None, max_iterations: Optional[int] = None)
Bases: RunEvent
A Runner finished, successfully or not.
Fires in a finally, so it is emitted even when the run raised: error is the
exception in that case and result is None.
result is also None on a streamed run (run(stream=True)): the chunks go
to the caller as they arrive and the runner never assembles a final string, so there is
no result to report. Read the text off the GENERATING chunks instead.
ended_by names the decision that ended the run, so a host can say why without
re-deriving the loop's control flow from the events before it:
"answered": the model's last turn was a real answer and no inbox message was waiting."wrap_up": the round budget ran out while the model still wanted tools (or had produced nothing), so the loop made one tools-disabled call asking for a final answer, and got one."error": the run raised;erroris the exception (DegenerateTurnErrorwhen even the wrap-up produced nothing,TruncatedTurnError,ModelRefusalError,CancelledError, ...).
It is None from a Runner that has no such decision to report (a workflow).
max_iterations is the round budget the loop ran under, for reading iteration against:
a wrap-up at iteration == max_iterations is the uncounted call after the cap. A delivered
inbox message restarts the count, so a messaged run can legitimately pass it. None where
there was no loop (a schema= run makes one call).