Skip to content

agentkit.kernel

Value types (ChatRequest, LLMResult, Message, Scope, Usage), the Port protocols, the middleware contract, and the concurrency + resilience primitives. (Budget is a runtime concern — see api-reference/runtime.)

See the Kernel concept for the mental model.

L0 kernel — opinion-free primitives: value types, the 4 infra seams, resilience combinators, structured concurrency, and the one middleware contract. Nothing here encodes a policy about how to use an LLM or tool; higher layers (runtime, middlewares, patterns) supply the opinions.

CancellationToken

CancellationToken()

A cooperative cancellation signal shared across a run tree (via RunContext).

A caller/parent calls cancel(); patterns check the token at safe points (loop top, between steps or tool calls) and raise_if_cancelled()Cancelled. This is an abort — distinct from a graceful TerminationCondition (which returns a Stop) and from a resource ceiling (MeterExceeded). Shared down the tree, so cancelling a parent cancels its children.

Cancelled

Bases: RuntimeError

Raised by a cooperative cancellation check when the run (or its subtree) has been cancelled.

Failure dataclass

Failure(
    category: ErrorClass,
    source: str,
    message: str,
    retriable: bool = False,
    cause: BaseException | None = None,
    partial_output: Any = None,
    children: tuple[Failure, ...] = (),
)

A failure as data. category is TRANSIENT/PERMANENT/UNKNOWN; retriable defaults from it.

Frozen — the docstring promises "a value a parent can reason about" and compose_failures aggregates children into an immutable tuple. Mutation of a child Failure observed by two parents would corrupt one parent's view under the other's edits.

of classmethod

of(
    exc: BaseException,
    *,
    source: str,
    partial_output: Any = None,
) -> Failure

Build a Failure from an exception, classifying it.

retriable is True for TRANSIENT and UNKNOWN. run_with_resilience retries both classes; keeping Failure.retriable aligned means a higher layer that reads it directly makes the same decision the kernel's resilience loop would. Conservative on UNKNOWN means "try again" (matches classify's prose: 'UNKNOWN # conservative').

MetricsPort

Bases: Protocol

The metrics seam. Implementations may be sync (OTel SDK does its own async dispatch internally) so the methods are plain def — easier to call from middleware without await overhead.

Operations are best-effort: implementations MUST NOT raise into the run on metric-write failure (log + drop instead).

add_counter

add_counter(
    name: str,
    value: int | float = 1,
    *,
    tags: Mapping[str, str] | None = None,
) -> None

Increment a monotonic counter by value (default 1). Standard names follow OTel semantic conventions: gen_ai.client.request.count, gen_ai.client.error.count.

record_histogram

record_histogram(
    name: str,
    value: int | float,
    *,
    tags: Mapping[str, str] | None = None,
) -> None

Record a single observation in a distribution. Used for token counts, durations, costs. Standard names: gen_ai.client.token.usage, gen_ai.client.operation.duration.

BaseMiddleware

The ergonomic, class-based middleware — the transform / guard / observe layer.

Override only the phases you need. Each phase is async and may be a plain coroutine OR an async generator — both are supported:

on_request(ctx)          # before: mutate ctx.request; raise Blocked to refuse; (yield/emit events)
on_response(ctx, result) # after success: return (or yield) a transformed result; default passes through
on_error(ctx, exc)       # on failure: return/yield a value to recover, else raise (default re-raises)

Emit a product-facing event either by await ctx.emit(...) or by yield-ing an Observation (the async-generator form). on_response returning/yielding a non-Observation value sets the result.

as_middleware() compiles these to the (call, next) primitive, wrapping next in one try/except so on_request is always paired with exactly one on_response OR on_error — a balanced invariant that standalone phase hooks tend to break. This layer cannot re-invoke or rewrite-and-re-run next; retry / fallback / memoize / circuit-breaking are a separate resilience/caching concern written as raw (call, next) middlewares, not a BaseMiddleware.

Blocked

Blocked(reason: str, detail: dict[str, Any] | None = None)

Bases: Exception

Raised by a middleware's on_request to refuse a unit of work before it runs (a guard/policy stop) — e.g. detected prompt injection. Propagates as a typed refusal, distinct from a model/tool fault.

MiddlewareContext

MiddlewareContext(call: Call)

The rich, ergonomic view a middleware sees — a thin facade over the Call + its RunContext, so a middleware reads ctx.messages / ctx.prompt / ctx.budget / ctx.vector rather than digging through call.request and call.ctx. The request is mutable (ctx.request = … rewrites it before the op).

emit async

emit(kind: str, render: str = '', **payload: Any) -> None

Emit a product-facing event on the run's observer stream (never raises into the run).

NoopObserver

Default observer: drops everything. Lets a run emit freely with nothing attached (zero-config).

Observation dataclass

Observation(
    kind: ObservationKind,
    seq: int = 0,
    ts: float = 0.0,
    agent: str = "",
    render: str = "",
    run_id: str = "",
    payload: Any = None,
    parent_id: str | None = None,
    trace_context: TraceContext | None = None,
)

A structured, product-facing record. payload is machine-readable; render is a short human line.

Frozen: an Observation is fanned out to observers (audit sinks, WebSocket forwarders, rollup buffers) that may retain the ref alongside the run's live emit loop. Post-emit mutation of render/payload would corrupt the record every subsequent consumer sees — the same reason TraceContext is frozen.

ObserverPort

Bases: Protocol

Producer side of the channel. Concrete observers add a consumer surface (e.g. stream()).

close() is part of the Protocol. Rollup/buffering observers need a shutdown hook to flush their buffered tail; without it the trailing summary is silently dropped at process exit. NoopObserver implements a no-op — passthrough impls can ignore it entirely and Protocol satisfaction is unchanged.

TracePort

Bases: Protocol

Operational tracing seam. Distinct from ObserverPort (the product-facing observation channel) — tracing records what the framework's middleware/capabilities did, with spans + structured attributes, for debugging and performance analysis. Implementations bridge to OpenTelemetry or structured logging.

span(name, kind, **attrs) is a context manager (sync or async — both forms are supported) that yields a span object with .set(key, value) and .add_event(name, **fields). The default NoopTrace in runtime/context.py does nothing; production wires an adapter.

current_span_id() returns the currently-open span's (trace_id, span_id) for in-process consumers (e.g. ctx.emit) to attach as Observation.trace_context. Returns None when no span is open (cold emit / no tracer configured).

add_event_to_current_span(name, **fields) drops a span event on whatever span is currently open, without the caller having to hold a reference to it. Used by ctx.emit to mirror CRITICAL observations into the trace timeline. No-op when no span is open.

Checkpoint dataclass

Checkpoint(
    run_id: str,
    version: int,
    state: dict[str, Any],
    created_at: float,
    status: CheckpointStatus,
    metadata: dict[str, Any] = dict(),
)

A serialized snapshot of run state at a meaningful transition.

state is opaque to the port — the application (a leaf Agent, a coordinator Agent, an application-defined state snapshot) interprets it. version is monotonic: v1 is the first save for a run, v2 the next, and so on. status is a CheckpointStatus and lets the port surface "is this run resumable?" without re-parsing state. created_at is a unix timestamp stamped by the producer's ClockPort (or time.time() when no clock is wired in) so replay and audit lines up across services.

CheckpointPort

Bases: Protocol

The durable-run-state seam. Distinct from StorePort (a generic KV) because the thing being persisted — a versioned, status-tagged snapshot — has its own access pattern: latest, at-version (time-travel), list-versions, and delete-all-for-run. A single producer is the authority over a run_id; two producers sharing a run_id would collide on version.

CheckpointStatus

Bases: StrEnum

Coarse durability gate for a Checkpoint. Auto-resume keys off this — a suspended checkpoint says "waiting on a human"; running says "engine is in motion"; done / failed are terminal. Producers map their domain phases (e.g., the api's RunPhase) onto this taxonomy at snapshot time.

Emission map (grep-able index — keep aligned with call sites):

  • RUNNING — the default snapshot status at every mid-run durable transition (Checkpointer.snapshot(...) with no explicit status). Set by the ReAct cognition after each tool-loop iteration and by coordinator policies at each turn.
  • SUSPENDED — emitted by the ReAct cognition's human-approval path when a gated tool call needs a human decision (agentkit/agents/cognition/react.py — the _save(..., status="suspended", pending=...) call site). The suspended snapshot carries the pending tool calls so resume() can apply per-call decisions. StrEnum equivalence means the string "suspended" at the emission site IS CheckpointStatus.SUSPENDED when the adapter round-trips it back (see tests/adapters/test_postgres_checkpoint.py for the wire-level round-trip). Coordinators + custom producers MUST use this status (not RUNNING) when persisting a human-gate wait — auto-resume relies on it to distinguish "engine is chugging" from "waiting on the world".
  • DONE / FAILED — emitted by the producer at terminal transitions; Checkpointer.resume filters these by default so a "resume if any checkpoint exists" wiring cannot silently re-run a finished job.

is_terminal

is_terminal() -> bool

True if no further work is expected — auto-resume should NOT fire on a done or failed checkpoint.

ClockPort

Bases: Protocol

Deterministic time seam — tests and replay need a fakeable wall clock. now() returns a unix timestamp; sleep(s) waits without blocking the loop. Use this instead of time.time() / datetime.now() / asyncio.sleep inside middlewares + retry/backoff loops.

FetchResponse dataclass

FetchResponse(
    url: str,
    status: int,
    headers: dict[str, str],
    body: str,
    content_type: str,
    fetched_at: float,
)

A text HTTP response. Binary fetch is out of scope — callers that need bytes use a different port. fetched_at is a unix timestamp from the implementation's ClockPort (real or fake).

SearchHit dataclass

SearchHit(
    url: str,
    title: str,
    snippet: str,
    score: float | None = None,
    metadata: dict[str, Any] = dict(),
)

One web-search result — every SearchPort adapter normalizes to this shape so callers (Researcher agents, retrieval middlewares) are provider-agnostic.

StorePort

Bases: Protocol

The single KV seam that backs the former cache / idempotency / checkpoint / audit ports. get_or_set is single-flight; a producer that raises is NOT stored (failures are never cached).

ReplayRecord dataclass

ReplayRecord(
    span_id: str,
    operation: str,
    request: Any,
    response: Any | None = None,
)

One LLM-call replay payload. span_id ties it to the trace so the trace acts as the index and the store carries the bulk.

operation is the unit of work — "chat" for an LLM turn, "execute_tool" for a tool execution — matching the OTel GenAI operation taxonomy span names. request is the input shape (messages, tools, response_format for chat; arguments for tools). response is the assembled LLMResult or tool output; None when the call is still in flight or errored before producing one.

ReplayStore

Bases: Protocol

Optional side-channel store for full LLM call replay.

Implementations are expected to be best-effort: a failed write must NOT break the run. Adapters wrap their writes in suppress blocks at the call site (see middlewares/tracing.py). The default NoopReplayStore drops writes silently — the canonical zero-config path.

CircuitBreaker dataclass

CircuitBreaker(
    name: str,
    fail_threshold: int = 5,
    cooldown: float = 15.0,
    clock: Callable[[], float] = monotonic,
    state: BreakerState = "closed",
    _fails: int = 0,
    _opened_at: float = 0.0,
)

Per-dependency breaker. CLOSED → (fail_threshold consecutive fails) → OPEN → (cooldown) → HALF_OPEN → one probe → CLOSED on success / OPEN on failure.

CircuitOpen

Bases: RuntimeError

Raised when a per-dependency breaker is OPEN (classified TRANSIENT → caller may degrade).

SamplerPort

Bases: Protocol

Decides whether a span should be recorded.

Called by the tracing middleware BEFORE opening the span; when it returns False no span is opened, no attributes stamped, no replay record written. The whole observability pipeline short-circuits for that call.

Implementations should be FAST (microseconds) — they're on the hot path. A 1ms sampler erases its own value.

should_sample

should_sample(
    *,
    operation: str,
    correlation_id: str,
    attrs: Mapping[str, str | int | float] | None = None,
) -> bool

Return True to record the span, False to skip.

operation is the span name ("chat", "execute_tool", "invoke_agent", ...). correlation_id is the run id — ratio-based samplers hash it so all spans in one run share the decision. attrs is the request-time attribute set (useful for rule-based sampling — "always sample errors," "always sample org X").

TraceIdRatioSampler

TraceIdRatioSampler(ratio: float)

Sample a deterministic ratio of runs.

Hashes correlation_id modulo a fixed denominator; keeps the span if the hash falls below ratio * denominator. Result: every span within the SAME run shares the keep/drop decision, so traces are coherent (no orphan child spans). Across runs, you get the configured ratio in aggregate.

Ratio 0.0 = drop everything; 1.0 = keep everything (same as AlwaysOn).

ChatRequest dataclass

ChatRequest(
    messages: list[Message],
    model: str,
    tools: list[ToolSchema] | None = None,
    response_format: dict[str, Any] | None = None,
    temperature: float = 0.0,
    max_tokens: int | None = None,
    cache_hint: Any = None,
)

Frozen value type. To rewrite a field mid-chain (fallback swaps the model, compaction rewrites messages), build a new instance via dataclasses.replace and reassign call.request — the Call reference is mutable; the ChatRequest it points at is not.

Chunk dataclass

Chunk(
    id: str, text: str, metadata: dict[str, Any] = dict()
)

A retrievable unit for semantic memory / RAG (metadata carries source + scope tags).

Delta dataclass

Delta(
    text: str = "",
    model: str | None = None,
    usage: Usage | None = None,
    provider: str | None = None,
    finish_reason: str | None = None,
    tool_calls: tuple[ToolCall, ...] = (),
    parsed: Any = None,
    partial: Any = None,
)

One streamed increment of an LLM response — the item the single stream() primitive yields. A text fragment and/or tool_calls; the terminal delta carries usage/finish_reason/ model/provider. assemble_deltas() reduces a sequence back to an LLMResult, so a full chat result is just the collected stream.

Message dataclass

Message(
    role: str,
    content: str = "",
    name: str | None = None,
    tool_call_id: str | None = None,
    tool_calls: tuple[ToolCall, ...] = (),
)

One turn in a chat transcript.

Operation

Bases: StrEnum

The kind of unit of work a middleware is intercepting — match on it in a middleware (if ctx.operation == Operation.MODEL_CALL: …). A str enum, so == "model_call" also holds.

Scope dataclass

Scope(
    org_id: int | None = None, domain_id: int | None = None
)

Tenant-isolation key. Threaded through every memory recall / cache key / meter / callback.

StreamEvent dataclass

StreamEvent(
    type: StreamEventType,
    text: str = "",
    tool_call: Any = None,
    tool_result: Any = None,
    result: Any = None,
    usage: Usage | None = None,
)

A pattern-level run event emitted by Agent.stream/*.stream — a higher-level signal than a Delta: where Delta is one transport increment of a single LLM response, a StreamEvent marks a step in the run (a token message_delta, a tool_call/tool_result, an interrupt, the final result). type discriminates the payload.

ToolCall dataclass

ToolCall(
    id: str, name: str, arguments: dict[str, Any] = dict()
)

A tool invocation the model requested. id is echoed back on the matching tool-result message.

arguments is exposed as an immutable MappingProxyType: the ToolCall flows into the ReAct approval snapshot AND into the idempotency-key hash AND into the audit trail — a tool impl that did args.pop("token") would have desynced all three. Callers that need to mutate copy first: dict(tc.arguments).

MappingProxyType is not natively copy.deepcopy-safe (stdlib limitation — the view can't be pickled), which matters because Checkpointer.snapshot deep-copies state that contains ToolCalls. __deepcopy__ / __copy__ unwrap the view into a plain dict at copy time; the fresh ToolCall re-wraps it via __post_init__.

ToolSchema dataclass

ToolSchema(
    name: str,
    description: str = "",
    parameters: dict[str, Any] = dict(),
)

JSON-schema advertisement of a tool — what the provider needs to see to call it.

gather_best_effort async

gather_best_effort(
    coros: list[Awaitable[R]], *, sem: Semaphore
) -> list[R | Failure]

Best-effort fan-out: bound concurrency, isolate failures (each slot is a result OR a :class:~agentkit.kernel.errors.Failure wrapping the raised exception). Use when one sub-agent/tool failing must NOT cancel the others.

Wrapping the exception in a Failure (rather than returning the raw exception object) lets a parent treat the failed slot as first-class data: it carries a classified category, a source naming the slot (gather_best_effort[i]), and the originating cause, so a caller can retry / route around / escalate uniformly. It also disambiguates "the coroutine returned an Exception value" from "the coroutine raised" — the raw- exception design conflated the two.

gather_bounded async

gather_bounded(
    coros: list[Awaitable[R]], *, sem: Semaphore
) -> list[R]

Run coroutines concurrently, at most sem._value at once, preserving input order. A failure cancels the rest (structured concurrency) and surfaces as an ExceptionGroup.

run_agents async

run_agents(
    pairs: list[tuple[Any, Any]],
    ctx: Any,
    *,
    best_effort: bool = False,
) -> list[Any]

Run [(agent, task), …] concurrently — each under ctx.child(), bounded by the tree semaphore.

Returns AgentResults in input order. In best_effort mode a failed slot holds a :class:~agentkit.kernel.errors.Failure (with the raised exception on .cause), not the raw exception, so callers can inspect it as first-class data. The shared budget accrues across all of them; depth/concurrency caps apply tree-wide.

Two opt-in coordination seams engage automatically when the caller wired them onto ctx:

  • ActorBudget slicing — when ctx.actor_budget is set, carve one slice per child (1/N of each axis of the parent's remaining) via reserve_for_child BEFORE dispatch. Each child's ctx sees its OWN ActorBudget (with the slice as max_*), and after every child returns we settle_child on the parent's book so the parent's used_* reflects what was actually spent (capped at reservation). If any reservation would exceed the parent's cap, we release the already-reserved siblings and re-raise BudgetExhausted before any child runs — fail-fast semantics.
  • SignalChannel attach — when the coordinator's ctx has signal_channel set AND the child Agent has its own channel field, we call child.channel.attach_parent(ctx.signal_channel.merge_inbox) so DataSignals emitted by the child fan up to the coordinator's merge inbox without polling.

Both features are strict no-ops when the caller doesn't opt in. Missing fields on structural stubs (NullCtx) are tolerated via getattr.

run_sync

run_sync(coro: Coroutine[Any, Any, R]) -> R

The single sync bridge for sync hosts to drive async agentkit.

If no event loop is running in this thread → asyncio.run(coro). If a loop IS already running (e.g. nested inside an async caller), spin up a fresh worker thread with its own loop and run the coroutine there to completion — avoids "loop already running" while keeping a blocking signature.

compose_failures

compose_failures(
    failures: Sequence[Failure | None],
    *,
    source: str = "composite",
) -> Failure | None

Aggregate child failures into one (or None if there are none; passthrough a single one).

Category rule: PERMANENT if any child is permanent; TRANSIENT only if all are transient; else UNKNOWN. The aggregate is retriable for any non-PERMANENT category, matching Failure.of and run_with_resilience. Children are preserved.

chain

chain(
    middlewares: list[Middleware | BaseMiddleware],
    terminal: Handler,
) -> Handler

Fold the chain right so middlewares[0] is outermost: m0(m1(… terminal)).

Accepts raw Middleware functions and/or BaseMiddleware instances (adapted via as_middleware()), so the two styles mix freely in one chain. Each is an async generator over the single streaming contract; the terminal is the seam stream (llm.stream(**req) / a one-item tool stream). An empty list returns the terminal unchanged.

collect async

collect(
    stream: AsyncIterator[Any], kind: str = "chat"
) -> Any

Reduce a stream to the operation's result — a chat result is just its collected stream. A chat assembles its Deltas into an LLMResult; any other op returns its single (last) yielded item.

collect_one async

collect_one(stream: AsyncIterator[Any]) -> Any

The single item of a one-item (tool) stream.

backoff_delay

backoff_delay(
    attempt: int,
    *,
    base: float = 0.5,
    cap: float = 30.0,
    rng: Any = random,
) -> float

Full-jitter exponential backoff (prevents retry storms across workers).

classify

classify(exc: BaseException) -> ErrorClass

Substring-classify an exception as TRANSIENT / PERMANENT / UNKNOWN.

TRANSIENT wins on collision. Alternative orderings that checked PERMANENT first, so ValidationError("request timed out") matched "validation" in the PERMANENT list and never got retried — exactly the failure mode the resilience layer exists to handle. Two reasons TRANSIENT must win:

  1. TRANSIENT signals (timeout, 5xx, rate limit) are inherently more specific than generic PERMANENT substrings ("invalid", "validation") that frequently appear in transient infra errors.
  2. The conservative choice on collision is to retry — wasting one retry on a permanent error costs the backoff delay; failing fast on a transient error costs the whole call.

Substring matching is brittle by nature; for stronger guarantees, callers should pass a custom classify_fn to run_with_resilience.

run_with_resilience async

run_with_resilience(
    fn: Callable[[], Any],
    *,
    breaker: CircuitBreaker | None = None,
    max_attempts: int = 3,
    classify_fn: Callable[
        [BaseException], ErrorClass
    ] = classify,
    sleep: Callable[[float], Any] | None = None,
    rng: Any = random,
) -> Any

Run async fn with classification + jittered retry + optional circuit breaker (async-first; the one resilience entry point). await fn(); backoff via await asyncio.sleep (injectable async sleep for deterministic tests).

PERMANENT errors fail fast; TRANSIENT/UNKNOWN retry up to max_attempts; an OPEN breaker raises CircuitOpen (itself TRANSIENT — the caller decides degrade vs propagate).

assemble_deltas

assemble_deltas(deltas: list[Delta]) -> LLMResult

Pure reducer: fold streamed Deltas into the final LLMResult (content concatenated; usage / finish_reason / model / provider / tool_calls / parsed taken from the deltas that carry them).