Adapter capability profiles¶
A capability profile is a machine-readable declaration of what a CLI agent supports and how it is invoked. The profile factory turns that declaration into a working adapter, so tracking a new agent is a data edit rather than a new hand-written module.
Source: src/bernstein/adapters/capability_profile.py.
Why declarations¶
Adapters used to be one hand-written module per agent. That works until the catalogue is large: every upstream rename, flag reshuffle or project handover lands as a source edit, so the cost of tracking the ecosystem scales with the size of the catalogue rather than with the size of the change.
A profile moves the per-agent knowledge into data. The parts that are genuinely shared - worker wrapping, environment isolation, log layout, timeout watchdog, multimodal refusal, rate-limit metering - stay in one implementation that every profiled agent gets for free.
Agents that do not fit the declarative shape keep their hand-written module, and agents nobody has profiled at all are still served by the generic CLI adapter. Neither path changes.
The schema¶
| Field | Meaning |
|---|---|
name | Registry key, e.g. pydantic_ai |
display_name | Human-readable name returned by CLIAdapter.name() |
invocation | The always-passed CLI surface (see below) |
implementation | FACTORY (built from the profile) or MODULE (declaration over a hand-written adapter) |
mcp_client / mcp_server | Consumes MCP servers / exposes itself as one |
native_subagents | Can fan work out to its own subagents |
sandbox | Strongest isolation tier: none, process, container, vm |
local_models | Can drive a locally-hosted model |
vision | Accepts image input |
computer_use | Can drive a screen, mouse or keyboard |
max_parallel_workers | Concurrent workers within one session; 1 is sequential |
agents_md | Reads AGENTS.md project instructions |
resume / dangerous_mode / event_channel | The three strategy axes shared with STRATEGY_MATRIX |
provides | Provider-name aliases for routing |
InvocationSpec describes the always-passed surface - the tokens every spawn emits:
InvocationSpec(
binary="clai",
model_flag="-m",
prompt_positional=True,
extra_args=("--no-stream",),
env_passthrough=("OPENAI_API_KEY", "ANTHROPIC_API_KEY"),
)
Flags an adapter passes conditionally are deliberately out of scope. They are not part of the pinned contract, and modelling them would make the profile-to-contract cross-check report false drift.
Recording an onboarding transcript¶
An operator can turn a drafted profile into a replayable golden transcript with the public helpers in bernstein.adapters.onboarding. The flow is supervised and bounded: it runs one known-good smoke invocation, then three deterministic held-out invocations against the same binary.
| API | Behaviour |
|---|---|
record_golden_transcript(profile, *, name, smoke_prompt, smoke_model, golden_dir) | Builds the smoke argv with InvocationSpec.build_argv, runs it once with a finite timeout, and atomically writes a transcript only after exit code 0. |
derive_held_out_invocations(evidence, invocation, *, smoke_prompt, smoke_model) | Checks that the probe evidence names the drafted binary and returns exactly three stable, unique cases. Only the prompt payload varies; every always-emitted profile token remains present. |
replay_held_out_invocations(invocations, *, invocation=...) | Executes every derived case and returns one StepResult per case in a TranscriptResult. |
The recorded document keeps the existing GoldenTranscript shape and has exactly four top-level keys: name, adapter_class, ctor_kwargs, and steps. Its adapter class is the stable bernstein.adapters.capability_profile.RecordedProfileAdapter, which rehydrates the exact invocation from YAML-safe fields (registry_name, display_name, binary, subcommands, model_flag, prompt_flag, prompt_positional, extra_args, and env_passthrough). This preserves token order and the environment allow-list across processes; no output, ambient environment, or operator identity is stored.
from pathlib import Path
from bernstein.adapters.onboarding import (
derive_held_out_invocations,
record_golden_transcript,
replay_held_out_invocations,
)
record_golden_transcript(
profile,
name="example_cli",
smoke_prompt="print a short greeting",
smoke_model="local-model",
golden_dir=Path("tests/golden"),
)
held_out = derive_held_out_invocations(
evidence_path,
profile.invocation,
smoke_prompt="print a short greeting",
smoke_model="local-model",
)
result = replay_held_out_invocations(held_out, invocation=profile.invocation)
assert result.passed
Every held-out case is attempted. A non-zero exit, timeout (124), missing binary (127), or execution error is a failed step, not a skip; an empty case list is also a failed result. Captured output is discarded after the exit check. _sandbox_env normalizes the supervised environment but is not an OS-level network sandbox.
Content addressing¶
profile.profile_hash is a SHA-256 over the profile's canonical JSON form (sorted keys, primitive values, notes excluded - prose about a declaration is not part of the declaration). The hash is reachable from a live adapter as adapter.profile_hash.
Recording that hash alongside a run makes a changed declaration show up as a hash divergence with a named cause, instead of as unexplained behaviour drift between two runs of the "same" adapter.
Two implementation shapes¶
FACTORY - no hand-written module exists; the adapter is built from the profile by build_adapter_class_from_profile. The factory returns a class, not a shared instance, so every get_adapter() call yields a fresh adapter with its own resource limits and rate-limit meter, exactly as a hand-written adapter does.
MODULE - a hand-written module owns the spawn path and the profile is the declaration of what that module supports. This is what makes migration incremental and opt-in: an existing adapter gains a machine-readable capability surface without any change to how it spawns.
Gates a profile must clear¶
A profile is not self-certifying. Three checks apply, and none of them was relaxed to admit profile-built adapters.
-
Construction-time validation. An underspecified profile cannot exist: a blank name or binary, an empty flag string, a prompt with no delivery mechanism, or
max_parallel_workers < 1raisesProfileValidationError(aValueErrorsubclass) at construction. -
Contract cross-check.
profile_contract_discrepanciescompares the declared invocation surface against the pinned contract YAML undertests/contract/contracts/. A profile may declare less than the contract requires, never more - it cannot claim a binary, subcommand or flag the contract does not carry.assert_profile_backed_by_contractraises on any discrepancy, so a declaration that outruns what was verified is refused rather than promoted. -
The existing conformance suite. Profile-built adapters are ordinary registry entries. They resolve through
get_adapter, are enumerated byiter_adapter_specs, and face the sametests/unit/test_adapter_consistency.pyprotocol suite, the same strategy declaration requirement, and the same contract gate as a hand-written adapter.
The profile test suite additionally asserts that every shipped profile agrees with its STRATEGY_MATRIX row and that no profile claims vision that the multimodal capability table does not grant, so the declaration and the authoritative matrices cannot drift apart.
Capability-aware selection¶
select_profile_for(requirements) returns the first profile satisfying a TaskCapabilityRequirements. A capability stronger than required still satisfies it: a container-sandboxed adapter serves a task asking for process isolation.
When nothing satisfies the task the call raises CapabilityMismatchError rather than quietly falling back to a weaker adapter. The error carries a CapabilityRefusalReceipt - a content-addressed record of the requirements, every candidate considered with the profile hash it presented, and the axes that went unmet:
try:
profile = select_profile_for(TaskCapabilityRequirements(vision=True))
except CapabilityMismatchError as exc:
exc.receipt.unmet # ("vision",)
exc.receipt.receipt_hash # deterministic sha256
The receipt hash is deterministic for a given (requirements, candidates, unmet) triple, so two operators reconstructing the same refusal derive the same identifier.
Anchoring the routing decision¶
route_and_record(requirements, audit_chain=chain, run_id=...) is the seam that turns an adapter selection into a replay-verifiable record. It wraps select_profile_for so the decision leaves a trace in the HMAC audit chain instead of being an unobservable side effect of dispatch:
- On a match it appends an
adapter.capability_selectionevent carrying the chosen adapter and the content-addressed profile hash it presented. Replay recomputes that hash, so a declaration that changed between two runs surfaces as a hash divergence named by the adapter. - On a mismatch it appends an
adapter.capability_refusalevent - the refusal receipt's hash, the unmet axes, and every candidate with the profile hash it presented - before theCapabilityMismatchErrorpropagates. The refusal is a signed, chain-anchored record an operator can hand to a postmortem, never a silent fallback to a weaker adapter.
from bernstein.adapters.capability_profile import route_and_record
profile = route_and_record(
TaskCapabilityRequirements(mcp_client=True),
audit_chain=chain,
run_id=run_id,
)
audit_chain is optional: with no chain the call selects (or refuses) exactly as select_profile_for does, so a dry-run capability probe stays chain-free. The chain module is imported lazily, only when a chain is supplied, so the adapter module's load-time import surface stays lean.
At dispatch¶
The spawn path invokes the seam once the adapter for a spawn is resolved, in AgentSpawner._record_adapter_capability_selection. It runs alongside the security-floor preflight, before the adapter receives any task context:
- For an adapter that ships a profile, the profile hash it presents is anchored as an
adapter.capability_selectionevent, so replay detects a changed declaration as a hash divergence named by the adapter. - When the task declares capability requirements the routed adapter cannot meet, the refusal receipt is anchored and the spawn is refused - a hard stop like a floor refusal, never an alternate-adapter failover.
An adapter with no profile (the common claude / codex / gemini path, served by the generic fallback) is a no-op: nothing is anchored and the spawn proceeds unchanged.
Declaring task requirements¶
A task declares what it needs from whichever adapter runs it through the capability: namespace of its requires list (the capability-addressing field on the task spec). Tokens outside that namespace are skill addressing and are ignored, so a task that declares no capability requirement routes exactly as before:
requires:
- python # skill addressing - ignored here
- capability:mcp_client # needs an MCP-client adapter
- capability:sandbox=container # needs container isolation or stronger
- capability:max_parallel_workers=4
capability_requirements_from_tokens parses those tokens into a TaskCapabilityRequirements. A boolean axis takes no value; sandbox takes a tier name; max_parallel_workers takes an integer. A mistyped axis raises ProfileValidationError rather than passing silently, so an unenforceable requirement fails loud instead of routing a task to an adapter that does not support it.
Adding an agent as a profile¶
- Add an
AdapterCapabilityProfileto_PROFILE_LISTincapability_profile.py. Keep the declaration conservative: claim a capability only when the contract or upstream documentation backs it. - Add the contract YAML under
tests/contract/contracts/<name>.yamlpinning the always-passed surface. - Add a
STRATEGY_MATRIXrow in_contract.pykeyed by the same name. - Add a
use_cases.pyentry sobernstein integrations listhas copy. - For a
FACTORYprofile, add the module to theadapters-independentcontract only if you introduce a new module - profiles themselves add no module, which is the point.
Run uv run pytest tests/unit/adapters/test_capability_profile.py to check the profile against every gate above.
Shipped profiles¶
| Adapter | Shape | Always-passed surface |
|---|---|---|
pydantic_ai | FACTORY | clai -m <model> --no-stream <prompt> |
droid | MODULE | droid <prompt> |
kimi | MODULE | kimi --yolo -c <prompt> |
opencode | MODULE | opencode run -m <model> --format json --auto <prompt> |
goose | MODULE | goose run --text <prompt> |