Adapters¶
Adapters are how agentkit talks to things it does not own — a model, a database, a vector index, a tracing backend, the clock.
Is this page for you?
Reach for it when you are swapping a backend — Postgres for in-memory, a real vector index for a fake — or writing an implementation of your own.
Skip it for now if the shipped defaults still fit. Every
adapter here is behind a Protocol, so swapping later costs you
one constructor argument.
The problem it solves¶
The moment a framework's core imports a vendor client, that vendor is in your dependency tree forever. You inherit its transport, its retry policy, its error taxonomy and its release cadence, and swapping it means touching code you did not write. The version of this that actually hurts is quieter: your test suite now needs an API key, so nobody runs it, so the integration path rots.
agentkit's answer is a hard rule. agentkit.core defines the
Port protocols. Concrete implementations live with their domain: model transports in agentkit.models, storage in agentkit.state, and external service adapters in agentkit.integrations. Core runtime dispatch loads provider transports lazily. Importing agentkit.core never
triggers an import of httpx, an SDK, or a database driver. Everything
outside is behind an opt-in extra, and every seam has an offline
implementation you can run in a unit test.
The smallest thing that works¶
The same application code, two different storage backends, nothing else changed:
import asyncio
import tempfile
from agentkit.agents import Agent
from agentkit.state.stores import FileStore, InMemoryStore
from agentkit.agents.cognition import ReActCognition
from agentkit.core.contracts.types import ToolCall
from agentkit.core.policies import memoize
from agentkit.core.testing import FakeLLM, FakeTool, Turn, make_test_ctx
async def run_twice(store) -> int:
"""Application code. It never names a storage backend."""
tool = FakeTool(name="lookup", responder={"expiry": "2026-08-01"})
llm = FakeLLM.script(
[
Turn(tool_calls=(ToolCall("c1", "lookup", {"host": "example.com"}),)),
Turn(content="done"),
Turn(tool_calls=(ToolCall("c2", "lookup", {"host": "example.com"}),)),
Turn(content="done"),
]
)
ctx = make_test_ctx(llm=llm, store=store, tool_middleware=[memoize()])
agent = Agent("sre", model="fake-model", cognition=ReActCognition(tools=[tool]))
await agent.run("check the cert", ctx)
await agent.run("check the cert", ctx)
return len(tool.calls)
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
print(await run_twice(InMemoryStore())) # 1 — cached
print(await run_twice(FileStore(tmp))) # 1 — cached, durably
asyncio.run(main())
The pattern, before the catalogue¶
A Port is a seam, not a feature¶
A port is a description of something agentkit needs but will not build: a model, a database, a vector index, the clock. An adapter is an actual implementation of one. Application code only ever names the port, so changing which adapter is behind it is one line at startup.
The design question, then, is what deserves to be a port — and getting it wrong in the generous direction is how frameworks end up with forty extension points that nobody can hold in their head. The rule here is strict, and it is easy to get wrong:
- A Port is an external system agentkit cannot implement itself — a model, a durable store, a vector index, web search, the network, the wall clock, a tracing backend.
- Idempotency, audit, caching, quota, retry, checkpointing are not
ports. They are middlewares and meters backed by a port. There is
no
CachePort; there ismemoize()riding onStorePort.
This is why agentkit.core.contracts.ports is small and stable. A signature
change there is a deliberate, reviewed event, because every adapter in
the ecosystem implements it.
Ports are structural, not inherited¶
Every port is a runtime_checkable Protocol. Your adapter does not
subclass anything; it just has the right methods.
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from agentkit.state.stores import InMemoryStore
from agentkit.core.contracts.ports import StorePort
class CountingStore:
"""A StorePort that counts reads. Nothing is subclassed."""
def __init__(self, inner: Any) -> None:
self._inner, self.reads = inner, 0
async def get(self, key: str) -> Any | None:
self.reads += 1
return await self._inner.get(key)
async def set(self, key: str, value: Any, *, ttl: int | None = None) -> None:
await self._inner.set(key, value, ttl=ttl)
async def get_or_set(
self, key: str, fn: Callable[[], Awaitable[Any]], *, ttl: int | None = None
) -> Any:
return await self._inner.get_or_set(key, fn, ttl=ttl)
async def delete(self, key: str) -> None:
await self._inner.delete(key)
async def append(self, key: str, value: Any) -> None:
await self._inner.append(key, value)
async def list(self, key: str) -> list[Any]:
return await self._inner.list(key)
# The coordination half. `isinstance` against a runtime_checkable Protocol
# checks for EVERY method, so leaving these three out makes the check below
# print False — a wrapper that forwards six of the nine is not a StorePort.
async def compare_and_set(
self, key: str, expected: Any, value: Any, *, ttl: int | None = None
) -> bool:
return await self._inner.compare_and_set(key, expected, value, ttl=ttl)
async def increment(self, key: str, by: int = 1, *, ttl: int | None = None) -> int:
return await self._inner.increment(key, by, ttl=ttl)
# `def`, not `async def`: an `async def` that yields is a function returning
# an iterator, not a coroutine. Delegating with `async for`/`yield` keeps it
# an async generator, which is what the port asks for.
async def scan(self, prefix: str, *, limit: int | None = None) -> AsyncIterator[str]:
async for key in self._inner.scan(prefix, limit=limit):
yield key
async def main() -> None:
store = CountingStore(InMemoryStore())
print(isinstance(store, StorePort)) # True
await store.set("k", 1)
print(await store.get("k"), store.reads) # 1 1
asyncio.run(main())
The ports¶
| Port | Module | What it abstracts |
|---|---|---|
LLMPort |
kernel.ports |
a model — stream is the primitive, chat ≡ collect(stream), complete is a shim |
ToolPort |
kernel.ports |
one callable capability the loop can see |
StorePort |
kernel.ports |
the single durable-KV seam |
VectorPort |
kernel.ports |
a scope-isolated vector index |
SearchPort |
kernel.ports |
web search, normalised to SearchHit |
FetchPort |
kernel.ports |
a text HTTP fetch, returning FetchResponse |
ClockPort |
kernel.ports |
wall time — now() / sleep() |
CheckpointPort |
kernel.ports |
durable run state, keyed (run_id, version) |
ObserverPort |
kernel.observation |
the product-facing observation channel |
TracePort |
kernel.observation |
operational spans |
MetricsPort |
kernel.metrics |
counters and histograms |
SamplerPort |
kernel.sampling |
the head-sampling decision at span open |
ReplayStore |
kernel.replay |
side-channel storage for full LLM payloads |
You wire them once, into Services, and every run in the process shares
them. Each seam has a default so Services() with no arguments is
fully usable: NoopTrace, NoopObserver, NoopReplayStore,
NoopMetrics, AlwaysOnSampler. Observability is never a required
dependency.
One reference implementation per seam¶
Every family ships an in-process adapter that is not a toy — it is the
contract the durable backends are held to. tests/source/architecture/test_protocol_conformance.py
runs one contract per Protocol against every implementation, so a new
adapter either passes that file or is not a conforming implementation.
That is what stops four adapters answering the same question four
slightly different ways.
The catalogue¶
adapters.llm — models¶
CallableLLM wraps anything you already have. Point it at a function
and you have an LLMPort:
import asyncio
from agentkit.agents import Agent
from agentkit.models import CallableLLM
from agentkit.core.testing import make_test_ctx
def my_provider_sdk(*, system, user, model, **_kw):
"""Stand-in for whatever your provider SDK already returns."""
return {
"content": f"[{model}] {user.upper()}",
"usage": {"input_tokens": 11, "output_tokens": 3},
}
async def main() -> None:
ctx = make_test_ctx(llm=CallableLLM(my_provider_sdk))
result = await Agent("shouty", model="my-model").run("hello", ctx)
print(result.output) # [my-model] HELLO
print(result.usage.input_tokens, result.usage.output_tokens) # 11 3
asyncio.run(main())
A sync fn runs off the loop via asyncio.to_thread; a coroutine is
awaited. Pass chat_fn= as well when your provider is tool-aware —
otherwise chat() flattens the transcript and reuses complete().
Provider tool-calls are normalised to agentkit ToolCalls, and
to_result= / cost_fn= let you override the mapping and the pricing.
The HTTP provider clients live under adapters.llm.providers
(extra: arc-agentkit[http]). There is one transport, httpx, and
no per-provider SDKs. Two HTTP shapes cover the field:
OpenAICompatibleLLM— OpenAI, DeepSeek, OpenRouter, Together, Groq, vLLM, anything else that speaks the same wire format, selected bybase_url. Presets:openai(),deepseek(),openrouter().AnthropicLLM— the Messages API, whose request shape genuinely differs (systemis top-level, content is blocks, prompt caching iscache_controlon a block). Preset:claude().
These clients only do the call, the parse, and cache-aware cost.
Resilience, observability and result caching are the middleware chain's
job — see Middlewares. They map HTTP failures onto a
pre-classified ProviderError (rate-limit / 5xx / network → transient;
4xx → permanent, with ProviderAuthError for credentials) so
retry() and fallback() react correctly without their own taxonomy.
An error can arrive inside a 200
Both providers can deliver a failure inside a successful
response, part-way through a stream, long after headers.
raise_if_error_frame turns those in-band SSE error frames into a
raised ProviderError. Before it existed, the frame fell through
every branch, the loop ended normally, and the caller received a
truncated answer presented as a complete one —
finish_reason=None, partial text, no exception. Nothing raised,
so retry() never fired: the single most retryable provider
failure there is (overloaded_error) was the one the resilience
layer never saw.
cache_hint on a ChatRequest is where the KV-cache discipline from
Context lands: the Anthropic adapter turns a truthy
cache_hint into cache_control: {"type": "ephemeral"} on the system
block. The clients normalise usage onto one convention —
input_tokens is fresh input, cache_read_tokens are prompt-cache
hits, cache_write_tokens are cache creation — so
providers.pricing.cost() can bill the three at their different rates.
The price table will go stale
models/providers/pricing.py holds best-effort public list
prices, USD per 1M tokens as (input, output, cache_read,
cache_write). An unknown model costs 0.0 — no guessing. If
money matters contractually, inject your own pricing= callable
on the client.
adapters.store — the one KV seam¶
StorePort backs what used to be four ports: cache, idempotency,
checkpoint and audit. The contract is small and precise:
import asyncio
from agentkit.state.stores import InMemoryStore
async def main() -> None:
store = InMemoryStore()
calls = {"n": 0}
async def produce() -> str:
calls["n"] += 1
return "expensive"
# Single-flight: 20 concurrent callers, ONE producer run.
got = await asyncio.gather(*(store.get_or_set("shared", produce) for _ in range(20)))
print(set(got), calls["n"]) # {'expensive'} 1
# A producer that raises is NEVER stored. Failures are not cached.
async def boom() -> str:
raise RuntimeError("upstream 503")
try:
await store.get_or_set("flaky", boom)
except RuntimeError as exc:
print("raised:", exc)
print("stored:", await store.get("flaky")) # None
# Append-only log — what the audit middleware rides on.
await store.append("audit:run-1", {"step": 1})
await store.append("audit:run-1", {"step": 2})
print(await store.list("audit:run-1"))
asyncio.run(main())
| Adapter | Extra | Notes |
|---|---|---|
InMemoryStore |
— | the reference implementation. Honours TTL (lazy expiry on read plus an amortised sweep every 256 writes, so a key nobody reads again is still reclaimed) |
FileStore |
— | zero-dependency durable JSON files; survives a restart. Ignores TTL and warns once |
RedisStore |
redis |
the TTL-native backend |
PostgresStore |
postgres |
set(ttl=...) raises NotImplementedError rather than silently dropping expiry |
get_or_set is keyed on presence, not truthiness, in every
adapter — a legitimately cached None is a hit, not a miss. That was a
real bug: a producer returning None re-ran on every call and
single-flight silently stopped holding.
The coordination half — compare_and_set, increment, scan¶
The six methods above express "cache this" and "record that". They do not express changing something that is already there, and three completely ordinary shapes went around the port because of it.
get_or_set covers create-if-absent. It cannot express
replace-only-if-unchanged, which is what every read-modify-write
needs. Allocating a monotonic ordinal is "read the max, write max+1",
and two writers race it: both read 4, both write 5, and one ordinal
is handed to two runs. compare_and_set(key, expected, value) writes
only if the key still equals expected, and it returns whether it
applied rather than raising. That is the load-bearing choice. Losing a
CAS is the expected half of an optimistic loop, not a fault; raising
would force every read-modify-write to wrap its own body in a
try/except and then tell "someone beat me" apart from "the store is
down" by exception type — precisely the distinction the bool already
makes. Two details you will meet on the first loop you write:
comparison is by equality, never identity (three of the four
backends round-trip the value through JSON, so you never hold the object
that was written), and an absent key compares equal to
expected=None, so the first iteration behaves like every later one.
A counter with an expiry is the shape of every rate limit, and the
port could not express it at all. The observed consequence was an
application writing a raw Lua script straight at Redis — putting its own
limiter outside everything the framework can test, trace or meter.
increment(key, by=1, ttl=...) is that shape as one atomic step:
absent counts as 0, the new total comes back, and by may be negative
because refunds and released reservations are decrements and a
counter that only went up would need a second key to track what came
back.
list(key) reads back one appended log. There was no way to
enumerate keys under a prefix, so "everything recorded for this run" was
answerable only if every writer also maintained an index by hand — the
classic pair that drifts. scan(prefix, limit=...) yields the KV keys
beginning with prefix: full keys, not values, in no promised order,
and never the append-log namespace (list reads those, and a scan that
surfaced log keys would hand you keys get answers None for). It is
declared def ... -> AsyncIterator[str], not async def, for the same
reason as LLMPort.stream — an async def that yields is a function
returning an iterator, not a coroutine — so you consume it with
async for. limit is a cap on how many keys you are willing to
receive, not a page cursor: with no ordering promised, which keys
arrive under a cap is backend-defined. 0 is a real cap of zero and a
negative limit raises ValueError, because collapsing the two would
make limit=remaining_budget return the entire key space at exactly the
moment the budget ran out.
import asyncio
from agentkit.core.contracts.errors import StoreValueError
from agentkit.state.stores import InMemoryStore
async def main() -> None:
store = InMemoryStore()
async def next_ordinal() -> int:
"""Read-modify-write. A lost CAS is a `False` you loop on."""
while True:
current = await store.get("run-1:ordinal")
nxt = (current or 0) + 1
if await store.compare_and_set("run-1:ordinal", current, nxt):
return nxt
print(sorted(await asyncio.gather(*(next_ordinal() for _ in range(5)))))
# A counter with a window. `ttl` opens one; it never slides an open one.
for _ in range(3):
used = await store.increment("run-1:calls", 1, ttl=60)
print(used) # 3
# `by` must be a non-bool int, on every backend.
try:
await store.increment("run-1:calls", 1.5)
except StoreValueError as exc:
print(str(exc).split(";")[0])
# `scan` is an AsyncIterator over KEYS — hence `async for`.
await store.append("run-1:audit", {"step": 1}) # a log: deliberately not scanned
print(sorted([k async for k in store.scan("run-1:")]))
asyncio.run(main())
ttl on these two is not portable, and the split is not the
obvious one. It is where the four backends genuinely differ, and a
caller choosing one needs the matrix before writing the code, not
after:
| Adapter | compare_and_set(..., ttl=) |
increment(..., ttl=) |
|---|---|---|
InMemoryStore |
honoured | honoured, EXPIRE NX semantics |
FileStore |
ignored, warns once per store | ignored, warns once per store |
RedisStore |
honoured (SET … EX) |
honoured (EXPIRE … NX, Redis 7.0+) |
PostgresStore |
raises NotImplementedError |
raises NotImplementedError |
scan takes no ttl at all; on the backends that honour expiry it
simply does not yield keys that have expired.
Two semantics inside that table are worth reading twice. On
compare_and_set, ttl applies only to the write that lands — a
refused CAS leaves the existing expiry alone. On increment, ttl
opens a window on a counter that has none and never slides one that is
already open. The alternative — resetting the deadline on every hit —
breaks the exact case the primitive exists for: under sustained traffic
the counter is touched more often than the window is long, so it never
expires, the limit never resets, and the rate limiter jams shut
precisely under load. The counter and its window are set together,
atomically, so a failure cannot leave a counter with no window (an
immortal one) or a window with no counter.
FileStore and PostgresStore disagreeing here is deliberate rather
than an oversight in one of them. FileStore has no expiry sweeper, so
it warns once and keeps the key forever; PostgresStore refuses the
kwarg outright, matching its own set, on the grounds that a method
quietly accepting a ttl its neighbours reject is worse than either
policy alone — the caller would conclude the backend supports expiry.
The practical consequence: a windowed counter needs RedisStore or
InMemoryStore. On Postgres you carry the window yourself, in a
second key or a column; on FileStore the counter is permanent and the
warning is telling you so.
by must be a non-bool int on every backend, and that is newly
enforced. increment validated the value already in the key and
never the amount being added, so each backend improvised, and all four
improvised differently. Measured, on increment(k, 1.5):
InMemoryStoreandFileStorereturned1.5— afloatout of a method annotated-> int— and left1.5in the key, which the next increment then rejects as a non-counter. The counter is poisoned by a call that reported success.RedisStorereturned1while the key held1.5: theint()around INCRBY's reply truncates, so the number the caller acts on and the number the store holds disagree.PostgresStorefailed inside the driver with a bareValueError.
check_by now rejects it before it reaches any backend, so it is one
type and one message everywhere. bool is excluded even though
isinstance(True, int) is True in Python: True would silently count
as 1 in a dict and be rejected as JSON true by Redis and Postgres,
which would make the offline reference store the one backend that
accepted a value the durable ones refuse — and it is the one everybody
tests against.
That error, and the one for a key that holds a JSON document, a string
or null instead of a counter, is the same StoreValueError on every
backend; Kernel › errors
covers why it is a separate type from StoreUnavailable and why it is
never worth a retry. Note that increment treats a stored null as a
non-integer rather than as absent — the one place it deliberately
disagrees with compare_and_set, whose expected came out of a get
that cannot tell null from absent and so has to accept both.
adapters.vector — retrieval¶
import asyncio
from agentkit.state.vectors import InMemoryVector
from agentkit.core.contracts.types import Chunk, Scope
async def main() -> None:
vec = InMemoryVector()
tenant_a = Scope(org_id=1)
await vec.upsert(
tenant_a,
[Chunk(id="1", text="the TLS certificate expired", metadata={"doc": "runbook"})],
)
for score, chunk in await vec.search(tenant_a, "certificate", k=3):
print(round(score, 3), chunk.id) # 0.5 1
# Scope is the isolation boundary, not a filter callers may forget.
print(await vec.search(Scope(org_id=3), "certificate", k=3)) # []
# `where` filters on chunk metadata.
print(len(await vec.search(tenant_a, "certificate", k=3, where={"doc": "other"}))) # 0
asyncio.run(main())
search returns (score, chunk) pairs rather than bare chunks so a
caller can threshold: RAG ignores the score, the semantic-cache
middleware uses it.
InMemoryVector— TF-cosine over tokenised text. Offline, deterministic.PgVectorStore(postgres) — the durable counterpart, sameupsert/searchcontract and the same dependency-free feature-hashed embedding, so RAG behaves identically offline and in production. Ranks by cosine distance (<=>) and returnsscore = 1 - distance. Callawait init()once to create the extension, table and index. Swap in a real embedding model by overriding_embed; the schema and queries are unchanged.
adapters.checkpoint — durable run state¶
import asyncio
import dataclasses
import json
from agentkit.state.checkpoints.adapters import InMemoryCheckpointStore
from agentkit.core.contracts.ports import Checkpoint, CheckpointStatus
async def main() -> None:
store = InMemoryCheckpointStore()
await store.save(Checkpoint("run-1", 1, {"turn": 1}, 1.7e9, CheckpointStatus.RUNNING))
await store.save(Checkpoint("run-1", 2, {"turn": 2}, 1.7e9, CheckpointStatus.SUSPENDED))
latest = await store.latest("run-1")
print(latest.version, latest.status, latest.status.is_terminal()) # 2 suspended False
print(await store.list_versions("run-1")) # [1, 2]
# `state` is genuinely frozen — not just the field reference.
try:
latest.state["turn"] = 99
except TypeError as exc:
print(str(exc).split(".")[0])
# ...and still serialises like the dict it is.
print(json.dumps(latest.state), isinstance(latest.state, dict)) # {"turn": 2} True
print(dataclasses.asdict(latest)["state"]) # {'turn': 2}
asyncio.run(main())
That last part is the design decision worth understanding, because it
constrains every checkpoint adapter. Checkpoint is
@dataclass(frozen=True), and frozen stopped at the field reference:
cp.state = {} raised while cp.state["turn"] = 99 silently rewrote a
run's snapshot after the row was committed. The in-memory record and
the durable row then disagreed, and nothing said so.
The obvious fix — MappingProxyType — is the wrong one here. Measured
against the four things these payloads actually have to do:
json.dumps |
dataclasses.asdict |
deepcopy |
pickle |
isinstance(_, dict) |
|
|---|---|---|---|---|---|
MappingProxyType |
TypeError | TypeError | TypeError | TypeError | False |
FrozenDict |
ok | ok | ok | ok | True |
Checkpoint.state is json.dumps'd into a JSONB column and
AgentResult round-trips through dataclasses.asdict, so a proxy would
have traded a mutability bug for a data-path outage. FrozenDict
(agentkit.core.serialization._immutable) is a dict subclass, so every consumer
keeps working — serialisers, isinstance checks, equality against plain
dicts — while refusing mutation. The freeze runs on every
construction, including PostgresCheckpointStore._row_to_checkpoint, so
a checkpoint comes back frozen on the way out of storage as well as
in. A record frozen on save and mutable on resume is half fixed, and
resume is exactly where a caller reaches for cp.state.pop("pending").
CheckpointStatus is the coarse durability gate auto-resume keys off:
RUNNING (engine in motion), SUSPENDED (waiting on a human), DONE /
FAILED (terminal, and filtered by resume() so a "resume if any
checkpoint exists" wiring cannot re-run a finished job). A coordinator
persisting a human-gate wait must use SUSPENDED, not RUNNING.
InMemoryCheckpointStore— dict-backed, the contract every durable backend matches.PostgresCheckpointStore(postgres) —PRIMARY KEY (run_id, version), which is also whatCheckpoint.__hash__hashes.
adapters.observer — the observation channel¶
Terminal sinks and cadence wrappers, composable in any order:
import asyncio
from agentkit.integrations.observers import CollectingObserver, Hooks, PolicyObserver
from agentkit.core.testing import make_test_ctx
async def main() -> None:
sink = CollectingObserver()
seen: list[str] = []
observer = Hooks(inner=PolicyObserver.result_only(sink)).on(
"*", lambda obs: seen.append(obs.kind)
)
ctx = make_test_ctx(observer=observer)
await ctx.emit("step", "planning", agent="analyst")
await ctx.emit("result", "done", payload={"ok": True}, agent="analyst")
await observer.close()
print(seen) # ['step', 'result']
print([o.kind for o in sink.items]) # ['result']
asyncio.run(main())
CollectingObserver— every observation into.items, no backpressure. Tests and small in-process consumers.QueueObserver(maxsize=...)— bounded and non-blocking, with a never-drop-results rule; consume it withasync for obs in observer.stream().PolicyObserver(inner, allow=...)— a kind filter. Constructors:.everything(),.summaries(),.result_only().RollupObserver— buffers and emits periodic summaries.Hooks(inner=...)—on(stage, handler)lifecycle subscription, wherestageis an observationkindor"*". Sync or async handlers, and an exception in a handler is swallowed: a hook must never destabilise the run it observes.
Observability covers the channel itself — kinds, cadence policy, and how observations relate to spans. What follows is just the adapter surface.
result / error (the CRITICAL_KINDS) and the human-gate
interrupt are always forwarded by both cadence wrappers. A
cadence policy may reorder or delay, but it may not silence a result or
a human gate.
close() is part of ObserverPort for a reason: buffering observers
need a shutdown hook to flush their tail. Hooks chains in front of
another observer, so when it lacked close() the whole stack below it
lost its shutdown hook and a Hooks → PolicyObserver → RollupObserver
stack silently dropped the trailing summary at process exit. Unlike
emit, a failing inner close is not swallowed — hiding a flush
failure would lose the buffered tail without a trace.
adapters.observability — the OTel bridge¶
Distinct from adapters.observer: that one is the product-facing
observation channel, this one is operational tracing and metrics.
Pure OTel SDK, no vendor dependency, behind arc-agentkit[observability].
otel_tracer()— aTracePort. Pass nothing in production and it pulls the global tracer configured by the standardOTEL_SERVICE_NAME/OTEL_EXPORTER_OTLP_ENDPOINTenv vars; pass an explicittracer_providerin tests.otel_meter()— aMetricsPort.otel_exporter_otlp_http()/otel_metrics_exporter_otlp_http()— one-line exporter setup.otel_sampler(ratio)— head sampling once traffic outpaces your backend.
See Observability for the mental model and the OpenTelemetry recipe for a full wiring.
adapters.replay — payloads out of the span¶
Spans carry the index; the replay store carries the bulk, so attribute
cardinality stays under the OTel SDK budget. FileReplayStore writes
each ReplayRecord as one JSON file under <root>/<span_id>.json,
atomically (temp file plus os.replace, so no reader ever sees a torn
file).
ReplayStore.put must never raise into a run — but best-effort was
never meant to be invisible. Every failure path logs (once per failure
class, so a full disk cannot flood the log) and bumps a counter. Alert
on dropped_writes / failed_reads if replay data matters; an ordinary
cache miss is neither logged above DEBUG nor counted.
Configuration — the AGENTKIT_REPLAY_DIR env var and the default
directory — is in API › adapters.
The model registry¶
Two problems wearing the same hat, so they share one table.
The last mile. claude() / openai() / deepseek() /
openrouter() all require an explicit api_key=, and the package read
no provider environment variable anywhere. Every application therefore
wrote the same bootstrap — read the key, pick the provider, handle the
optional-extra ImportError, degrade without failing silently. In one
production codebase that was ~710 lines and the only domain-free code in
its engine.
The silent capability failure. A model is a string.
claude(model="claude-sonnet-4-6") says nothing about what it can do,
so nothing catches a role bound to a model that lacks a needed
capability. That failure is silent and well-formed: an agent asked to
read images, bound to a model that cannot see, returns a structurally
valid answer citing evidence it never read.
Both are answered by knowing things about a model name. A ModelEntry
carries the provider that serves it and the capabilities it declares; a
ProviderEntry carries the environment variable and a lazily-imported
factory. Resolution and capability checking read the same rows.
Capability is three-valued, and the third value is the point¶
from agentkit.models import Capability, ModelCapabilities, ModelEntry, default_registry, normalize_model_name
reg = default_registry()
print(reg.capabilities("claude-sonnet-4-6").tools) # yes (Capability.YES)
print(reg.capabilities("deepseek-reasoner").tools) # no (Capability.NO)
print(reg.capabilities("acme-llm-v3").tools) # unknown
print(reg.capabilities("acme-llm-v3").all_unknown) # True
# A dated release id from a provider response resolves to the family row.
print(normalize_model_name("anthropic/claude-sonnet-4-6-20250101"))
print(reg.capabilities("claude-sonnet-4-6-20250101").context_window) # 200000
reg.register_model(
ModelEntry(
name="acme-llm-v3",
provider="openai",
capabilities=ModelCapabilities(
tools=Capability.YES, streaming=Capability.YES, context_window=32_000
),
)
)
print(reg.capabilities("acme-llm-v3").tools) # yes
Capability is a three-valued StrEnum — YES, NO, UNKNOWN —
because two-state is what causes the bug:
- A
boolforces every unrecognised model into "has it" (silent wrong answers) or "doesn't" (refuses every self-hosted model on day one). - A
bool | Noneis worse than either, becauseif caps.vision:readsNoneas absent and the type checker will not stop you.
A three-valued enum makes the third case impossible to ignore at the
read site. Nothing is guessed: an unregistered model reports every
capability as UNKNOWN, never False and never True. What to do
about UNKNOWN is the caller's policy.
The declarable capabilities are tools, structured_output,
native_json_schema, streaming, vision. context_window is
deliberately not among them — it is an int, not a tri-state, and is
requested as min_context_window=. Two distinctions worth reading
twice:
structured_outputmeans "can be steered to emit parseable JSON at all", via the native mode or the prompt-injection fallback.native_json_schemais the strict server-sideresponse_format={"type": "json_schema"}mode specifically.visionis declared but not yet checkable end to end —Message.contentis astrand both provider adapters map it to text only. It is there so the declaration is ready when that path lands, and so an application routing images outside the framework can still consult it.
ModelCapabilities.get(name) raises on an unrecognised name: a typo
in requires= is a programming error, not something that should degrade
into UNKNOWN and silently never check anything.
Refusing a mismatch before any spend¶
import warnings
from agentkit.models import CapabilityMismatch, default_registry
reg = default_registry()
# A declared NO is fatal, always.
try:
reg.check("deepseek-reasoner", ("tools",), subject="sre-agent")
except CapabilityMismatch as exc:
print(str(exc).splitlines()[0])
# UNKNOWN is policy. Default: warn and continue.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
reg.check("acme-llm-v3", ("tools",), subject="sre-agent")
print(caught[0].category.__name__) # UserWarning
# A service that pins its models makes "we don't know" a deployment-time stop.
try:
default_registry().check("acme-llm-v3", ("tools",), on_unknown="refuse")
except CapabilityMismatch:
print("refused")
# A DERIVED requirement stays quiet on UNKNOWN.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
default_registry().check("acme-llm-v3", derived=("tools",))
print(len(caught)) # 0
deepseek-reasoner is the canonical example: it does not accept tools,
that is real and checkable today, and without a declaration you bind it
to a ReActCognition and the tools are simply ignored.
requires= is what the caller declared; derived= is what the
framework inferred from wiring (a tool-holding cognition implies
tools). Both raise on a declared NO — that is unambiguous and no
policy makes it acceptable — but only requires participates in the
on_unknown policy. The asymmetry is deliberate: nagging about a
derived requirement for every unregistered model name would put a
warning on essentially every development wiring and teach people to
filter the category out, at which point the real warnings go unread too.
on_unknown takes "warn" (default — every existing wiring keeps
working), "refuse" (the right setting for a production service that
pins its models), or "allow" (verified out of band).
Resolving a provider from the environment¶
from agentkit.models import ProviderNotConfigured, default_registry
reg = default_registry()
print(reg.provider_for("claude-sonnet-4-6")) # anthropic
print(reg.provider_for("gpt-4o-mini")) # openai
print(reg.provider_for("meta-llama/llama-4")) # openrouter
print(reg.provider_for("anthropic/claude-sonnet-9")) # anthropic
print(reg.provider_for("acme-llm-v3")) # None
# Credentials come from the environment; `env` is injectable so a test
# never touches os.environ.
try:
reg.resolve("claude-sonnet-4-6", env={})
except ProviderNotConfigured as exc:
print(exc)
# Your own naming convention, without forking a prefix table.
reg.register_rule(lambda name: "openai" if name.startswith("acme-") else None)
print(reg.provider_for("acme-llm-v3")) # openai
Lookup order: a registered ModelEntry always wins over any rule, and
rules run most-recently-registered first — each rule sees every
normalised candidate before the next rule is consulted. That ordering
is load-bearing. With candidates as the outer loop,
provider_for("anthropic/claude-sonnet-9") returned openrouter,
because the coarse "has a slash" rule matched the raw name before the
family rule was ever offered the bare claude-sonnet-9.
normalize_model_name produces the candidates, most specific first, by
stripping an OpenRouter-style provider/ prefix and a trailing dated
release id (claude-haiku-4-5-20251001, gpt-4o-mini-2024-07-18 — both
shapes, one pattern). Providers echo the dated id on every response, so
without this a LLMResult.model never matched a table row.
Four things this module commits to, each load-bearing:
- A missing extra degrades loudly. No
httpx, and you getMissingProviderExtranaming the extra. It is never absorbed into the fallback — a broken install must not masquerade as a missing credential, and a fake serving production traffic is the worst outcome in this problem space. - Fallback is explicit.
resolve()with no credential raises unless you passedfallback="fake". This differs from "warn and downgrade" on purpose: in a server process aUserWarninggoes to a log nobody reads, and the result is fabricated completions served as real ones. When it is requested, the downgrade warns exactly once per registry. - A credential never appears in a message. Not in an error, not in
a warning, not in a
repr.ProviderNotConfigurednames the variables checked, never their values — not even masked, because a masked credential is a leak's first half. Pinned by test. - Empty string is absent. A shell exporting
OPENAI_API_KEY=is a misconfiguration; passing""to a provider produces a confusing 401 far from the cause.
The module-level conveniences — resolve_llm, model_capabilities,
require_capabilities, register_model, register_provider,
register_rule — act on the process-wide registry(), built on first
use and shared, so an application's startup registrations are visible to
every later Agent construction. default_registry() returns a fresh,
independent instance; take one of those in a test rather than mutating
the shared one.
The built-in table will go stale
It is a convenience default, like the price table beside it. An
application pins its own truth by registering rows over it —
register_model replaces by name. The built-in rules only route
a name to a provider when no row matches; they never invent a
capability. A gpt- model nobody has heard of resolves to the
OpenAI provider (correct) and reports every capability as
UNKNOWN (also correct).
Writing your own adapter¶
- Pick the port. Read its Protocol in
agentkit/core/contracts/ports.py(orobservation.py/metrics.py/sampling.py/replay.py). If the thing you are wrapping is a feature rather than an external system, you probably want a middleware instead — see Middlewares. - Implement the methods. No base class. Protocols are structural;
isinstance(mine, StorePort)isTrueonce the methods exist. - Read the reference implementation.
InMemoryStore,InMemoryVector,InMemoryCheckpointStoreandNoopObserverare the contract, not toys. Behavioural details like presence-vs-truthiness inget_or_setand never-storing-a-raised-producer are part of the port, not of that adapter. - Run the conformance suite.
tests/source/architecture/test_protocol_conformance.pyholds one contract per Protocol, parametrised over every implementation. Add yours to the parameter list. - Keep the dependency optional. If it needs a client library,
import it lazily inside
__init__or the first call, add an extra inpyproject.toml, and raise something that names the extra. Never import it at module scope in a package the core touches. - Do not put policy in it. Retry, tracing, caching, budgets and quotas are the middleware chain's job. An adapter that retries internally makes the framework's retry policy unobservable and double-retries under load.
What bites people¶
memoize() on a chat chain: a typed parsed is not cached durably
Chat memoization used to store an LLMResult object.
InMemoryStore holds objects, so it worked; every durable adapter
serialises with json.dumps, so the same wiring raised
TypeError: Object of type LLMResult is not JSON serializable on
the first cache write against FileStore, RedisStore or
PostgresStore — i.e. it failed for everyone who wired a cache
that outlives the process. A chat result is now stored
JSON-shaped and rebuilt on the way out (tool_calls come back as
real ToolCalls with frozen arguments; all five Usage fields
survive), so every store adapter caches a chat turn.
One field cannot follow: LLMResult.parsed, the typed object
output_coerce() produces. Rebuilding a Pydantic model or a
dataclass from JSON would mean importing a class named in a cache
entry. So the rule is carry it or refuse the entry, never
downgrade it.
A JSON-native parsed — dict, list, str, int, float, bool,
None — round-trips normally. Anything else makes that one call
uncacheable on a serialising store: the call still runs, the
caller still gets the complete typed result, but nothing is
stored, and a UserWarning plus
call.meta["cache_stored"] = False say so.
On InMemoryStore the typed object survives a hit as before.
Storing a parsed=None copy instead would mean a miss returning
Plan(...) and a hit returning None — a wrong answer beats a
missed cache hit only in a benchmark.
Tool-result memoization is unaffected on every adapter — a tool result is ordinary JSON and always was.
TTL is not uniform across store adapters
InMemoryStore honours it. RedisStore honours it natively.
FileStore ignores it and warns once — an idempotent() key
that never expires dedupes a legitimate retry of the same
operation forever. PostgresStore raises NotImplementedError
rather than accept a kwarg it cannot honour. Pick the backend for
the key space, not the other way round.
The same split applies to compare_and_set and increment, with
one sharper consequence: a windowed counter cannot exist at all on
the two backends without expiry. See the matrix in
the coordination half.
Importing adapters.llm.providers requires the http extra
It raises ImportError at module load without httpx. This is
why ProviderEntry.factory is a dotted "module:attr" string
rather than a callable — registering a provider must cost no
import, or the registry itself would become unimportable on a
zero-dependency install, which is the opposite of what it is for.
Importing agentkit.models (the registry) is always safe.
An adapter that retries is an adapter you cannot observe
The provider clients deliberately do only call + parse + cost.
Wrap them with retry(), tracing(), meter(), fallback()
from the middleware chain instead. If your custom adapter hides a
retry loop, the framework's own retry sits on top of it and your
backoff multiplies.
InMemoryVector is not a toy, but it is not an embedding model
It ranks by TF-cosine over tokenised text, and PgVectorStore
uses the same feature-hashed embedding so the two agree. That is
the point — RAG behaves identically offline and in production. It
is not semantic similarity. Override _embed with a real model
before you conclude retrieval quality is bad.
Related¶
- Kernel — the Port protocols and the value types adapters speak.
- Runtime —
Services, where adapters get wired. - Middlewares — the policies that ride on top of a port.
- Testing — the offline doubles for every port.
- Observability — the observer / trace / metrics / replay seams in depth.
- Memory — what
VectorPortis usually holding. - Context — where
cache_hintcomes from and why it matters. - Pick a provider from config — the model registry end to end.
- API › adapters — the generated reference.