Observability¶
Observability is how you find out what a run did, why it was slow, and what it cost — after it has already happened.
agentkit splits that into three surfaces that are easy to confuse because they all sound like "telemetry". They are not interchangeable, they are wired independently, and picking the wrong one is the usual reason a team ends up with dashboards that cannot answer their question.
| Observations | Traces | Meters | |
|---|---|---|---|
| Answers | "what is the agent doing right now?" | "why did this run take 40s?" | "how much did it cost, and who pays?" |
| Audience | your product and its users | you, at 3am | finance, and the ceiling that stops a runaway loop |
| Shape | an ordered stream of Observation records |
spans with attributes and events | a Decimal ledger with ceilings |
| Always on? | yes — the default just drops everything | no — sampled, off by default | yes — Budget is always on the context |
| Seam | ObserverPort on Services.observer |
TracePort on Services.trace |
ctx.budget and ctx.meters |
| Wire it with | agentkit.integrations.observers |
agentkit.integrations.observability |
Budget / Quota + the meter() middleware |
The one-line test: if a user should see it, it is an observation. If an operator should see it, it is a trace. If an accountant should see it, it is a meter.
Is this page for you?
Reach for it when a run was slow, expensive, or wrong, and you need to find out which — after the fact.
Skip it for now if you are still developing locally and
print() is genuinely enough.
The problem it solves¶
An agent run is a long-lived, non-deterministic process that spends money. Without observations, a user stares at a spinner for ninety seconds with no idea whether anything is happening — and a parent agent supervising children has nothing to supervise with. Without traces, "the run was slow" is unanswerable: you cannot tell a slow provider from a slow tool from six retries. Without meters, a tool loop that never converges bills until someone notices, and one tenant's runaway job starves everyone else on the box.
Each one is cheap to wire and expensive to add after the incident.
The smallest thing that works¶
An observation stream, with nothing else configured:
import asyncio
from agentkit.integrations.observers import CollectingObserver
from agentkit.core.testing import make_test_ctx
async def main() -> None:
obs = CollectingObserver()
ctx = make_test_ctx(observer=obs)
await ctx.emit("progress", "read 3 of 10 files", payload={"done": 3, "of": 10})
await ctx.emit("result", "wrote the summary", payload={"words": 120})
for o in obs.items:
print(o.kind, "|", o.render, "|", o.payload, "|", o.run_id)
asyncio.run(main())
progress | read 3 of 10 files | {'done': 3, 'of': 10} | test-run
result | wrote the summary | {'words': 120} | test-run
render is the short human line; payload is the machine-readable
record. run_id comes from ctx.correlation_id automatically.
Observations — the product-facing stream¶
The mental model¶
Every run emits observations whether or not anyone is listening. The
default Services.observer is NoopObserver, which drops everything,
so emitting is free and zero-config. Attaching an observer is what turns
the stream on.
ctx.emit(kind, render, *, payload=None, agent="", parent_id=None) is
the only producer API you need. kind is a closed set, so a consumer's
branch table can be exhaustively type-checked:
| kind | emitted when |
|---|---|
progress |
incremental work happened |
summary |
a rolled-up digest (what RollupObserver produces) |
partial_result |
a usable-but-incomplete answer |
result |
the run finished |
error |
the run failed |
interrupt |
the run is waiting on a human |
signal.emitted |
a SignalChannel published to a peer |
gate.check |
an autonomy gate was evaluated — see the note below, the payload is not one shape |
memory.written |
a memory decorator wrote |
gate.check has three producers and two payload shapes
ReActCognition and elicit() emit it with three keys.
ApprovalServer emits it with six — tool, allowed, reason,
source, asked and at — because a CLI permission prompt is
answered somewhere agentkit cannot see, so the record has to carry
who decided and whether a person was reached.
A consumer writing a typed handler off the table above will
otherwise assume one shape and lose fields on the producer that has
the most to say. Note also that arguments is deliberately absent
from the broadcast even though ApprovalDecision holds it: the
stream is the wrong place for a payload that may carry a secret.
Read it off approvals.decisions instead.
result and error are CRITICAL_KINDS. Together with interrupt
they are never silenced, delayed, or dropped by any cadence wrapper in
the box. That is a guarantee, not a default: an observer that swallows a
result has broken the run's only success channel, and one that swallows
an interrupt has hung it.
Emitting never breaks the run. ctx.emit wraps the observer call in
contextlib.suppress(Exception) unconditionally. A Redis hiccup, a
slow WebSocket subscriber, a Kafka push error — none of it propagates
into the agent loop. Every middleware and pattern in the framework emits
through this seam expecting it to be inert.
Observation is an audit record, so it is frozen — payload included¶
Observation is a frozen dataclass, and __post_init__ additionally
deep-freezes payload.
The reason is fan-out. One record reaches an audit sink, a WebSocket
forwarder and a rollup buffer, all of which retain the reference
alongside the run's live emit loop. frozen=True alone only protected
render; obs.payload["status"] = "done" — on the field that is the
machine-readable record — rewrote what the other two sinks had already
queued.
from agentkit.core.telemetry.observation import Observation
obs = Observation(kind="summary", render="wrote intro", payload={"words": 120})
print(type(obs.payload).__name__, obs.payload["words"])
try:
obs.payload["words"] = 0
except TypeError as exc:
print("TypeError:", exc)
print("scalar payload passes through:", repr(Observation(kind="progress", payload="tick").payload))
FrozenDict 120
TypeError: this payload belongs to a frozen value and cannot be mutated in place. Build a new one instead: dataclasses.replace(obj, field={**obj.field, ...})
scalar payload passes through: 'tick'
deep_freeze returns non-containers untouched, so a str / int /
None payload is bit-for-bit the object you passed; only dicts and
lists are walked.
The measured cost, stated plainly
Freezing is O(payload) and runs once per emitted event. Per construction, measured on the author's machine:
| payload | before | after |
|---|---|---|
None / str / int |
1.84 µs | 2.30 µs |
{"step": 3, "of": 10} |
1.87 µs | 3.23 µs |
| 4-key summary | 1.84 µs | 3.84 µs |
| nested signal payload | 1.84 µs | 5.20 µs |
| full agent result (~40 keys + 10-message transcript) | 1.84 µs | 37.9 µs |
End to end on the cheapest emit that exists — construct, suppress,
and await into a NoopObserver — a 4-key summary went from
2.62 µs to 4.60 µs. That is +75%, and it is the honest headline
rather than a footnote.
What it buys: the same 2 µs sits in front of an await into an
adapter that buffers or writes a row, so it does not show up against
a real observer; the +36 µs case is a result, emitted once per run;
and scalar payloads — the ones on the tightest progress loops — pay
0.46 µs. If you profile this as your bottleneck, the fix is a
shallower payload, not an unfrozen one.
Observation.__hash__ is deliberately computed on the stream key —
(run_id, agent, kind) — never on payload. A frozen
dataclass would otherwise derive its hash from every compared field, and
payload is in practice always a dict, which made every observation a
real run produced unhashable while a test that passed a string proved
the opposite. __eq__ still compares every field, so a set-based
dedup of a replayed stream stays exact; two records sharing a stream key
just land in one bucket, which is what a bucket is for.
Cadence: how much of the stream reaches the consumer¶
Four adapters live in agentkit.integrations.observers. Two are terminal
sinks, two are cadence wrappers you compose in front of them.
CollectingObserver— appends to.items. Unbounded. Tests and small in-process consumers.QueueObserver(maxsize=256)— bounded, non-blocking, async-iterable viastream().PolicyObserver(inner, allow=…)— a kind filter.RollupObserver(inner, every=8, summarize=…)— buffers non-critical observations and emits onesummarypereveryof them.
RollupObserver exists because a step-level stream is too noisy for a
UI and too expensive for a socket. Its cadence rule has three parts, all
visible in one run:
import asyncio
from agentkit.integrations.observers import CollectingObserver, PolicyObserver, RollupObserver
from agentkit.core.testing import make_test_ctx
async def main() -> None:
sink = CollectingObserver()
rollup = RollupObserver(sink, every=4)
ctx = make_test_ctx(observer=rollup)
for i in range(6):
await ctx.emit("progress", f"step {i}")
print("after 6 progress:", [(o.kind, o.render, o.payload) for o in sink.items])
await ctx.emit("result", "done")
print("after the result: ", [(o.kind, o.render, o.payload) for o in sink.items])
await rollup.close()
print("dropped:", rollup.dropped)
# A policy on top: only result / error / interrupt reach the sink.
quiet_sink = CollectingObserver()
qctx = make_test_ctx(observer=PolicyObserver.result_only(quiet_sink))
await qctx.emit("progress", "step 0")
await qctx.emit("error", "provider 500")
print("result_only: ", [(o.kind, o.render) for o in quiet_sink.items])
asyncio.run(main())
after 6 progress: [('summary', '4 updates; latest: step 3', {'rolled': 4})]
after the result: [('summary', '4 updates; latest: step 3', {'rolled': 4}), ('summary', '2 updates; latest: step 5', {'rolled': 2}), ('result', 'done', None)]
dropped: 0
result_only: [('error', 'provider 500')]
Read the second line carefully. Six progress observations produced one
summary of 4 and left 2 buffered. The result did not queue behind
them: it flushed the buffer — producing the second summary — and then
forwarded immediately. close() flushes any remaining tail, which is
why ObserverPort declares close() at all.
summarize(buffer) -> str may be sync or async; the async form is the
hook for an LLM judge or a Compactor-backed summariser. The default is
dependency-free: a count plus the latest line.
Two failure modes RollupObserver had to be taught
Both are worth knowing because both look impossible until they happen.
The flush window. _flush originally awaited summarize and
then cleared the buffer. A concurrent emit re-entered it, the
first task cleared the buffer, and the second read self._buf[-1]
on an empty list. Measured with an async summariser, every=2 and 6
concurrent observations: IndexError('list index out of range')
raised into the caller's emit. The same window also silently
discarded everything appended during the await. The fix detaches the
buffer before any await, under a lock, so exactly one task owns a
buffer generation.
A telemetry failure killing the run. A caller-supplied
summarize that raised, or an inner sink that was down, propagated
straight into emit. An agent must not die because its telemetry
did. Failures are now caught, the batch is counted on .dropped,
and the first one logs a warning. except Exception, not
BaseException — CancelledError is how a run is torn down and
must keep propagating.
.dropped == 0 means the rollup is complete. Anything else means a
consumer is reading a partial view.
QueueObserver applies the same never-drop-results rule under
backpressure. It bounds non-critical observations to maxsize and
evicts the oldest when over; result and error are never dropped and
never count against the bound:
import asyncio
from agentkit.integrations.observers import Hooks, QueueObserver
from agentkit.core.testing import make_test_ctx
async def main() -> None:
queue = QueueObserver(maxsize=2)
hooks = Hooks(inner=queue)
hooks.on("error", lambda o: print("ALERT:", o.render))
ctx = make_test_ctx(observer=hooks)
for i in range(5):
await ctx.emit("progress", f"step {i}")
await ctx.emit("result", "done")
await hooks.close()
async for o in queue.stream():
print(o.kind, o.render)
asyncio.run(main())
Hooks is lifecycle subscription over the same stream — on(kind,
handler), or on("*", …) for everything. Handlers may be sync or
async, and an exception in one is swallowed: a hook must never
destabilise the run it observes. It chains in front of another observer
via inner=, and forwards close() down the chain.
Compose them; the order is the cadence
Hooks(inner=PolicyObserver.summaries(RollupObserver(QueueObserver())))
is a full stack: alerting hooks in front, a kind filter, rolled-up
batching, and a bounded queue a WebSocket drains. Every layer
forwards close(), which is what stops the rollup's trailing
summary from vanishing at process exit.
Traces — the operational timeline¶
The mental model¶
A trace answers "what happened inside this one run, in what order, and
how long did each part take". TracePort is the seam:
from typing import Any
from agentkit.core.telemetry.observation import TraceContext
class TracePort: # the real one is a runtime_checkable Protocol
def span(self, name: str, kind: str, **attrs: Any) -> Any: ... # sync or async CM
def current_span_id(self) -> TraceContext | None: ...
def add_event_to_current_span(self, name: str, **fields: Any) -> None: ...
The default is NoopTrace. You never open spans by hand — the
tracing() middleware owns span lifecycle for every chat and tool call,
so span naming and attribute keys stay consistent across the framework
instead of drifting per pattern.
Names follow the OpenTelemetry GenAI v1.41 operation taxonomy (chat,
execute_tool). The kernel itself stays OTel-free: only the names and
attribute keys are aligned, so an adapter can lift them into a real
exporter without translation.
import asyncio
from agentkit.core.contracts.types import ChatRequest, Message
from agentkit.core.policies import tracing
from agentkit.core.testing import FakeLLM, RecordingTracer, make_test_ctx
async def main() -> None:
tracer = RecordingTracer()
ctx = make_test_ctx(
llm=FakeLLM("Octopuses taste with their arms."),
trace=tracer,
chat_middleware=[tracing()],
)
req = ChatRequest(messages=[Message("user", "One fact about octopuses.")], model="fake-1")
await ctx.invoker.chat(req, ctx)
for span in tracer.spans:
print(span.name, span.kind)
for key, value in span.attrs.items():
print(" ", key, "=", value)
print(" events:", [name for name, _ in span.events])
asyncio.run(main())
chat chat
gen_ai.request.model = fake-1
gen_ai.response.first_token_latency_ms = 0.014541990822181106
gen_ai.usage.input_tokens = 10
gen_ai.usage.output_tokens = 5
gen_ai.usage.cost_usd = 0.0001
gen_ai.response.model = fake-1
gen_ai.response.finish_reasons = stop
gen_ai.response.duration_ms = 0.04454198642633855
events: ['gen_ai.user.message', 'gen_ai.choice']
RecordingTracer is a real TracePort from agentkit.core.testing — the
same object the framework's own tests assert against.
What the middleware stamps, and the caps it respects¶
Attributes on a chat span come from the assembled result at
stream-end: token usage, cost_usd, response model, finish reason,
total duration, and first_token_latency_ms (absent, not zero, on
streams that never produce content — a tool-only turn has no first
token, and a misleading 0 is worse than "no value").
Prompt-cache attributes are stamped only when the provider reports a
non-zero value, so the 95% of calls without caching do not carry noisy
zeros. When they are present, the middleware also derives
gen_ai.usage.cost_saved_by_cache_usd from a 10% cached-input price
ratio — the rate both OpenAI and Anthropic bill cache reads at.
Events carry content, and content is capped because OTel collectors
enforce per-attribute length limits and silently drop over-long values.
Truncating in agentkit's own code preserves the leading content, which
is what an operator wants to see, and signals the cut with a
[truncated] suffix:
| constant | value | applies to |
|---|---|---|
MAX_MESSAGE_CONTENT_BYTES |
8 KB | gen_ai.{role}.message, gen_ai.choice |
MAX_TOOL_RESULT_CONTENT_BYTES |
4 KB | gen_ai.tool.result — tool output can be an HTML blob |
MAX_TOOL_NAMES_IN_ATTR |
16 names | gen_ai.response.tool_calls, then ,+K more |
gen_ai.tool.result_size_bytes is kept uncapped, so you can still
see how big the real blob was after the content was truncated. The exact
count of tool calls lives on the separate integer attribute
gen_ai.response.tool_calls_count, which is what a histogram wants.
Redaction is deliberately not done here — configure it collector-side
(e.g. the OTel collector's processor/attributes regex rules).
Sampling drops spans, never metrics¶
ctx.sampler is consulted before the span opens. When
should_sample returns False the span, its per-message events, and
its replay write are all skipped — but the underlying handler still runs
and the result still flows, and the metrics still record.
That split is the point. Sampling controls trace fidelity; metrics roll up unconditionally. Without it, every dashboard would under-count token cost, duration and error rate by exactly the sample rate.
Services.sampler defaults to AlwaysOnSampler — record everything —
so nothing is silently missing until you decide otherwise.
otel_sampler(ratio) builds the in-tree TraceIdRatioSampler
(ratio=1.0 always-on, 0.1 ten percent); it is pure Python with no
OTel dependency, and lives next to the OTel factories only so operators
have one module to import from. For a richer policy — parent-based,
rule-based — write your own SamplerPort and inject it directly.
A misbehaving sampler defaults to keeping the span. More spans is the safer failure mode than silent loss.
Services.metrics defaults to NoopMetrics, so a Services() with no
arguments is fully usable and the middleware's metric calls cost
nothing.
"""Sampling drops the SPAN. The metric is still recorded."""
import asyncio
from typing import Any
from agentkit.core.contracts.types import ChatRequest, Message, Scope
from agentkit.core.runtime import RunContext, Services
from agentkit.integrations.observability import otel_sampler
from agentkit.core.policies import tracing
from agentkit.core.runtime import Invoker
from agentkit.core.testing import FakeLLM, RecordingTracer
class RecordingMetrics:
"""A MetricsPort: two plain methods, and it never raises into the run."""
def __init__(self) -> None:
self.records: list[tuple[str, Any, dict[str, str]]] = []
def add_counter(self, name: str, value: int | float = 1, *, tags: Any = None) -> None:
self.records.append((name, value, dict(tags or {})))
def record_histogram(self, name: str, value: int | float, *, tags: Any = None) -> None:
self.records.append((name, value, dict(tags or {})))
async def main() -> None:
tracer, metrics = RecordingTracer(), RecordingMetrics()
services = Services(
invoker=Invoker(llm=FakeLLM("hi"), chat_middleware=[tracing()]),
trace=tracer,
metrics=metrics,
sampler=otel_sampler(0.0), # drop every span
)
ctx = RunContext(correlation_id="run-1", scope=Scope(org_id="acme"), services=services)
await ctx.invoker.chat(ChatRequest(messages=[Message("user", "hi")], model="fake-1"), ctx)
print("spans:", len(tracer.spans))
for name, value, tags in metrics.records:
print(f" {name} = {value!r} {tags}")
asyncio.run(main())
spans: 0
gen_ai.client.token.usage = 10 {'gen_ai.request.model': 'fake-1', 'agentkit.scope.org_id': 'acme', 'gen_ai.token.type': 'input'}
gen_ai.client.token.usage = 5 {'gen_ai.request.model': 'fake-1', 'agentkit.scope.org_id': 'acme', 'gen_ai.token.type': 'output'}
gen_ai.client.operation.duration = 3.1666975701227784e-05 {'gen_ai.request.model': 'fake-1', 'agentkit.scope.org_id': 'acme'}
Note the tag set: model plus a small, known org_id, and nothing else.
It is kept tiny on purpose — every additional tag multiplies the
time-series count. Durations are in seconds, per OTel convention,
while span durations are in milliseconds.
The error path increments gen_ai.client.error.count tagged with the
exception class name, so you can alert on a provider 5xx storm without
joining traces.
A tracer must never break the run¶
Every observability call in the middleware — metrics, sampler, replay,
per-message events — is wrapped in contextlib.suppress(Exception). The
span itself gets _safe_trace_span, which falls back to a NoopSpan if
trace.span(...) raises on open, enter or exit, and logs the traceback
exactly once per process so a misbehaving tracer cannot spam a log
per unit of work.
One asymmetry is deliberate and easy to get wrong: on the error path the
span is closed with the live (type, value, traceback) rather than a
clean exit. ExitStack.close() is defined as __exit__(None, None,
None) — it tells the wrapped context manager the block succeeded,
whatever actually happened. The OTel adapter relies on OTel's own
__exit__ to call record_exception and set the ERROR status, so
closing cleanly meant a provider failure exported as a successful
span. Measured: the provider raised RuntimeError("provider 500") and
the span came out with exception=None, status=None.
The original exception is always re-raised, even if the span's
__exit__ returns True. And only Exception is reported as a span
error: GeneratorExit / CancelledError reach that frame when a
consumer abandons or cancels a stream, which is not a provider fault.
Wiring OpenTelemetry¶
agentkit.integrations.observability bridges both ports to the OTel SDK.
Install pip install "arc-agentkit[observability]"; the factories raise
ImportError naming the extra if you have not.
"""A real OTel span, exported in-memory so the example needs no backend."""
import asyncio
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from agentkit.core.contracts.types import ChatRequest, Message
from agentkit.integrations.observability import otel_tracer
from agentkit.core.policies import tracing
from agentkit.core.testing import FakeLLM, make_test_ctx
async def main() -> None:
provider = TracerProvider()
exporter = InMemorySpanExporter()
provider.add_span_processor(SimpleSpanProcessor(exporter))
ctx = make_test_ctx(
llm=FakeLLM("ok"),
trace=otel_tracer(provider),
chat_middleware=[tracing()],
)
await ctx.invoker.chat(ChatRequest(messages=[Message("user", "hi")], model="fake-1"), ctx)
for span in exporter.get_finished_spans():
usage = {k: v for k, v in (span.attributes or {}).items() if k.startswith("gen_ai.usage")}
print(span.name, span.kind, f"trace_id={span.context.trace_id:032x}")
print(" ", usage)
asyncio.run(main())
chat SpanKind.INTERNAL trace_id=78db743082019e8cf1a337976407ce1e
{'gen_ai.usage.input_tokens': 10, 'gen_ai.usage.output_tokens': 5, 'gen_ai.usage.cost_usd': 0.0001}
In production you call otel_exporter_otlp_http() once at startup and
otel_tracer() with no argument, which picks up the global provider.
Wire OpenTelemetry is the
full recipe, including the metrics exporter.
The replay side channel¶
Attribute cardinality has a budget; a full prompt does not fit in it.
When a ReplayStore is wired on Services.replay, the tracing
middleware writes a ReplayRecord keyed by the chat span's id carrying
the complete request and assembled response. The span carries the index;
the store carries the bulk, so downstream tooling can rebuild a call
without re-running it. The default NoopReplayStore makes this a
zero-cost no-op.
Meters — spend and quota¶
The mental model¶
A meter is one concept — guard a ceiling, then charge usage — applied at two scopes:
Budget— per run. One instance shared by reference across the whole agent tree viactx.child(), so a fan-out cannot spend the parent's ceiling twice. It is also the run's depth and concurrency authority.Quota— per tenant. Rolling RPM / TPM / dollar windows keyed byscope.key(). The noisy-neighbour guard and the chargeback source. The shipped one is in-memory; production swaps a Redis-backedMeter.
Both implement the Meter protocol (guard before the work, charge
after) and both are driven by the single meter() middleware, which
guards every meter on ctx before a call and charges them after.
Money is Decimal, with a float mirror¶
Binary floating point cannot represent 0.01. A hundred one-cent
charges summed as floats land at 1.0000000000000007, and a metered run
that cannot be reconciled to the cent is not a ledger.
So Budget keeps an exact Decimal ledger at MONEY_SCALE = 6 decimal
places — a tenth of a millicent, the same scale pricing.cost() rounds
to — and exposes spent_usd as a float mirror of it, re-derived
after every charge.
The mirror is not a wart, it is a compatibility decision made
explicitly: keeping the name rather than deprecating it meant ~20 doc
references, 28 test references, and every application reading
budget.spent_usd kept working untouched. Rebuilding a Budget from a
checkpoint — Budget(spent_usd=saved.state["spent_usd"]) — is a
documented path, so spent_usd stays an init-accepting field that seeds
the exact ledger. From there _spent is authoritative and the mirror is
re-derived, so the two cannot drift.
The naming convention is worth memorising, because it is the thing that stops a float creeping back in:
spent/remainingareDecimaleverywhere.*_usdisfloateverywhere.
import asyncio
from decimal import Decimal
from agentkit.core.contracts.types import ChatRequest, Message
from agentkit.core.contracts.types import Usage
from agentkit.core.policies import meter
from agentkit.core.runtime import Budget
from agentkit.core.testing import FakeLLM, make_test_ctx
async def main() -> None:
budget = Budget(max_cost_usd="0.01", on_exceeded="stop")
ctx = make_test_ctx(
llm=FakeLLM("ok", usage=Usage(1000, 500, 0.004)),
budget=budget,
chat_middleware=[meter()],
)
req = ChatRequest(messages=[Message("user", "hi")], model="fake-1")
for i in range(3):
await ctx.invoker.chat(req, ctx)
print(
f"call {i + 1}: spent={budget.spent()} remaining={budget.remaining()} "
f"exhausted={budget.exhausted()} tokens={budget.usage.total_tokens}"
)
print("float mirror:", budget.spent_usd, "| cents:", budget.spent_cents())
print("verdict:", budget.verdict().reason)
naive = 0.0
for _ in range(100):
naive += 0.0001
exact = sum((Decimal("0.0001") for _ in range(100)), Decimal(0))
print("100 x $0.0001 as float:", naive, "| as Decimal:", exact)
asyncio.run(main())
call 1: spent=0.004000 remaining=0.006000 exhausted=False tokens=1500
call 2: spent=0.008000 remaining=0.002000 exhausted=False tokens=3000
call 3: spent=0.012000 remaining=0 exhausted=True tokens=4500
float mirror: 0.012 | cents: 0.01
verdict: cost $0.012 > $0.01
100 x $0.0001 as float: 0.009999999999999995 | as Decimal: 0.0100
Three things in that output:
Budgetaccumulates the wholeUsage, not just a cost scalar. Input / output / cache-read / cache-write counts survive to the end of a multi-agent run. Reducing it to one number forced every application to re-aggregate what the framework had already seen.spent_cents()quantizes at read time, never per charge. Rounding each charge to cents would round every sub-cent call to zero and undercount the whole run.- The budget overran its ceiling by one call. That is a known
property, not a bug: the verdict compares
spent > ceilingafter the work has run, because there is no pre-flight cost estimate. Set the ceiling slightly below your true limit.
Ceilings are intent; charges are measurement¶
The two are treated differently on purpose:
| rule | why | |
|---|---|---|
A ceiling with more precision than MONEY_SCALE |
MoneyPrecisionError at construction |
silently rounding changes what the operator asked for, and construction is where it is free to fix |
| A charge with more precision | quantized, never refused | raising mid-run would abort before the cognition reached its checkpoint — the exact failure this class was rewritten to remove |
There is one exception to the first rule: assigning max_cost_usd
after construction (budget.max_cost_usd = 10.0, the documented way
to raise a ceiling and resume an exhausted run) re-derives
non-strictly, because that path is reached from inside charge().
charge() returns a verdict rather than only raising¶
Raising from inside charge() aborts the run mid-call — before the
cognition reaches its checkpoint write — so exhausting a budget
destroyed everything spent up to that point.
charge() now returns a Charge: ok, reason, spent (exact),
remaining (exact), calls, and the cumulative usage, so a caller
acting on a verdict never has to go back to the meter for the numbers
that justified it.
on_exceeded picks what happens at the ceiling:
"raise"(default) —MeterExceededfrom insidecharge."stop"—chargereturnsCharge(ok=False, …)and raises nothing. The ReAct cognition readsbudget.exhausted()between units of work, writes asuspendedcheckpoint, and ends withstop_reason="budget_exhausted". The spend is recoverable.
"raise" stays the default deliberately. Flipping it would silently
change the control flow of every existing wiring: a run that used to
abort would continue past its ceiling in any caller that ignores the
return value, which is a worse failure than the one being fixed. Callers
opt into recoverability.
(await budget.charge(...)).raise_if_exceeded() converts a verdict back
into the exception at one specific site.
Quota — the tenant axis¶
Quota enforces on guard (before the work) rather than on charge
(after it), because a rate limit that only notices afterwards is not a
rate limit.
import asyncio
from agentkit.core.contracts.types import ChatRequest, Message, Scope
from agentkit.core.contracts.types import Usage
from agentkit.core.policies import meter
from agentkit.core.runtime import Quota
from agentkit.core.runtime.meter import MeterExceeded
from agentkit.core.testing import FakeLLM, make_test_ctx
async def main() -> None:
quota = Quota(max_rpm=2, max_usd="1.00")
ctx = make_test_ctx(
llm=FakeLLM("ok", usage=Usage(100, 50, 0.02)),
scope=Scope(org_id="acme"),
meters=[quota],
chat_middleware=[meter()],
)
req = ChatRequest(messages=[Message("user", "hi")], model="fake-1")
for i in range(3):
try:
await ctx.invoker.chat(req, ctx)
print(f"call {i + 1}: ok, tenant spend {quota.spent_in_window(ctx.scope.key())}")
except MeterExceeded as exc:
print(f"call {i + 1}: refused — {exc}")
asyncio.run(main())
call 1: ok, tenant spend 0.020000
call 2: ok, tenant spend 0.040000
call 3: refused — orgacme:domNone: 2 req ≥ 2 rpm
spent_in_window(key) is the chargeback number, summed in Decimal so
it reconciles. Quota.charge deliberately leaves Charge.usage empty:
it keeps per-window token counts partitioned by tenant, not one
cumulative Usage, and returning this call's usage under a field
documented as cumulative would be a quieter lie than leaving it blank.
Two housekeeping behaviours you will not see but should know about.
_prune deletes a tenant's key entirely when its window empties,
because a scope carrying a per-user id would otherwise retain one
permanently-empty dict entry per distinct scope ever seen. And _sweep
evicts every long-dead tenant at most once per window — measured, 5000
distinct scopes left 5000 retained keys long after every window had
expired.
Budget also owns depth and concurrency¶
max_depth and max_concurrency live on Budget because they bound
the same runaway the cost ceiling does.
semaphore(depth) returns one semaphore per depth of the agent tree,
not one for the whole tree. A single shared semaphore deadlocks, and not
hypothetically: a parent's fan-out holds its permits for the entire
duration of each child run, so a nested fan-out draws from a pool its
own ancestors have already drained. With max_concurrency=2, an agent
dispatching two as_tool sub-agents that each dispatch their own tools
hangs forever.
Keying on depth breaks the cycle structurally — an ancestor at depth d
only ever holds permits from pool d, and its children draw from pool
d+1. The honest trade: the bound is max_concurrency per level,
so worst-case in-flight work is
max_concurrency * (max_depth + 1). Set it with that in mind.
Where the three surfaces meet¶
They are separate seams, but two bridges exist so you are not cross-referencing streams by hand.
Observation → span. ctx.emit reads TracePort.current_span_id()
and stamps the result on Observation.trace_context, so a UI-visible
observation can deep-link into the span tree that produced it. For
CRITICAL_KINDS it additionally drops an observation.emitted event on
the open span, so the trace timeline is complete on its own.
import asyncio
from agentkit.integrations.observers import CollectingObserver
from agentkit.core.testing import RecordingTracer, make_test_ctx
async def main() -> None:
sink, tracer = CollectingObserver(), RecordingTracer()
ctx = make_test_ctx(observer=sink, trace=tracer)
with ctx.trace.span("chat", "chat", **{"gen_ai.request.model": "fake-1"}):
await ctx.emit("progress", "thinking")
await ctx.emit("result", "done")
for o in sink.items:
print(o.kind, "->", o.trace_context)
print("span events:", [name for name, _ in tracer.spans[0].events])
asyncio.run(main())
progress -> TraceContext(trace_id='00000000000000000000000000000001', span_id='0000000000000001')
result -> TraceContext(trace_id='00000000000000000000000000000001', span_id='0000000000000001')
span events: ['observation.emitted']
trace_context is None when no tracer is configured or no span is
open — a cold emit is still a valid emit.
Meter → span. After charging, the meter() middleware drops a
budget.checkpoint event on the currently-open chat span with the
post-charge spend:
so you can find the call that crossed a threshold by reading the trace, without correlating against an external metric.
What bites people¶
Observationhas noseqorts. It used to declare both, and nothing in agentkit ever set either one — measured,Observation(kind="tool_result", payload={}).seqwas0and.tswas0.0on every record any run produced, while__hash__folded both into the stream key and calledseq"that emitter's monotonic counter". They were removed rather than populated, because ordering already has two better owners: in-process,emitis awaited and every sink preserves arrival order (CollectingObserver.itemsis append-ordered,QueueObserver.stream()yields in insertion order); durably, the Postgres log adapter allocatesseq BIGSERIAL PRIMARY KEYper row and reads backORDER BY seq. If you need a per-sink sequence or an arrival stamp, keep it in the sink beside the record — one shared record cannot carry a different number for each fan-out target, and the sink is what knows when it saw the event.- Wrapping an observer without forwarding
close()strands it. This bit the framework itself: aHooks -> PolicyObserver -> RollupObserverstack silently dropped the rollup's trailing summary at process exit, andisinstance(PolicyObserver(...), ObserverPort)wasFalse— a cadence wrapper failing the very Protocol it exists to compose. Every shipped wrapper now forwardsclose(); a custom one must too. - A cache hit is traced but not billed.
memoizere-emits the stored result verbatim,usageand all. Metering on "any result carrying usage" billed the same provider call once per hit — measured, four identical chats behindmemoizemade one provider call but drovespent_usdfrom 0.25 to 1.0, and a fifth raisedMeterExceededon $0.75 that was never spent.meter()readscall.meta["cache_hit"]and skips the charge;tracing()sits outsidemeter()in the documented chain, so the hit still shows up in the trace. - Never put a high-cardinality tag on a metric.
span.set(k, v)becomes a trace attribute;add_counter/record_histogramtags become time-series dimensions.user_idorrun_idon a metric is how a metrics bill becomes the incident. - Do not set a global OTel provider twice. OTel forbids overriding
set_tracer_provideronce set; sharing a global across tests gives stale-provider failures. Pass an explicittracer_provider=tootel_tracer(...)in tests. ObserverPortis notTracePort. They are wired independently onServicesand neither implies the other. If this page achieves one thing, let it be this one.
Related¶
- Wire OpenTelemetry — the task-shaped version of the traces half, against a real provider.
- Cap spend with Budget and Quota — the meters half, end to end.
- Concepts · Middlewares — where
tracing()andmeter()sit in the chain, and why the order matters. - Concepts · Runtime —
RunContext,Services, and the seams these three surfaces plug into. - Concepts · Kernel —
Observation,ObserverPort,TracePortas value types and Protocols.