Kernel¶
The kernel is agentkit's vocabulary: the small set of data shapes every other part of the framework passes around, the interfaces to the outside world, and the one way a cross-cutting concern wraps a call.
The foundational core.contracts and core.serialization modules contain no
provider client, policy or execution loop. The broader core namespace also
owns execution, runtime enforcement and telemetry. Nothing in these contracts
decides how to use a model — it only says what a message, a request, a
result, a cost and a tool call are.
Is this page for you?
Reach for it when you are writing a middleware, an adapter,
or anything that needs the exact shape of a Message, a Usage
or a ChatRequest.
Skip it for now if you are composing an Agent out of parts
that already exist — the kernel is underneath you either way,
and it stays out of your way.
The problem it solves¶
Frameworks rot from the middle. A "core" package that imports a provider SDK to define its result type has just made that SDK's shape load-bearing for everything above it, and swapping it later means touching every layer. The same happens with policy: once the core owns a retry loop, every consumer inherits the core's opinion about retries.
agentkit's rule is narrow and enforced: agentkit.core imports no
provider, holds no policy, and grows no loop. That is what makes the
other packages substitutable rather than merely configurable. A tracing
adapter, a Postgres checkpoint store and a fake LLM all satisfy kernel
protocols, so a run wired with one is the same run wired with another.
The second thing it buys is a single vocabulary. There is one Usage
type, not one per layer; one Message; one Scope. When the meter, the
memoizer, the audit record and the provider adapter all talk about the
same Usage object, a number that appears in a trace is the same number
that appears on the invoice.
The smallest thing that works¶
Everything below runs offline — FakeLLM and make_test_ctx come from
agentkit.core.testing and need no API key.
import asyncio
from agentkit.core.contracts.types import ChatRequest, Message
from agentkit.core.testing import FakeLLM, make_test_ctx
async def main() -> None:
ctx = make_test_ctx(llm=FakeLLM("42"))
request = ChatRequest(
messages=[Message("user", "what is 6 * 7?")],
model="fake-model",
)
result = await ctx.invoker.chat(request, ctx)
print(result.content) # 42
print(result.usage.total_tokens) # 15
print(result.provider) # fake
asyncio.run(main())
ChatRequest, Message, LLMResult and Usage are kernel types. The
Invoker that ran the request is runtime; the FakeLLM behind it is an
adapter satisfying LLMPort. The kernel supplied the nouns and the
contract, and nothing else.
How it works — four things, and nothing else¶
- Value types. Frozen dataclasses for the shapes that cross a boundary. They carry data; they never do anything.
- Ports.
Protocols describing an external system the framework cannot implement itself — a model, a tool, a vector index, a KV store, the web, the wall clock, durable storage. - The middleware contract. One
Callenvelope, oneMiddlewareshape, onechain()function. Every cross-cutting concern in agentkit is written against exactly this. - Primitives. Cooperative cancellation, bounded fan-out, classify/backoff/circuit-breaker, and the reactive stream operators.
If you can predict which of those four a new idea belongs to, you can predict where agentkit will put it.
Value types¶
| Type | What it carries |
|---|---|
Scope |
The tenant key (org_id, domain_id). Threaded through every cache key, memory recall, meter and callback. |
Usage |
One call's consumption — input / output / cache-read / cache-write tokens, plus an approximate cost_usd. |
Message |
One turn of a transcript: role, content, and tool_calls on an assistant turn. |
ToolCall |
A tool invocation the model asked for: id, name, arguments. |
ToolSchema |
The JSON-Schema advertisement a provider needs in order to offer a tool. |
ChatRequest |
A unit of work: messages, model, tools, sampling settings. |
ToolRequest |
The other unit of work: tool name, arguments, the resolved tool, and the egress/idempotency routing flags. |
LLMResult |
An assembled model response — content, usage, tool_calls, and parsed when an output schema is wired. |
Delta |
One transport increment of a single model response. |
StreamEvent |
One step of a run: a token, a tool call, an interrupt, the final result. |
Operation |
MODEL_CALL or TOOL_CALL — the discriminator a middleware matches on. |
Budget is deliberately not here: a ceiling is a policy, so it lives in
Runtime.
Usage.cost_usd is an approximation; Budget keeps the ledger¶
Usage.__add__ re-rounds cost_usd to six decimal places on every
addition, so the field is neither associative nor identity-preserving —
Usage(cost_usd=1.4296875) + Usage() gives 1.429688. It also rounds
half-to-even while the budget's ledger quantizes half-up, so on an exact
tie the two differ in the last place.
None of that matters while you read it for display. It matters a lot if
you sum this field and expect cents to balance. Budget.spent() is a
Decimal and is the authority. See
Runtime › money.
Ports: the seams to the outside world¶
A port is agentkit's word for a description of something it needs but refuses to build: a model, a database, a vector index, the clock. The port says only what shape that thing must have — which methods, taking what arguments. Somebody else supplies the actual implementation (an adapter), and agentkit never learns which one it got.
That is what lets you run the entire framework offline in a unit test. Swap the real model for a fake one and nothing above the port notices.
The bar for being a port is deliberately high: an external system agentkit cannot implement itself. Features are not ports — idempotency, audit, caching and quota are all things agentkit can build, out of a store plus some logic, so they are middlewares and meters rather than seams of their own. That distinction is what keeps the list short:
| Port | The system behind it | Key methods |
|---|---|---|
LLMPort |
a chat model | stream(), chat(), complete() |
ToolPort |
anything the agent can execute | run(args, ctx) |
VectorPort |
a vector index | upsert(), search() |
StorePort |
a generic KV | get/set/get_or_set/delete/append/list |
SearchPort |
web search | search() |
FetchPort |
the network | fetch() |
ClockPort |
the wall clock | now(), sleep() |
CheckpointPort |
durable run state | save/latest/at_version/list_versions/delete |
TracePort |
operational tracing | span(), current_span_id(), add_event_to_current_span() |
ObserverPort |
the product-facing observation stream | emit(), close() |
MetricsPort |
counters and histograms | add_counter(), record_histogram() |
They are Protocols rather than base classes because the implementations
have nothing in common at the Python level and should not be forced to
inherit from us. Structural typing means an object you already have —
your own HTTP client wrapper, a test double, a vendor SDK behind three
lines of glue — satisfies the port by having the right methods. Most are
@runtime_checkable, so isinstance(x, LLMPort) works for a wiring
assertion.
Two of them are worth calling out.
LLMPort.stream() is the primitive. chat() is defined as
"collect the stream", not the other way round. Everything in the
framework that consumes a model consumes a stream of Deltas and
reduces it, which is why a cache hit and a live call are indistinguishable
to the layers above.
CheckpointPort is separate from StorePort on purpose. A durable
run snapshot has its own access pattern — latest, at-version (time
travel), list-versions, delete-all-for-run — and a versioned,
status-tagged record does not fit a bare KV. One producer is the
authority over a run_id; two producers sharing one would collide on
version. See Capabilities › Checkpointer.
ClockPort exists so retry and backoff loops are testable: use it
instead of time.time() / asyncio.sleep anywhere inside a middleware.
There is one more structural protocol that is not a port: Ctx, in
agentkit.core.contracts.protocols. It is the slice of a RunContext that
capabilities and agents actually read, and it exists so those layers can
type-annotate the context without importing the runtime (which sits
above them). RunContext satisfies it by duck typing; so does a test
stub.
The middleware contract¶
agentkit does exactly two kinds of work: it talks to a model, or it runs
a tool. Rather than give those two separate plumbing, the kernel wraps
both in one envelope — a Call — so that anything cross-cutting
(retry, caching, cost tracking, tracing) is written once and works on
both.
That is the entire reason this contract exists, and why it is this small:
from agentkit.core.contracts.middleware import Call
from agentkit.core.contracts.types import ChatRequest, Message
from agentkit.core.testing import make_test_ctx
call = Call(
kind="chat", # "chat" | "tool"
request=ChatRequest(messages=[Message("user", "hi")], model="fake-model"),
ctx=make_test_ctx(), # the RunContext
meta={}, # per-call signals between middlewares
)
print(call.kind, type(call.request).__name__) # chat ChatRequest
Call.request is a mutable slot holding an immutable value: you
cannot edit the ChatRequest, but you can point call.request at a new
one. That is how compaction rewrites a transcript and how fallback swaps
a model.
A handler yields the operation's items: Deltas for a chat, exactly one
item for a tool. A middleware is an async generator wrapping the next
handler, so it can act before, after, around, or instead of the rest of
the chain. chain(middlewares, terminal) folds a list into one handler,
with middlewares[0] outermost.
That single shape is what makes retry, fallback and memoize expressible
at all: retry re-invokes next, fallback rewrites the request and
re-invokes, memoize may skip next entirely. See
Middlewares for the two authoring styles and the
shipped set.
collect(stream, kind) is the reducer that turns a handler's stream back
into a result — for a chat it is assemble_deltas, so a chat result is
just its collected stream.
Concurrency and cancellation¶
CancellationToken— a cooperative abort signal shared down a run tree. A parent callscancel(); agent loops callraise_if_cancelled()at safe points and getCancelled. This is distinct from a gracefulTerminationCondition(which returns a stop) and from a ceiling (MeterExceeded).gather_bounded(coros, sem=...)— structured fan-out overasyncio.TaskGroup, bounded by a semaphore, preserving input order. A failure cancels the siblings and surfaces as anExceptionGroup.gather_best_effort(coros, sem=...)— the same, except each slot is either a result or aFailurevalue. Use it when one child failing must not sink the batch.run_agents(...)— the fan-out agents actually use; it carves child contexts and per-actor budgets before dispatch.run_sync(coro)— the one sync bridge. If no loop is running it callsasyncio.run; if one is, it runs the coroutine on a fresh worker thread, so you get a blocking signature without "loop already running".
contextvars are copied into each task, so the active span and
correlation id propagate into fan-out.
An abort is bounded, not unconditional
When a fan-out aborts, it waits at most SIBLING_CLEANUP_GRACE_S
(5 seconds) for already-cancelled siblings to run their finally
blocks. Measured with one sibling swallowing CancelledError in a
loop, an unbounded wait was still wedged at 130 seconds, and an outer
asyncio.wait_for could not break it out — the await sat inside an
except BaseException handler, which is a shielded region from the
caller's point of view. Five seconds is a graceful-shutdown grace,
not a deadline: releasing a semaphore or flushing a span is
sub-millisecond.
Failure (from agentkit.core.contracts.errors) is the "error as data" type
these return: a category from classify(), a source naming the slot, a
cause, an optional partial_output, and children when it aggregates.
A Failure is a value a parent can route around; an exception is control
flow.
Immutability: what "frozen" actually means here¶
Every public value type is @dataclass(frozen=True). That alone stops at
the field reference — a frozen dataclass holding a plain dict still
lets you rewrite the dict. Twelve public types had exactly that hole:
from agentkit.core.contracts.ports import Checkpoint, CheckpointStatus
cp = Checkpoint(
run_id="r1", version=1, state={"turn": 3},
created_at=0.0, status=CheckpointStatus.RUNNING,
)
try:
cp.state = {} # always refused: the dataclass is frozen
except Exception as exc:
print(type(exc).__name__) # FrozenInstanceError
try:
cp.state["turn"] = 99 # once silently rewrote a committed durable record
except TypeError:
print("refused too, now")
The obvious fix is MappingProxyType, and it is the wrong one. Measured
against the four things these payloads have to do:
json.dumps |
dataclasses.asdict |
deepcopy |
pickle |
isinstance(_, dict) |
|
|---|---|---|---|---|---|
MappingProxyType |
TypeError | TypeError | TypeError | TypeError | False |
FrozenDict |
ok | ok | ok | ok | True |
So the container payloads are dict and list subclasses that refuse
mutation (agentkit.core.serialization._immutable). Serialisers, isinstance checks
and equality against plain dicts all keep working; checkpoint.state is
still json.dumps-able into a JSONB column, and an AgentResult still
round-trips through dataclasses.asdict.
import copy
import json
import pickle
from agentkit.core.contracts.types import ChatRequest, Message, ToolCall
tc = ToolCall(id="c1", name="search", arguments={"q": "octopus", "filters": {"tags": ["marine"]}})
# The payload is a real dict, so everything that consumes dicts still works.
print(isinstance(tc.arguments, dict), json.dumps(tc.arguments))
print(tc == copy.deepcopy(tc), pickle.loads(pickle.dumps(tc)) == tc)
# ...but it refuses mutation, at every level.
try:
tc.arguments["q"] = "squid"
except TypeError as exc:
print("refused:", str(exc)[:46])
try:
tc.arguments["filters"]["tags"].append("deep")
except TypeError:
print("refused one level down too")
req = ChatRequest(messages=[Message("user", "hi")], model="fake-model")
try:
req.messages.append(Message("user", "and again"))
except TypeError:
print("a request in flight cannot be rewritten in place")
The freeze is deep and it copies. Deep, because a shallow freeze leaves the same bug one level down, which is exactly where decoded provider JSON lives. Copying, because a caller who keeps editing the dict they passed in must not be able to reach the stored payload.
Why it matters per type:
ToolCall.argumentsflows into the approval snapshot, the idempotency key, and the audit trail. A tool doingargs.pop("token")used to desync all three — the arguments that were authorised and the arguments that were executed became different things, with the audit recording the first.ToolSchema.parametersis shared, not per-call: a registry hands the same object to every request for the life of the process. One adapter patching it to fix up its own payload would silently rewrite what every other provider advertises.ChatRequest.messagesis the unit of work the chain is mid-way through executing. Retry re-sends the request it kept, tracing snapshots the messages after the call, memoize keys on the transcript; an in-place append desyncs all three against a request already on the wire. The intended shape is compaction's: read the messages, build a new request withdataclasses.replace, reassigncall.request.
Cost is O(payload) and paid once at construction — around 2.8 µs extra
for a realistic tool-call arguments dict, against the network call the
tool is about to make. deep_freeze short-circuits on an
already-frozen payload, so re-freezing on dataclasses.replace or
unpickle costs one isinstance check rather than a second walk.
The consequence: hashing is on identity, not payload¶
A FrozenDict is still a dict, and dicts are unhashable — so freezing
does not hand back the generated __hash__ a mutable payload cost. Every
affected type defines __hash__ over an identity subset and leaves
__eq__ comparing everything:
| Type | Hashed on |
|---|---|
ToolCall |
(id, name) |
ToolSchema |
(name, description) |
ToolRequest |
(name, side_effecting, url_arg) |
ChatRequest |
(model, temperature, max_tokens, len(messages)) |
Prompt |
(id, version, template, inputs) |
This is sound rather than a shortcut: the hash invariant only requires
equal objects to hash equally, never that unequal ones differ. Two
calls to the same tool with different arguments share a bucket, where
__eq__ separates them — which is what a bucket is for.
It is also the only option that works. Arguments and schema bodies are
decoded provider JSON, so their values are routinely nested list/dict;
a content-inclusive hash would be hashable only when the model happened
to emit scalars. Hashing a stable serialisation instead would make
__hash__ O(payload): measured 0.130 µs for ToolCall at any size,
against 4.99 µs / 87.8 ms for stable_hash on a 1-key and a
100,000-key dict. stable_hash remains right where the content is the
identity — memoize keys, idempotency keys — and wrong for __hash__.
The bug this fixed was not hypothetical
WorkingContext.merge(mode="union") deduplicates via
set(self.messages). It passed its tests, which used plain messages,
and raised TypeError: unhashable type: 'dict' on the real
coordinator fan-in path, where every assistant turn carries tool
calls. ChatRequest was worse: messages is a list, so every
request was unhashable, including the degenerate one.
Delta vs StreamEvent — two levels of "streaming"¶
A Delta is one transport increment of a single model response. A
StreamEvent marks a step in the run: a token, a tool call, an
interrupt, the final result. assemble_deltas() reduces a list of
Deltas back to an LLMResult.
Both can carry an in-progress typed object when an output schema is
declared. Delta.partial is set by the output_coerce() middleware, and
the cognitions forward it verbatim onto StreamEvent.partial_output —
that is how an application streams a typed object through Agent.stream
alone. See the recipe.
assemble_deltas deliberately drops partial. It only ever runs on
a complete delta list, so by then there is no in-progress state left to
carry: parsed — the strict, validated object — is the answer. Lifting
both onto LLMResult would give the result type two competing typed
fields, where the tolerant one (which may have unset required fields)
could shadow the strict one.
A partial object may have unset required fields
StreamEvent.partial_output is built through the type's
bypass-init path (model_construct / object.__new__), so plain
attribute access is not safe. Gate on what has actually arrived:
from pydantic import BaseModel
class Article(BaseModel):
title: str
body: str
# What a tolerant partial parse hands back mid-stream:
partial = Article.model_construct(title="Octopus cognition")
print("title" in partial.model_fields_set) # True — safe to render
print("body" in partial.model_fields_set) # False — has not arrived
So the guard on a message_delta event is
if ev.partial_output is not None and "title" in
ev.partial_output.model_fields_set: before you touch
ev.partial_output.title.
For dataclass and attrs shapes the equivalent guard is
getattr(obj, "title", None) — an unset field is genuinely absent
from __dict__, so attribute access raises AttributeError —
see the worked example in
Capabilities › SchemaAdapter.
The value also refreshes per event and is only stamped when the
partial changed, so consecutive message_deltas may carry None
mid-stream. Hold the last non-None value; do not read a None as
"the object went away".
Errors: one base, and a three-way retry verdict¶
Every typed error the framework raises inherits AgentkitError, so an
application that wants a single boundary can catch that one name:
from agentkit.core.contracts.errors import AgentkitError, StoreUnavailable
try:
...
except StoreUnavailable:
... # the store could not be reached — degrade, or fail the run
except AgentkitError:
... # anything else the framework raised, typed
StoreUnavailable is the one worth naming separately, because it is
infrastructure rather than logic: a StorePort operation could not reach
its backing store. Everything a memoize or checkpoint layer does on top
of a store has to decide what that means for the run.
StoreValueError is its logic-side counterpart, and the two want opposite
responses: StoreUnavailable is usually worth a retry, StoreValueError
never is. StorePort.increment raises it when the argument or the key is
not an integer — either a by that is not a whole number, or a key that
already holds a JSON document, a string or null:
from agentkit.core.contracts.errors import StoreValueError
try:
remaining = await store.increment("ratelimit:user-7", 1, ttl=60)
except StoreValueError:
... # a counter and a document are sharing one key — a bug, not an outage
It exists so that one mistake has one type and one message on every
backend. Left to the backends, the same call raises TypeError from the
in-memory store, redis.ResponseError over Redis and an asyncpg cast
failure over Postgres — three driver-specific types leaking through the
port whose purpose is that callers never see them.
Not every error in the tree is an AgentkitError
The tool errors are the deliberate exception — ToolArgumentError
subclasses ValueError, because the model sending a bad argument is
a value problem and callers already catch ValueError around
argument handling. issubclass(ToolArgumentError, AgentkitError) is
False, on purpose.
ErrorClass is the separate question retry asks — not what went wrong
but is trying again worth anything:
| Member | Meaning | What retry() does |
|---|---|---|
transient |
a timeout, a 429, a 5xx | back off and try again |
permanent |
a 401, a malformed request | give up immediately |
unknown |
anything unrecognised | give up — guessing costs money |
unknown defaulting to "do not retry" is the conservative direction on
purpose: an unrecognised failure retried is a bill with no upside.
Measured — classify(TimeoutError(...)) is transient,
classify(ValueError(...)) is unknown.
Retrying when the problem is not flakiness¶
run_with_resilience retries a call that failed to complete — a timeout, a
502, a dropped connection. Classify, back off, trip a breaker. That is the
right shape for a flaky call and the wrong shape for a semantic one: an
attempt that completed, produced an answer, and did not achieve the goal.
attempt_until_stuck is the other half.
from agentkit.core.execution.recurrence import attempt_until_stuck
result = await attempt_until_stuck(
lambda: run_one(),
fingerprint=lambda outcome: outcome.failure_signature,
on_repeat="escalate", # or "stop"
max_attempts=4, # a backstop, not the bound
)
The bound is recurrence, not a count, and the difference matters more than it sounds. Three attempts producing three different failures is progress. Two producing the same failure is not. A retry count cannot tell those apart, so it is either too tight for the first case or too loose for the second.
So each attempt is fingerprinted, and the run stops when a signature it has already seen comes back. That is why the history is every signature seen rather than just the previous one: an A, B, A, B oscillation is a cycle, and comparing only against the last attempt reads it as progress forever.
on_repeat="escalate" raises Stuck, carrying the Failure for the attempt
that repeated. on_repeat="stop" returns instead. max_attempts is the
backstop for the case where signatures keep genuinely differing.
Why a signature is confirmed by equality, not by its hash
Signatures are compared through stable_hash, because a fingerprint may be
an unhashable dict or list. But stable_hash was built for cache keys,
where its lossiest branch is fail-safe: two values it cannot introspect
collide, and the cost is a cache miss that should have been a hit.
Here the identical collision is fail-dangerous — it is a permanent,
non-retriable "you are going in circles" verdict on two attempts that were
genuinely different. Measured: three distinct __slots__ signatures all
hashed to b5e4d8ee180594ea and the run stopped at attempt 2. So a shared
hash only nominates a candidate; the repeat is not declared until equality
confirms it.
Two helpers worth knowing¶
collect_one(stream) reduces a one-item stream to its single value —
the tool seam's counterpart to collect, which assembles a chat stream's
deltas into an LLMResult. You need it when writing a middleware that
has to see a tool's result rather than pass the stream through.
compose_failures(...) aggregates child failures into one, returning
None when there are none. It is what a fan-out uses to answer "did any
of these fail, and what do I raise" without inventing its own shape.
What bites people¶
ctx.messagesinside a middleware is a copy. Mutating it does nothing. Assignctx.request = ...— that is the writable seam.dataclasses.asdictgives you back frozen containers. It rebuilds each nested container astype(obj)(...), so aFrozenDictstays frozen through the round trip. Good for a durable record; not the "editable copy" escape hatch.dict(tc.arguments)is.ChatRequest.tools is Noneandtools == []are different. The first means "advertise nothing", the second means "here is an empty list"; provider adapters treat them differently, so the optional fields are never materialised into empty containers.cache_hintis not frozen. It is annotatedAny, is whatever the provider wanted, and is passed through without ever being read by the framework. Freezing it would mean rewriting an object we do not own.- Never swallow
CancelledError. A middleware or tool that catches broad exceptions must re-raise it, or cooperative cancellation stops working for everything inside it.
The invariants it enforces¶
- No provider dependency. Importing
agentkit.corenever triggers an import ofhttpx, a vendor SDK, or a database driver. - Values are immutable, payloads included. Middleware constructs a new value and forwards; it never edits one in place.
- Explicit accounting contracts. Legacy cognition and middleware use
core.Usage; model-neutral calls usemodels.contracts.types.Usage, with Decimal costs and separate cache TTL buckets. These result conventions require explicit conversion before sharing a budget ledger. Scope and canonical message values remain shared core contracts. - Additive fields only. A new field carries a default and changes
nothing for a consumer that does not read it —
StreamEvent.partial_outputisNonefor every unstructured run.
Where this fits in the four themes
The kernel sits underneath all four themes on the
landing page — it is the shared vocabulary they build
on. Cognition speaks in ChatRequest / LLMResult /
StreamEvent; Control uses CancellationToken and the
concurrency primitives; State threads the Message / Usage /
Scope value types; Behaviour is the Call + Middleware +
chain(...) contract.
Related¶
- Runtime — the per-request frame these values travel in.
- Middlewares — writing against the
Callcontract. - Capabilities — the optional collaborators over these ports.
- Adapters — the implementations behind the ports.
- Tools — what satisfies
ToolPortin practice. - Testing —
FakeLLM,make_test_ctxand the other doubles. - API › kernel — the generated reference.