Skip to content

Domain adapters

Concrete Port implementations: LLM providers (Claude, OpenAI, DeepSeek, OpenRouter), vector / store / checkpoint back-ends, observer + replay tooling, and the OTel bridge.

Most adapters are behind an opt-in extra (http, postgres, redis, observability) so the zero-dep core stays clean.

There is no separate adapter aggregate package. Each implementation lives with its model, state or integration domain. Reference its owning package directly:

LLMPort adapters — CallableLLM (inject any provider).

Model fallover is no longer an adapter — it's the fallback middleware. These adapters just wrap a single provider seam. CallableLLM adapts an injected fn/chat_fn (e.g. the OpenRouter ModelSDKTools). The raw↔LLMResult translation helpers live in _mapping. The public surface is agentkit.models.

For the offline, deterministic FakeLLM/Turn test double, import from agentkit.core.testing — real adapters live in adapters/, test doubles in testing/.

model_registry is the from-configuration layer sitting ABOVE the explicit provider factories: it maps a model name to a provider, reads the credential from the environment, and declares per-model capabilities so a mismatch is refused at bind time. Importing it costs nothing — every provider factory is loaded lazily by dotted path, so the registry stays usable on a zero-dependency install.

CallableLLM

CallableLLM(
    fn: Callable[..., Any],
    *,
    to_result: Callable[[Any], LLMResult] | None = None,
    chat_fn: Callable[..., Any] | None = None,
    cost_fn: Callable[[Any, int, int], float | None]
    | None = None,
)

Adapt fn(*, system, user, model, …) (single call) and optionally chat_fn(*, messages, model, tools, …) (tool-aware) into an LLMPort. Sync fns run off the loop via asyncio.to_thread; coroutines are awaited. Provider tool-calls are normalized to ToolCall.

Capability

Bases: StrEnum

Tri-state, because two-state is what causes the bug.

A bool forces every unrecognised model into either "has it" (silent wrong answers) or "doesn't" (refuses everything custom). A bool | None is worse than either, because if caps.vision: silently reads None as absent and the type system won't stop you. A three-valued enum makes the third case impossible to ignore at the read site.

CapabilityMismatch

Bases: RegistryError

A model was bound to a role needing a capability it does not have.

Raised at construction / first bind, never on the call — the entire value is catching it before money is spent.

MissingProviderExtra

Bases: RegistryError

The provider's optional dependency isn't installed.

Deliberately fatal rather than a downgrade: "your transport is missing" is a wiring bug the operator must see, and quietly serving a fake instead is precisely the silent failure this module exists to prevent.

ModelCapabilities dataclass

ModelCapabilities(
    tools: Capability = UNKNOWN,
    structured_output: Capability = UNKNOWN,
    native_json_schema: Capability = UNKNOWN,
    streaming: Capability = UNKNOWN,
    vision: Capability = UNKNOWN,
    context_window: int | None = None,
)

What one model can do. Every field defaults to UNKNOWN so a partially-filled entry never over-claims.

vision is declared but not yet checkable end-to-end: Message.content is a str and both provider adapters map it to text only, so there is no multimodal input path to mismatch against today. It is here so the declaration is ready when that path lands, and so an application that routes images outside the framework can still consult it.

tools class-attribute instance-attribute

tools: Capability = Capability.UNKNOWN

Provider-native tool/function calling (what ReActCognition needs).

structured_output class-attribute instance-attribute

structured_output: Capability = Capability.UNKNOWN

Can be steered to emit parseable JSON at all — via native mode OR the prompt-injection fallback. Practically every instruct model: NO means a completion-only model that cannot follow the schema block.

native_json_schema class-attribute instance-attribute

native_json_schema: Capability = Capability.UNKNOWN

Accepts a provider-native response_format={"type": "json_schema"}. Distinct from structured_output: this is the strict server-side mode, not the prompt fallback. Read by _infer_response_format.

streaming class-attribute instance-attribute

streaming: Capability = Capability.UNKNOWN

Server-sent incremental deltas (as opposed to one-shot completion).

vision class-attribute instance-attribute

vision: Capability = Capability.UNKNOWN

Accepts image input. See the class docstring on why this is forward-looking.

context_window class-attribute instance-attribute

context_window: int | None = None

Total context in tokens, or None when unknown. Lets compaction size itself against a real number instead of a guess.

all_unknown property

all_unknown: bool

True when nothing at all is declared — i.e. this model is not in the registry and the object is a placeholder.

get

get(name: str) -> Capability

Look one capability up by name. An unrecognised NAME is a programming error (a typo in requires=) and raises immediately, rather than degrading into UNKNOWN and silently never checking anything.

ModelEntry dataclass

ModelEntry(
    name: str,
    provider: str,
    capabilities: ModelCapabilities = ModelCapabilities(),
)

One model: who serves it, and what it can do.

ModelRegistry

ModelRegistry()

Model/provider knowledge, and the two operations that read it: :meth:resolve (name → wired LLMPort) and :meth:check (name + requirements → refuse or pass).

Instantiable so tests get an isolated table; the process-wide default is :func:registry.

register_provider

register_provider(entry: ProviderEntry) -> ModelRegistry

Add or replace a provider. Returns self so registration chains.

register_model

register_model(entry: ModelEntry) -> ModelRegistry

Add or replace a model row.

The provider is NOT validated here: registration order shouldn't matter, and an application may legitimately declare capabilities for a model whose provider it registers later (or never, if it only ever calls :meth:check).

register_rule

register_rule(rule: Rule) -> ModelRegistry

Add a name→provider rule, consulted when no ModelEntry matches.

The most-recently-added rule wins: the built-in rules act as the fallback, and an application's rule (registered after them) takes precedence. A rule returns None to abstain.

reg.register_rule(lambda n: "openai" if n.startswith("acme-") else None)

entry_for

entry_for(model: str) -> ModelEntry | None

The registered row for a model, trying each normalised candidate.

provider_for

provider_for(model: str) -> str | None

Which provider serves this model, or None if nothing claims it.

A registered ModelEntry wins over any rule — an explicit row is always more authoritative than a pattern.

Rules are the OUTER loop, most-recently-registered first: each rule sees EVERY normalised candidate before the next rule is consulted. Iterating candidates outermost meant the first candidate decided the answer, so the coarsest rule that matched the raw name won and the later, more specific rules never saw the bare name at all — measured: provider_for('anthropic/claude-sonnet-9') returned openrouter because by_openrouter_prefix matched the slash before by_family was offered claude-sonnet-9, contradicting both register_rule's promise and _builtin_rules' own comment. Reversing the rule order is the short-circuiting form of "run every rule, last non-None wins", which is what :meth:register_rule documents.

provider_entry

provider_entry(name: str) -> ProviderEntry | None

The registered row for a provider, or None. Lets a caller read a provider's default_model / env_vars without reaching into the registry's internals.

capabilities

capabilities(model: str) -> ModelCapabilities

What this model can do. An unregistered model yields an all-UNKNOWN placeholder — never a guess in either direction.

describe

describe() -> str

A human-readable inventory, used in error messages so a refusal tells the operator what they COULD have asked for.

check

check(
    model: str,
    requires: tuple[str, ...] = (),
    *,
    derived: tuple[str, ...] = (),
    min_context_window: int | None = None,
    on_unknown: str = "warn",
    subject: str = "",
) -> None

Refuse a capability mismatch, or return silently.

requires names capabilities from :data:CAPABILITY_NAMES that the caller DECLARED. derived names ones the framework INFERRED from wiring (a tool-holding cognition implies tools). Both raise on a declared :attr:Capability.NO — that is an unambiguous mismatch and no policy makes it acceptable — but only requires participates in the UNKNOWN policy below.

That asymmetry is the point. A derived requirement is the framework's own inference, so nagging about it 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 requires= warnings go unread too. Derived requirements therefore stay silent on UNKNOWN and speak only when a model has been explicitly declared incapable.

:attr:Capability.UNKNOWN on a DECLARED requirement is governed by on_unknown:

  • "warn" (default) — emit a UserWarning and continue. This is the default because refusing on unknown would break every self-hosted, fine-tuned, or newly-released model name the moment this module shipped, and the briefs require existing wiring to keep working.
  • "refuse" — raise. The right setting for a production service that pins its models; makes "we don't know" a deployment-time stop.
  • "allow" — say nothing. For a caller that has verified out of band.

subject names the thing being bound (an agent name), so the message points at the wiring rather than at the framework.

resolve

resolve(
    model: str | None = None,
    *,
    provider: str | None = None,
    api_key: str | None = None,
    fallback: str | None = None,
    env: Mapping[str, str] | None = None,
) -> Any

Build an LLMPort for model (or an explicit provider), reading credentials from the environment.

llm = resolve("claude-sonnet-4-6")          # reads ANTHROPIC_API_KEY
llm = resolve(provider="openai")            # reads OPENAI_API_KEY
llm = resolve("gpt-4o", fallback="fake")    # degrade, loudly, if unkeyed

fallback is OPT-IN and defaults to None (raise). This deliberately differs from "quietly return the fake and warn": in a server process a UserWarning goes to a log nobody reads, and the result is fabricated completions served as real ones. Making the caller type fallback="fake" keeps the escape hatch one keystroke away for a notebook while refusing to make it the accidental default for a deployment. When it IS requested, the downgrade warns exactly once per registry.

env is injectable so tests never mutate os.environ.

A missing optional extra raises :class:MissingProviderExtra and is NEVER absorbed into the fallback — a broken install must not masquerade as a missing credential.

ProviderEntry dataclass

ProviderEntry(
    name: str,
    env_vars: tuple[str, ...],
    factory: str,
    extra: str = "http",
    default_model: str | None = None,
    accepts_model_kwarg: bool = True,
)

One provider: how to authenticate it and how to build its client.

factory is a dotted module:attr string rather than a callable so that registering a provider costs NO import. agentkit.models.providers raises ImportError at module load when the http extra is absent — importing it eagerly here would make the whole registry unimportable on a zero-dependency install, which is the opposite of what it is for.

ProviderNotConfigured

Bases: RegistryError

A provider was selected but no credential was found in the environment.

The message names the environment variables that were CHECKED — never their values, and never a partial/masked value either. A masked credential is still a credential leak's first half.

RegistryError

Bases: AgentkitError

Base for every failure this module raises — one except clause covers the whole bootstrap.

UnknownModel

Bases: RegistryError

No registered provider claims this model name.

Raised instead of a KeyError so the message can name what IS registered; a bare KeyError('gpt-5') tells the caller nothing about how to fix their config.

CostSource

Bases: StrEnum

Provenance for known monetary measurements; unknown cost is never zero.

Credential dataclass

Credential(
    env: Mapping[str, str] = dict(),
    home: Mapping[str, bytes] = dict(),
    on_changed: Callable[
        [Mapping[str, bytes]], Awaitable[None]
    ]
    | None = None,
)

Resolved host credentials. Home files are installed only by allowlisted CLI adapters.

CredentialRef dataclass

CredentialRef(resolve: CredentialResolver)

Host-owned sync or async credential resolver; secrets are excluded from repr.

Workspace dataclass

Workspace(
    working_dir: Path | str | None = None,
    add_dirs: tuple[Path | str, ...] = (),
    permission_mode: ClaudePermissionMode | None = None,
    native_tools: tuple[str, ...] = (),
    hooks: Mapping[str, Any] | None = None,
    sandbox: CodexSandboxMode | None = None,
)

Per-call CLI filesystem and native-tool authority.

default_registry

default_registry() -> ModelRegistry

A freshly-populated registry with the built-in table. Each call returns an INDEPENDENT instance — tests take one of these instead of mutating the process-wide default.

model_capabilities

model_capabilities(model: str) -> ModelCapabilities

:meth:ModelRegistry.capabilities against the process-wide registry.

normalize_model_name

normalize_model_name(model: str) -> tuple[str, ...]

Candidate lookup keys for a model name, most specific first.

("anthropic/claude-sonnet-4-6-20250101") → ("anthropic/claude-sonnet-4-6-20250101", "claude-sonnet-4-6-20250101", "anthropic/claude-sonnet-4-6", "claude-sonnet-4-6")

Deliberately mirrors the candidate ORDER in adapters/llm/providers/pricing.py::_lookup — exact, then strip an OpenRouter-style provider/ prefix, then strip the trailing date, then both. It is a separate function rather than a shared import because the pricing table is owned elsewhere and stays untouched; the cost of the duplication is one drift risk, which tests/source/unit/models/adapters/test_model_registry.py::test_normalisation_agrees_with_pricing_lookup pins by asserting both resolve the same names.

Returned as a de-duplicated tuple so callers can just iterate.

register_model

register_model(entry: ModelEntry) -> None

Declare (or override) a model row on the process-wide registry.

register_provider

register_provider(entry: ProviderEntry) -> None

Declare (or override) a provider on the process-wide registry.

register_rule

register_rule(rule: Rule) -> None

Add a name→provider rule to the process-wide registry.

registry

registry() -> ModelRegistry

The process-wide registry. Built on first use, then shared — so an application's register_model / register_rule calls at startup are visible to every later Agent construction.

require_capabilities

require_capabilities(
    model: str,
    requires: tuple[str, ...] = (),
    *,
    derived: tuple[str, ...] = (),
    min_context_window: int | None = None,
    on_unknown: str = "warn",
    subject: str = "",
) -> None

:meth:ModelRegistry.check against the process-wide registry.

resolve_llm

resolve_llm(
    model: str | None = None,
    *,
    provider: str | None = None,
    api_key: str | None = None,
    fallback: str | None = None,
    env: Mapping[str, str] | None = None,
) -> Any

:meth:ModelRegistry.resolve against the process-wide registry.

compile_schema

compile_schema(
    schema: dict[str, Any], mode: Mode, model: str = ""
) -> dict[str, Any]

Compile a canonical 2020-12 schema to a transport dialect.

Model-specific schema extensions are deliberately conservative until a live probe establishes support. model is retained for future profiles.

validate_output

validate_output(value: Any, schema: dict[str, Any]) -> Any

Clean and validate a response against the canonical schema.

validate_schema

validate_schema(schema: dict[str, Any]) -> None

Check the canonical schema before dispatch; validation dependencies are mandatory.

Model registry

The from-configuration layer above the explicit provider factories: model name → provider → wired LLMPort (credential from the environment), plus per-model capability declaration so a mismatch is refused at bind time rather than surfacing as a plausible empty answer. See the recipe for the mental model.

Model registry — resolve a model name to a wired LLMPort, and refuse a capability mismatch before any spend.

Two problems, one object. They are the same problem wearing different hats:

The last mile (Brief 3). agentkit.models.client.chat ships claude() / openai() / deepseek() / openrouter(), and every one of them requires an explicit api_key=. The package read no provider environment variable anywhere. So every application wrote the same bootstrap — read the key, pick the provider, handle the optional-extra ImportError, fall back without failing silently. In one production codebase that was ~710 lines and the only domain-free code in its engine.

The silent capability failure (Brief 5). 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. So this module keeps ONE table: a :class:ModelEntry carries the provider that serves it and the capabilities it declares, and a :class:ProviderEntry carries the environment variable and the (lazily imported) factory. Resolution and capability checking read the same rows.

Design commitments, each of which is load-bearing:

  • Nothing is guessed. An unregistered model reports every capability as :attr:Capability.UNKNOWN, never False and never True. A framework that guesses True reintroduces the exact silent failure it exists to catch; one that guesses False refuses every self-hosted model on day one. UNKNOWN is the honest third answer, and what to DO about it is the caller's policy (on_unknown=).
  • A missing extra degrades loudly. If httpx isn't installed we raise :class:MissingProviderExtra naming the extra. We never silently fall through to the fake — a fake serving production traffic is the worst outcome in this module's problem space.
  • Fallback is explicit. resolve() with no credentials raises unless the caller passed fallback=. See :meth:ModelRegistry.resolve for why this deliberately differs from "warn and downgrade".
  • A credential never appears in a message. Not in an error, not in a warning, not in repr. Pinned by test.
  • Rules over a prefix table. register_rule lets an application map its own naming convention onto a provider without forking a hardcoded prefix list.

This layer sits ABOVE the explicit factories and does not replace them. providers.claude(api_key=...) keeps working untouched; this is the from-configuration path for callers who don't want to name a provider in code.

RegistryError

Bases: AgentkitError

Base for every failure this module raises — one except clause covers the whole bootstrap.

UnknownModel

Bases: RegistryError

No registered provider claims this model name.

Raised instead of a KeyError so the message can name what IS registered; a bare KeyError('gpt-5') tells the caller nothing about how to fix their config.

ProviderNotConfigured

Bases: RegistryError

A provider was selected but no credential was found in the environment.

The message names the environment variables that were CHECKED — never their values, and never a partial/masked value either. A masked credential is still a credential leak's first half.

MissingProviderExtra

Bases: RegistryError

The provider's optional dependency isn't installed.

Deliberately fatal rather than a downgrade: "your transport is missing" is a wiring bug the operator must see, and quietly serving a fake instead is precisely the silent failure this module exists to prevent.

CapabilityMismatch

Bases: RegistryError

A model was bound to a role needing a capability it does not have.

Raised at construction / first bind, never on the call — the entire value is catching it before money is spent.

Capability

Bases: StrEnum

Tri-state, because two-state is what causes the bug.

A bool forces every unrecognised model into either "has it" (silent wrong answers) or "doesn't" (refuses everything custom). A bool | None is worse than either, because if caps.vision: silently reads None as absent and the type system won't stop you. A three-valued enum makes the third case impossible to ignore at the read site.

ModelCapabilities dataclass

ModelCapabilities(
    tools: Capability = UNKNOWN,
    structured_output: Capability = UNKNOWN,
    native_json_schema: Capability = UNKNOWN,
    streaming: Capability = UNKNOWN,
    vision: Capability = UNKNOWN,
    context_window: int | None = None,
)

What one model can do. Every field defaults to UNKNOWN so a partially-filled entry never over-claims.

vision is declared but not yet checkable end-to-end: Message.content is a str and both provider adapters map it to text only, so there is no multimodal input path to mismatch against today. It is here so the declaration is ready when that path lands, and so an application that routes images outside the framework can still consult it.

tools class-attribute instance-attribute

tools: Capability = Capability.UNKNOWN

Provider-native tool/function calling (what ReActCognition needs).

structured_output class-attribute instance-attribute

structured_output: Capability = Capability.UNKNOWN

Can be steered to emit parseable JSON at all — via native mode OR the prompt-injection fallback. Practically every instruct model: NO means a completion-only model that cannot follow the schema block.

native_json_schema class-attribute instance-attribute

native_json_schema: Capability = Capability.UNKNOWN

Accepts a provider-native response_format={"type": "json_schema"}. Distinct from structured_output: this is the strict server-side mode, not the prompt fallback. Read by _infer_response_format.

streaming class-attribute instance-attribute

streaming: Capability = Capability.UNKNOWN

Server-sent incremental deltas (as opposed to one-shot completion).

vision class-attribute instance-attribute

vision: Capability = Capability.UNKNOWN

Accepts image input. See the class docstring on why this is forward-looking.

context_window class-attribute instance-attribute

context_window: int | None = None

Total context in tokens, or None when unknown. Lets compaction size itself against a real number instead of a guess.

all_unknown property

all_unknown: bool

True when nothing at all is declared — i.e. this model is not in the registry and the object is a placeholder.

get

get(name: str) -> Capability

Look one capability up by name. An unrecognised NAME is a programming error (a typo in requires=) and raises immediately, rather than degrading into UNKNOWN and silently never checking anything.

ModelEntry dataclass

ModelEntry(
    name: str,
    provider: str,
    capabilities: ModelCapabilities = ModelCapabilities(),
)

One model: who serves it, and what it can do.

ProviderEntry dataclass

ProviderEntry(
    name: str,
    env_vars: tuple[str, ...],
    factory: str,
    extra: str = "http",
    default_model: str | None = None,
    accepts_model_kwarg: bool = True,
)

One provider: how to authenticate it and how to build its client.

factory is a dotted module:attr string rather than a callable so that registering a provider costs NO import. agentkit.models.providers raises ImportError at module load when the http extra is absent — importing it eagerly here would make the whole registry unimportable on a zero-dependency install, which is the opposite of what it is for.

ModelRegistry

ModelRegistry()

Model/provider knowledge, and the two operations that read it: :meth:resolve (name → wired LLMPort) and :meth:check (name + requirements → refuse or pass).

Instantiable so tests get an isolated table; the process-wide default is :func:registry.

register_provider

register_provider(entry: ProviderEntry) -> ModelRegistry

Add or replace a provider. Returns self so registration chains.

register_model

register_model(entry: ModelEntry) -> ModelRegistry

Add or replace a model row.

The provider is NOT validated here: registration order shouldn't matter, and an application may legitimately declare capabilities for a model whose provider it registers later (or never, if it only ever calls :meth:check).

register_rule

register_rule(rule: Rule) -> ModelRegistry

Add a name→provider rule, consulted when no ModelEntry matches.

The most-recently-added rule wins: the built-in rules act as the fallback, and an application's rule (registered after them) takes precedence. A rule returns None to abstain.

reg.register_rule(lambda n: "openai" if n.startswith("acme-") else None)

entry_for

entry_for(model: str) -> ModelEntry | None

The registered row for a model, trying each normalised candidate.

provider_for

provider_for(model: str) -> str | None

Which provider serves this model, or None if nothing claims it.

A registered ModelEntry wins over any rule — an explicit row is always more authoritative than a pattern.

Rules are the OUTER loop, most-recently-registered first: each rule sees EVERY normalised candidate before the next rule is consulted. Iterating candidates outermost meant the first candidate decided the answer, so the coarsest rule that matched the raw name won and the later, more specific rules never saw the bare name at all — measured: provider_for('anthropic/claude-sonnet-9') returned openrouter because by_openrouter_prefix matched the slash before by_family was offered claude-sonnet-9, contradicting both register_rule's promise and _builtin_rules' own comment. Reversing the rule order is the short-circuiting form of "run every rule, last non-None wins", which is what :meth:register_rule documents.

provider_entry

provider_entry(name: str) -> ProviderEntry | None

The registered row for a provider, or None. Lets a caller read a provider's default_model / env_vars without reaching into the registry's internals.

capabilities

capabilities(model: str) -> ModelCapabilities

What this model can do. An unregistered model yields an all-UNKNOWN placeholder — never a guess in either direction.

describe

describe() -> str

A human-readable inventory, used in error messages so a refusal tells the operator what they COULD have asked for.

check

check(
    model: str,
    requires: tuple[str, ...] = (),
    *,
    derived: tuple[str, ...] = (),
    min_context_window: int | None = None,
    on_unknown: str = "warn",
    subject: str = "",
) -> None

Refuse a capability mismatch, or return silently.

requires names capabilities from :data:CAPABILITY_NAMES that the caller DECLARED. derived names ones the framework INFERRED from wiring (a tool-holding cognition implies tools). Both raise on a declared :attr:Capability.NO — that is an unambiguous mismatch and no policy makes it acceptable — but only requires participates in the UNKNOWN policy below.

That asymmetry is the point. A derived requirement is the framework's own inference, so nagging about it 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 requires= warnings go unread too. Derived requirements therefore stay silent on UNKNOWN and speak only when a model has been explicitly declared incapable.

:attr:Capability.UNKNOWN on a DECLARED requirement is governed by on_unknown:

  • "warn" (default) — emit a UserWarning and continue. This is the default because refusing on unknown would break every self-hosted, fine-tuned, or newly-released model name the moment this module shipped, and the briefs require existing wiring to keep working.
  • "refuse" — raise. The right setting for a production service that pins its models; makes "we don't know" a deployment-time stop.
  • "allow" — say nothing. For a caller that has verified out of band.

subject names the thing being bound (an agent name), so the message points at the wiring rather than at the framework.

resolve

resolve(
    model: str | None = None,
    *,
    provider: str | None = None,
    api_key: str | None = None,
    fallback: str | None = None,
    env: Mapping[str, str] | None = None,
) -> Any

Build an LLMPort for model (or an explicit provider), reading credentials from the environment.

llm = resolve("claude-sonnet-4-6")          # reads ANTHROPIC_API_KEY
llm = resolve(provider="openai")            # reads OPENAI_API_KEY
llm = resolve("gpt-4o", fallback="fake")    # degrade, loudly, if unkeyed

fallback is OPT-IN and defaults to None (raise). This deliberately differs from "quietly return the fake and warn": in a server process a UserWarning goes to a log nobody reads, and the result is fabricated completions served as real ones. Making the caller type fallback="fake" keeps the escape hatch one keystroke away for a notebook while refusing to make it the accidental default for a deployment. When it IS requested, the downgrade warns exactly once per registry.

env is injectable so tests never mutate os.environ.

A missing optional extra raises :class:MissingProviderExtra and is NEVER absorbed into the fallback — a broken install must not masquerade as a missing credential.

normalize_model_name

normalize_model_name(model: str) -> tuple[str, ...]

Candidate lookup keys for a model name, most specific first.

("anthropic/claude-sonnet-4-6-20250101") → ("anthropic/claude-sonnet-4-6-20250101", "claude-sonnet-4-6-20250101", "anthropic/claude-sonnet-4-6", "claude-sonnet-4-6")

Deliberately mirrors the candidate ORDER in adapters/llm/providers/pricing.py::_lookup — exact, then strip an OpenRouter-style provider/ prefix, then strip the trailing date, then both. It is a separate function rather than a shared import because the pricing table is owned elsewhere and stays untouched; the cost of the duplication is one drift risk, which tests/source/unit/models/adapters/test_model_registry.py::test_normalisation_agrees_with_pricing_lookup pins by asserting both resolve the same names.

Returned as a de-duplicated tuple so callers can just iterate.

default_registry

default_registry() -> ModelRegistry

A freshly-populated registry with the built-in table. Each call returns an INDEPENDENT instance — tests take one of these instead of mutating the process-wide default.

registry

registry() -> ModelRegistry

The process-wide registry. Built on first use, then shared — so an application's register_model / register_rule calls at startup are visible to every later Agent construction.

resolve_llm

resolve_llm(
    model: str | None = None,
    *,
    provider: str | None = None,
    api_key: str | None = None,
    fallback: str | None = None,
    env: Mapping[str, str] | None = None,
) -> Any

:meth:ModelRegistry.resolve against the process-wide registry.

model_capabilities

model_capabilities(model: str) -> ModelCapabilities

:meth:ModelRegistry.capabilities against the process-wide registry.

require_capabilities

require_capabilities(
    model: str,
    requires: tuple[str, ...] = (),
    *,
    derived: tuple[str, ...] = (),
    min_context_window: int | None = None,
    on_unknown: str = "warn",
    subject: str = "",
) -> None

:meth:ModelRegistry.check against the process-wide registry.

register_model

register_model(entry: ModelEntry) -> None

Declare (or override) a model row on the process-wide registry.

register_provider

register_provider(entry: ProviderEntry) -> None

Declare (or override) a provider on the process-wide registry.

register_rule

register_rule(rule: Rule) -> None

Add a name→provider rule to the process-wide registry.

StorePort adapters — ONE durable-KV seam backing checkpoints, idempotency, audit, and cache.

InMemoryStore is the offline reference + contract (single-flight get_or_set, never-negative on a raised producer, append-only logs). FileStore is the zero-dependency durable adapter (survives a restart). RedisStore/PostgresStore (behind extras) are the same shape over real infra. One KV seam serves checkpoints, idempotency, the audit log, and the result cache.

One backend per module (single responsibility); the public surface is agentkit.state.stores.

FileStore

FileStore(base_dir: str)

Durable StorePort backed by JSON files under base_dir — survives a process restart, so a human-gate suspend or a crashed run resumes from disk. Zero-dependency (stdlib json/pathlib) and async-first: the blocking file I/O is bridged via asyncio.to_thread so it never stalls the loop.

The reference durable adapter; a Postgres/Redis StorePort is the same shape behind an extra. Values must be JSON-serializable (which the loop/workflow checkpoints are). Single-flight is in-process (an asyncio.Lock per key); cross-process single-flight needs the real DB's transaction — documented. ttl is accepted but not enforced (no expiry sweeper); use a TTL-native backend for that.

get_or_set async

get_or_set(
    key: str,
    fn: Callable[[], Awaitable[Any]],
    *,
    ttl: int | None = None,
) -> Any

Single-flight, keyed on PRESENCE rather than on the value.

This tested existing is not None, which conflates "nothing stored" with "None stored" — so a producer that legitimately returns None re-ran on every call and single-flight silently did not hold. InMemoryStore, the declared reference contract, keys on in, so the two backends disagreed on the same input: 1 call versus 3. _exists restores the contract without needing the value at all.

compare_and_set async

compare_and_set(
    key: str,
    expected: Any,
    value: Any,
    *,
    ttl: int | None = None,
) -> bool

Atomic within the process, via the SAME lock table get_or_set uses.

Sharing the table is the load-bearing part: two lock tables keyed on the same string give mutual exclusion between compare-and-sets and between single-flights, and none at all BETWEEN them — a producer writing its result while a CAS reads would be exactly the lost update the primitive exists to prevent.

Across processes this is NOT atomic, and no file-based store can make it so without a lock file protocol; the class docstring already says cross-process single-flight needs the real DB's transaction, and this inherits that caveat. PostgresStore and RedisStore do it properly.

increment async

increment(
    key: str, by: int = 1, *, ttl: int | None = None
) -> int

Read-add-write under the shared per-key lock — see compare_and_set.

Presence, not get() is None: a key holding a stored null is a value the durable backends refuse to add to, so it raises here too rather than restarting the counter at by.

scan async

scan(
    prefix: str, *, limit: int | None = None
) -> AsyncIterator[str]

List the directory ONCE, in a thread, then yield from that list.

Two reasons it is not lazy. A directory handle held open across an await would see entries appear and vanish mid-iteration — the same "must not raise while writers land" promise the in-memory backend needs a snapshot for. And iterdir is blocking: streaming it would put a stat syscall per key on the event loop, which is what asyncio.to_thread exists here to avoid.

Filtering on the .json suffix is what excludes both namespaces that must not appear: .log files (list reads those) and the <name><random>.tmp scratch files _write_atomic leaves in flight, which would otherwise surface a half-written key as a real one.

InMemoryStore

InMemoryStore(*, clock: Callable[[], float] = monotonic)

purge_expired

purge_expired() -> int

Drop every key whose deadline has passed; returns how many went.

Purge-on-read reclaimed a key only if something read it again, so TTL'd keys nobody revisits were retained forever — a store used purely for idempotency grew without bound while reporting the right answers. Public because a long-lived process may want to sweep on its own cadence; set also calls it every _SWEEP_EVERY writes, which keeps the table bounded by live data rather than lifetime traffic.

get_or_set async

get_or_set(
    key: str,
    fn: Callable[[], Awaitable[Any]],
    *,
    ttl: int | None = None,
) -> Any

Single-flight: a hit returns the stored value; a miss runs fn once and stores it. A raised fn propagates and is NOT stored, so a transient error retries clean (failures are never cached). ttl is applied on the stored result, matching the other backends.

compare_and_set async

compare_and_set(
    key: str,
    expected: Any,
    value: Any,
    *,
    ttl: int | None = None,
) -> bool

Deliberately NOT under self._locks.

There is no await between the read and the write below, and asyncio is cooperative, so no other task can observe or interleave with the intermediate state — the sequence is already atomic and a lock would add nothing. Taking get_or_set's lock instead would be actively worse: a producer that itself compare-and-sets the key it is producing would deadlock against its own single-flight entry.

== rather than is: the durable backends compare JSON-decoded values, and an identity check here would make the reference store the only one where CAS on a dict could ever succeed.

increment async

increment(
    key: str, by: int = 1, *, ttl: int | None = None
) -> int

Same no-await-in-between argument as compare_and_set: read, add, write.

ttl is applied only when the key has no deadline yet, which is Redis's EXPIRE NX and keeps a hot counter from sliding its own window forever.

The deadline is captured and restored around the write because set CLEARS any expiry when handed ttl=None (it mirrors Redis's SET, where a plain SET drops the TTL). Without that, the second increment of every windowed counter would silently make it permanent — the counter would keep counting and the window would never close.

Keyed on PRESENCE, not on get() is None. A key holding a stored null is a VALUE, and the durable backends cannot add to it — Redis INCRBY on the four bytes null is an error, and so is Postgres's ::bigint cast. Treating it as zero here would make the offline reference the one backend that accepted it, which is the same drift get_or_set was already caught in.

scan async

scan(
    prefix: str, *, limit: int | None = None
) -> AsyncIterator[str]

Snapshot the key list BEFORE yielding any of it.

yield is a suspension point, so a writer runs between two iterations of this loop; iterating self._kv live raised RuntimeError: dictionary changed size during iteration the first time an audit reader ran against a live run. The snapshot costs one list of keys — which the caller is about to hold anyway — and buys the only concurrency promise the port makes.

PostgresStore

PostgresStore(dsn: str | None = None, *, pool: Any = None)

Durable StorePort over Postgres (extra: arc-agentkit[postgres], via asyncpg). One KV table + one append-log table, JSON-text values. Call await init() once to create the tables. Single-flight is in-process; durability/atomicity is Postgres's. TTL is not enforced (no sweeper — use RedisStore for TTL). Inject a pool for tests; else an asyncpg pool is created from dsn.

compare_and_set async

compare_and_set(
    key: str,
    expected: Any,
    value: Any,
    *,
    ttl: int | None = None,
) -> bool

ONE statement, so the compare and the set cannot be pulled apart.

A SELECT followed by an UPDATE is two statements and, at the default READ COMMITTED isolation, another transaction commits between them — the lost update this primitive exists to prevent. The predicate rides inside the write instead, and the row count is the answer.

value::jsonb = $3::jsonb rather than text equality: the column is TEXT holding json.dumps output, and Python dicts with the same contents in a different insertion order serialise to different text. Comparing as JSON makes the equality structural, which is what the port promises and what every other backend does.

expected=None takes the INSERT branch because absent must compare equal to None — and the ON CONFLICT guard keeps it honest: if the row turns out to exist it is only overwritten when it holds JSON null. Without that guard the upsert would clobber any value at all, turning the CAS into an unconditional set.

increment async

increment(
    key: str, by: int = 1, *, ttl: int | None = None
) -> int

One upsert that adds in place, guarded so the cast can never raise.

kv.value ~ '^-?[0-9]+$' is not defensive noise: without it, a key holding {"a": 1} makes value::bigint raise InvalidTextRepresentationError — an asyncpg type escaping through a port whose whole purpose is that callers never see backend types. With it, the conflicting row simply fails the ON CONFLICT ... WHERE, no row is returned, and the uniform StoreValueError is raised from here. (ON CONFLICT ... WHERE filters rows before the SET expression is evaluated, so the cast never runs on a non-numeric row.)

The regex is exactly the set of strings json.dumps produces for an int, which is why a counter and an ordinary stored value can share the KV table: 1.5, "7", true and null all fail it, and all four are values Redis's INCRBY refuses too.

scan async

scan(
    prefix: str, *, limit: int | None = None
) -> AsyncIterator[str]

A prefix query over the KV table only — the log table has its own reader.

starts_with(key, $1) rather than key LIKE $1 || '%' because the prefix is caller data: LIKE would read _ as "any character" and % as "anything", so a scan for run_1: would also return run-1:'s keys. Escaping for LIKE is possible but it is a correctness trap that has to be got right in every caller's head; starts_with has no pattern syntax to escape. The same class of bug is escaped explicitly on the Redis backend, where SCAN gives no alternative.

The whole result is fetched, then yielded. asyncpg cursors are transaction-scoped, and holding a transaction open for as long as a caller takes to consume an audit scan is a far worse failure mode than materialising a list of keys — the caller is about to hold them anyway.

RedisStore

RedisStore(
    url: str | None = None,
    *,
    client: Any = None,
    namespace: str = "agentkit",
)

Durable StorePort over Redis (extra: arc-agentkit[redis]). KV via GET/SET(EX), append-logs via RPUSH/LRANGE, values JSON-encoded. Single-flight is in-process (an asyncio.Lock per key); TTL is honored natively (SETEX). Inject a client (e.g. a fake) for tests; else built from url.

compare_and_set async

compare_and_set(
    key: str,
    expected: Any,
    value: Any,
    *,
    ttl: int | None = None,
) -> bool

WATCH / read / MULTI / SET — Redis's optimistic-locking idiom.

WATCH arms the key BEFORE the read, so if anything writes it between the read and the EXEC, the EXEC is discarded and redis-py raises WatchError. Without the WATCH this would be a plain read-then-write across two round trips, which is the lost update the primitive exists to prevent — and it would pass every single-threaded test.

A WatchError is translated to False, not re-raised: losing the race IS the answer the caller asked for. Any other exception propagates, because "the store is down" is not a lost race.

increment async

increment(
    key: str, by: int = 1, *, ttl: int | None = None
) -> int

INCRBY and EXPIRE ... NX inside ONE transaction.

The two commands must be one unit. Issued separately, a process that dies between them leaves a counter with no window — a rate limit that is permanently exhausted for that key, with no expiry to ever clear it. MULTI/EXEC makes the pair atomic.

NX is what makes the window fixed rather than sliding: it sets an expiry only when the key has none. A plain EXPIRE would push the deadline out on every hit, so a caller sending traffic faster than the window is long would keep the counter alive forever and the limit would never reset — the failure appears only under exactly the load the limit exists for. (EXPIRE ... NX needs Redis 7.0+.)

The JSON encoding lines up for free: json.dumps(5) == "5" is precisely the string Redis stores an integer as, so a counter written by INCRBY reads back through get as an int and a counter written by set can be INCRBY'd. Nothing else in this adapter would work if that were not true.

The error translation matches on Redis's MESSAGE rather than importing ResponseError — same reason as _is_watch_conflict: the extra may not be installed at all on the path that constructs this with a client.

scan async

scan(
    prefix: str, *, limit: int | None = None
) -> AsyncIterator[str]

SCAN with an escaped MATCH, looping on the cursor and de-duplicating.

Three properties of SCAN that a naive translation gets wrong:

  • MATCH is a GLOB. The prefix is caller data, so it is escaped — see _escape_glob for the cross-tenant read that follows if it is not.
  • A non-zero cursor means "more to come" even when this call returned nothing. Stopping on an empty page silently truncates the scan.
  • The same key may be returned more than once (a rehash during the scan), so keys are de-duplicated. The seen set is bounded by the number of MATCHING keys, which is what the caller is about to hold anyway — the alternative is a caller that double-processes an audit record and cannot tell why.

Keys come back as bytes: redis-py only decodes when the client was built with decode_responses=True, and this one is not. get survives that because json.loads accepts bytes; the string surgery here does not.

VectorPort adapters.

InMemoryVector

InMemoryVector()

VectorPort — scope-isolated, cosine over TF vectors, metadata-filtered, scored (tests/lean).

PgVectorStore

PgVectorStore(
    dsn: str | None = None,
    *,
    pool: Any = None,
    dim: int = _DIM,
)

CheckpointPort adapters — InMemoryCheckpointStore is the offline reference + contract every durable backend matches; PostgresCheckpointStore is the production durable backing (extra: arc-agentkit[postgres]). Public surface is agentkit.state.checkpoints.adapters.

InMemoryCheckpointStore

InMemoryCheckpointStore()

A CheckpointPort over an in-process dict. One list of Checkpoints per run_id.

PostgresCheckpointStore

PostgresCheckpointStore(pool: Pool)

A CheckpointPort over Postgres via asyncpg. Pool is application-owned (matches the explicit-lifecycle convention PostgresStore follows — see store/postgres.py). Call await ensure_schema() once at startup; the per-call methods then go through pool.acquire() like every other asyncpg adapter in this package.

ensure_schema async

ensure_schema() -> None

Idempotent DDL — CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Safe to call multiple times. Apps call this once at startup before any save/load (mirrors PostgresStore.init()).

Concrete observer adapters for the observation channel.

  • CollectingObserver / QueueObserver — terminal sinks (list capture; bounded async-consumable queue).
  • PolicyObserver / RollupObserver — emission-cadence wrappers around an inner observer.
  • Hooks — lifecycle subscription (on(stage, handler)), sugar over the stream.

In-process transports. A cross-process transport (e.g. Redis pub/sub → SSE) is a future adapter on the same ObserverPort. The public surface is agentkit.integrations.observers.

PolicyObserver

PolicyObserver(
    inner: Any,
    *,
    allow: frozenset[str] | set[str] | None = None,
)

The emission-cadence knob: forwards only selected kinds to an inner observer.

result/error/interrupt always pass (never silence a result or a human gate); other kinds pass only if allow is None (everything) or contains the kind. Compose it around any ObserverPort (e.g. a QueueObserver); the consumer still streams from the inner observer. Emit-only by design — an ObserverPort needs only emit.

close async

close() -> None

Forward shutdown to inner. ObserverPort declares close(), so without it isinstance(PolicyObserver(...), ObserverPort) was False (measured) — a cadence wrapper did not satisfy the very Protocol it exists to compose. Worse, a wrapper that swallows close strands a buffering inner: wrapping a RollupObserver dropped its trailing summary entirely.

everything classmethod

everything(inner: Any) -> PolicyObserver

Forward every observation (the streaming/step cadence).

summaries classmethod

summaries(inner: Any) -> PolicyObserver

Forward rolled-up summary/progress (+ always-forwarded), drop finer events.

result_only classmethod

result_only(inner: Any) -> PolicyObserver

Send only on a complete result/error (+ interrupt) — the quiet-pipeline cadence.

RollupObserver

RollupObserver(
    inner: Any,
    *,
    every: int = 8,
    summarize: Callable[
        [Sequence[Observation]], str | Awaitable[str]
    ]
    | None = None,
    kind: ObservationKind = "summary",
)

The 'rolled-up summary' cadence: buffer non-critical observations and emit one summary every every of them — flushing the buffer first whenever a critical kind (result/error/interrupt) passes through, and again on close().

summarize(buffer) -> str builds the roll-up text; it may be sync or async (async is the hook for an LLM judge or a Compactor-backed summariser). Critical observations are always forwarded immediately (after the flush), so a result is never delayed behind buffered progress.

dropped property

dropped: int

Observations discarded because summarising them failed. 0 means the rollup is complete; anything else means a consumer is reading a partial view.

close async

close() -> None

Flush any buffered tail (and close the inner observer if it supports it).

Hooks

Hooks(inner: Any | None = None)

Lifecycle subscription: a thin ObserverPort that dispatches each observation to handlers registered with on(stage, handler), where stage is the observation kind (e.g. run_start, summary, interrupt, error, result) or "*" for every observation.

Lifecycle is not a new mechanism — it's the observation stream, named. Handlers are sync or async and never break the run: an exception in a handler is swallowed. Optionally forwards to an inner observer, so Hooks chains in front of a QueueObserver.

close async

close() -> None

Forward shutdown to inner (no-op when unwired).

ObserverPort declares close(); without it isinstance(Hooks(), ObserverPort) was False (measured) and, because Hooks chains in FRONT of another observer, the whole chain below it lost its shutdown hook — a Hooks -> PolicyObserver -> RollupObserver stack silently dropped the rollup's trailing summary at process exit. Like emit, a handler must never destabilise the run, so a failing inner close is not swallowed here: close is an explicit shutdown call, and hiding a flush failure would lose the buffered tail without a trace.

CollectingObserver

CollectingObserver()

Records every observation into .items (no backpressure). For tests and small in-proc consumers.

QueueObserver

QueueObserver(maxsize: int = 256)

Non-blocking, bounded async observer with the never-drop-results rule.

emit never blocks and never raises into the run. Non-critical observations (progress/summary) are bounded to maxsize; when over, the oldest non-critical is dropped (coalesced). result/error are never dropped and never count against the bound. stream() async-iterates in insertion order until close().

OpenTelemetry bridge. Distinct from adapters.observer (in-process observer-pattern impls). Requires the observability extra.

otel_exporter_otlp_http

otel_exporter_otlp_http(
    *, endpoint: str | None = None
) -> None

One-call setup of the OTel SDK with an OTLP-HTTP exporter.

Builds a TracerProvider with a BatchSpanProcessor wrapping an OTLP-HTTP exporter pointed at endpoint (or the OTEL_EXPORTER_OTLP_ENDPOINT env var). Call once at process startup BEFORE otel_tracer() is consumed — sets the global tracer provider.

The convenience version: operators who want a "give me OTel with sensible defaults" path call this. Operators with their own setup (custom processor, multiple exporters, etc.) skip this and configure opentelemetry.trace.set_tracer_provider themselves.

otel_meter

otel_meter(
    meter_provider: Any = None,
    *,
    instrument_name: str = "agentkit",
) -> Any

Construct a MetricsPort backed by the OpenTelemetry SDK.

Production: pass nothing — pulls the global meter (configured via env vars OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_METRICS_ENDPOINT). Tests: pass an explicit meter_provider so each test gets isolated state.

Raises ImportError when opentelemetry-sdk isn't installed (i.e., the [observability] extra wasn't installed); the error message names the extra so the operator knows what to do.

otel_metrics_exporter_otlp_http

otel_metrics_exporter_otlp_http(
    *, endpoint: str | None = None, interval_ms: int = 60000
) -> None

One-call OTel metrics SDK setup with an OTLP-HTTP exporter.

Sister to otel_exporter_otlp_http for traces. Call once at startup BEFORE otel_meter() is consumed — sets the global meter provider with a PeriodicExportingMetricReader pushing to the OTLP-HTTP endpoint every interval_ms (default 60s, the OTel default).

The convenience version: operators who want a "give me OTel metrics with sensible defaults" path call this. Operators with their own setup (custom readers, multiple exporters, etc.) skip this and configure opentelemetry.metrics.set_meter_provider themselves.

otel_sampler

otel_sampler(ratio: float = 1.0) -> Any

Convenience: build a SamplerPort from agentkit's pure-Python TraceIdRatioSampler. ratio=1.0 = always-on; ratio=0.1 = 10%.

The sampler is a kernel-level seam (no OTel dependency) so this factory just wraps the in-tree impl — it lives next to the OTel metrics/tracer factories so operators have ONE module to import from when wiring observability. For richer policies (parent-based, rule-based), construct your own SamplerPort impl and inject it directly into Services(sampler=...).

otel_tracer

otel_tracer(tracer_provider: Any = None) -> OtelTracePort

Construct a TracePort backed by the OpenTelemetry SDK.

Production: pass nothing — pulls the global tracer (configured via env vars OTEL_SERVICE_NAME / OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS / ... per the standard OTel SDK convention).

Tests: pass an explicit tracer_provider (e.g. a TracerProvider wired to InMemorySpanExporter) so each test gets its own isolated provider. OTel forbids overriding the global set_tracer_provider once it's been set, so sharing a global across tests gives stale-provider failures.

Raises ImportError when opentelemetry-sdk isn't installed (i.e., the [observability] extra wasn't installed); the error message names the extra so the operator knows what to do.

Replay store

FileReplayStore keeps the full LLM request/response for a span on disk, keyed by span id, so the trace stays the index and the payloads live outside the span's attribute budget.

Configuration, in precedence order:

  1. An explicit FileReplayStore(root=...).
  2. AGENTKIT_REPLAY_DIR — the env var FileReplayStore.from_env() reads. An empty value counts as unset.
  3. FileReplayStore.default() — $XDG_DATA_HOME/agentkit/replays, or ~/.local/share/agentkit/replays when XDG_DATA_HOME is unset.

That list is exhaustive. AGENTKIT_REPLAY_DIR is the only env var read, and default() probes nothing on disk — it returns the same path whatever directories happen to exist, so where the store writes is always predictable from the environment alone.

RIO_REPLAY_DIR no longer does anything

RIO_REPLAY_DIR and the ~/.rio/replays default date from before this package was agentkit, and neither is read any more. If you still set the old variable, replay is simply off — from_env() returns None and the caller falls back to NoopReplayStore, with nothing logged. Set AGENTKIT_REPLAY_DIR instead.

Replays under an old directory are not migrated for you and are not found by default(). Move them yourself (mv ~/.rio/replays ~/.local/share/agentkit/replays) or point the store at them with AGENTKIT_REPLAY_DIR.

Writes are best-effort by contract — ReplayStore.put must never raise into a run — but never silent: a dropped write logs (once per failure class, to keep a full disk from flooding the log) and increments FileReplayStore.dropped_writes. Read failures that are not ordinary cache misses increment failed_reads. Alert on those counters if replay data matters to you; a miss on its own is normal and is neither logged above DEBUG nor counted.

ReplayStore adapters.

FileReplayStore

FileReplayStore(root: Path | str)

ReplayStore that writes each record to <root>/<span_id>.json.

Constructed with an explicit root directory (created on first write if missing). Use FileReplayStore.from_env() to read the AGENTKIT_REPLAY_DIR env var, or FileReplayStore.default() for the conventional location.

Best-effort, but never silent: a failed write logs and returns None (the contract of ReplayStore.put says writes must not raise into the run) while incrementing dropped_writes. A failed read returns None and increments failed_reads. A genuine miss also returns None but is NOT a failure — it logs at DEBUG and touches no counter, so "the record isn't there" stays distinguishable from "the store is broken".

root property

root: Path

The resolved directory this store reads and writes.

dropped_writes property

dropped_writes: int

Records put() accepted but could not persist.

Non-zero means replay data is missing for this run. Exposed as a number rather than only a log line because the log is warned-once (see _warn_once) — the counter is what stays accurate under a sustained failure such as a full disk.

failed_reads property

failed_reads: int

Reads that failed for a reason OTHER than a genuine miss.

A missing file is a cache miss and is deliberately excluded: mixing misses into this counter would make it useless, since a miss is the expected outcome for any span that was never recorded.

from_env classmethod

from_env() -> FileReplayStore | None

Read AGENTKIT_REPLAY_DIR and construct; return None when the env var isn't set so callers can fall back to NoopReplayStore.

An empty value counts as unset. FOO= in a shell profile or a compose file is how people spell "not configured", and Path("") resolves to the process CWD — so treating it as a path would scatter replay JSON through whatever directory the app happened to start in, while looking configured.

default classmethod

default() -> FileReplayStore

Conventional location: $XDG_DATA_HOME/agentkit/replays or ~/.local/share/agentkit/replays.

No directory probing and no fallbacks: the path is a pure function of XDG_DATA_HOME, so what this returns is predictable from the environment alone. An explicit root= or AGENTKIT_REPLAY_DIR is how you point the store anywhere else.