Adapter capability contract¶
Every CLI agent expresses the same orchestration concepts differently. Resume is --resume <id> for one CLI, --session-id <id> for another, and a subcommand <cli> resume <id> for a third. "Skip permission prompts" is a flag here, an environment variable there, and an always-on default for CLIs with no permission system. The event surface Bernstein observes is stream-json for some, a plain-text signal grammar for others, and upstream hooks for the rest.
To keep the orchestrator free of if adapter == "X" branches, each adapter declares its strategy on three typed axes. The orchestrator dispatches off the enum, so adding a new adapter is a contract-completion exercise rather than a hunt-and-patch across the core.
The enums and the declaration matrix live in src/bernstein/adapters/_contract.py. Strategy is declared, not probed: Bernstein never runs the CLI at start just to discover its capabilities.
The three axes¶
Resume strategy (ResumeStrategy)¶
How an adapter reattaches to a prior session for bernstein resume.
| Value | Meaning |
|---|---|
flag | A single flag carries the session id, e.g. --resume <id>. |
flag-pair | Two flags: one names the existing session, one mints a new one. |
subcommand | A dedicated subcommand, e.g. <cli> resume <id>. |
unsupported | No native resume; fall back to a fresh session with scratchpad reinjection. |
The legacy two-state view (native / fallback-fresh) consumed by bernstein resume is derived from this axis: any value other than unsupported maps to native. See resume_capability in _contract.py.
Dangerous-mode strategy (DangerousModeStrategy)¶
How an adapter is told to skip interactive permission prompts so it can run unattended.
| Value | Meaning |
|---|---|
cli-flag | A flag, e.g. --yolo or --permission-mode bypassPermissions. |
env-var | An environment variable the CLI reads at startup. |
always-on | The CLI has no permission system; it is always non-interactive. |
unsupported | The CLI cannot run unattended in dangerous mode. |
Event channel (EventChannel)¶
The surface Bernstein reads for an adapter's lifecycle signals.
| Value | Meaning |
|---|---|
stream-json | Newline-delimited JSON events from the upstream CLI. |
text-signals | Plain stdout carrying the canonical BERNSTEIN:<KIND> grammar (see stream_signals.md). |
hooks | The upstream SDK fires hooks/callbacks Bernstein registers against. |
poll-pty | No structured channel; Bernstein polls a PTY or log for liveness. |
none | No event channel; process-exit detection only. |
Declaring a strategy¶
The canonical declaration is a row in STRATEGY_MATRIX, keyed by registry name:
STRATEGY_MATRIX = {
"claude": AdapterStrategy(
resume=ResumeStrategy.FLAG,
dangerous_mode=DangerousModeStrategy.CLI_FLAG,
event_channel=EventChannel.STREAM_JSON,
),
# ...
}
An adapter MAY instead keep the declaration next to its implementation by setting the class attribute strategy_override to an AdapterStrategy. Read the resolved strategy through CLIAdapter.strategy(), never the raw attribute: the resolver applies the inline override first, then the matrix keyed by registry name, then the conservative DEFAULT_ADAPTER_STRATEGY (no native resume, dangerous mode unsupported, text-signal channel).
Conformance¶
Every shipped adapter must declare its strategy on each axis. The conformance harness calls assert_strategies_declared(), which raises StrategyDeclarationError listing any registry adapter missing a row in STRATEGY_MATRIX. bernstein adapters check surfaces the per-adapter strategy table (strategy_conformance_table) so operators can compare adapters at a glance.
Shipped adapter declarations¶
| Adapter | Resume | Dangerous mode | Event channel |
|---|---|---|---|
aichat | unsupported | unsupported | text-signals |
aider | unsupported | unsupported | text-signals |
amp | unsupported | unsupported | text-signals |
auggie | unsupported | unsupported | text-signals |
autohand | unsupported | unsupported | text-signals |
charm | unsupported | cli-flag | text-signals |
claude | flag | cli-flag | stream-json |
cline | unsupported | cli-flag | text-signals |
clm | unsupported | unsupported | text-signals |
cloudflare | unsupported | unsupported | hooks |
codebuff | unsupported | unsupported | text-signals |
codex | unsupported | cli-flag | text-signals |
cody | unsupported | unsupported | text-signals |
composio | unsupported | unsupported | hooks |
continue | unsupported | unsupported | text-signals |
copilot | unsupported | unsupported | text-signals |
cursor | unsupported | cli-flag | stream-json |
devin_terminal | unsupported | unsupported | poll-pty |
droid | unsupported | unsupported | text-signals |
forge | unsupported | unsupported | text-signals |
gemini | unsupported | cli-flag | stream-json |
generic | unsupported | unsupported | text-signals |
goose | unsupported | unsupported | text-signals |
gptme | unsupported | unsupported | text-signals |
hermes | unsupported | unsupported | text-signals |
iac | unsupported | unsupported | text-signals |
junie | unsupported | unsupported | text-signals |
kilo | unsupported | unsupported | text-signals |
kimi | unsupported | cli-flag | text-signals |
kiro | unsupported | unsupported | text-signals |
letta_code | unsupported | cli-flag | text-signals |
mistral | unsupported | unsupported | text-signals |
mock | unsupported | unsupported | text-signals |
ollama | unsupported | unsupported | text-signals |
open_interpreter | unsupported | unsupported | text-signals |
openai_agents | flag | always-on | hooks |
opencode | unsupported | unsupported | text-signals |
openhands | unsupported | unsupported | text-signals |
pi | unsupported | unsupported | text-signals |
plandex | unsupported | unsupported | text-signals |
q_dev | unsupported | unsupported | text-signals |
qwen | unsupported | unsupported | text-signals |
ralphex | unsupported | unsupported | text-signals |
rovo | unsupported | cli-flag | text-signals |
The matrix is the source of truth; this table is regenerated from strategy_conformance_table(). Out of scope for this contract: runtime capability discovery, and removing every adapter-specific conditional (some output-formatting quirks remain as overrides).
Sampling and endpoint parameters¶
Adapters that can honour per-spawn sampling and endpoint overrides declare AdapterCapability.SUPPORTS_SAMPLING_PARAMS in their plugin_info() capabilities. The overrides travel in the per-spawn mcp_config and, for openai_agents, land in the runner manifest as optional fields:
| Field | Type | Meaning |
|---|---|---|
temperature | float | Sampling temperature. Absent = provider default. |
top_p | float | Nucleus sampling. Absent = provider default. |
top_k | int | Top-k sampling, forwarded for OpenAI-compatible endpoints that accept it. Absent = provider default. |
base_url | str | OpenAI-compatible endpoint URL. Absent = default endpoint. |
api_key_env | str | NAME of the environment variable holding the API key for base_url. Never a literal key. |
All fields are optional; a manifest without them behaves exactly as before. When base_url is set, the runner switches the SDK to the chat-completions API (third-party OpenAI-compatible endpoints do not serve /responses) and excludes the custom client from tracing so the endpoint's key is never sent to api.openai.com.
api_key_env must match ^[A-Z][A-Z0-9_]*$ AND be a known LLM provider key (OPENAI_API_KEY, OPENROUTER_API_KEY, DEEPSEEK_API_KEY, and the rest of the built-in allowlist in openai_agents_runner.py). Any other name - including credential-shaped names of unrelated secrets such as GITHUB_TOKEN - is rejected at startup with a config_invalid error (exit code 2), the same failure path as a name whose variable is not set. To use a provider key outside the built-in set, the operator sets BERNSTEIN_ALLOWED_API_KEY_ENVS (comma-separated names) on the host; a repo-carried config cannot set host environment variables, so the override cannot be forged by the repo. The runner logs every effective value in its start event; only the env var name is ever logged, never the key itself.
The spawn path enforces the capability: requesting any of these keys for an adapter that does not declare SUPPORTS_SAMPLING_PARAMS raises SamplingParamsRefusal instead of silently dropping the parameters. See ensure_sampling_params_supported in src/bernstein/adapters/plugin_sdk.py.
Where these values come from¶
Three sources feed the per-spawn slots above, in ascending precedence:
- Mode profile - the resolved
ModeProfilefor the spawn carries optionaltemperature,top_p,top_k, andmax_tokens. They are folded in only when the target adapter declaresSUPPORTS_SAMPLING_PARAMS, so a default profile never breaks a spawn on an adapter that cannot honour sampling. role_model_policy[<role>]- a role may setbase_urlandapi_key_envinbernstein.yamlto target an OpenAI-compatible endpoint other than the default.api_key_envis the NAME of an environment variable, validated at parse time against the same fail-closed credential allowlist described above; a rejected name failsbernstein.yamlparsing rather than reaching a spawn.- Explicit
mcp_config- an operator-set value always wins over the two derived sources; the merge only fills slots the operator left unset.
A run that sets none of these behaves exactly as before.
Builtin tool source¶
By default the runner gives the agent the tools brokered by the Bernstein MCP gateway (tool_source: "gateway"). Some runs execute with no MCP gateway reachable - an isolated worktree with no bridge, a minimal offline environment - and would otherwise have no sanctioned way to act on the workdir at all.
Setting tool_source: "builtin" in the per-spawn mcp_config (which the adapter forwards to the runner manifest) opts into a small set of builtins. They split into two confinement tiers - do not conflate them:
| Tool | Confinement | Purpose |
|---|---|---|
read_file(path) | Workdir-confined (builtin layer) | Read a UTF-8 file relative to the run workdir. |
write_file(path, content) | Workdir-confined (builtin layer) | Write UTF-8 text relative to the run workdir. |
list_dir(path) | Workdir-confined (builtin layer) | List a directory relative to the run workdir. |
run_command(argv) | Restricted process-exec primitive; filesystem confinement is the OS sandbox | Run a bare-name command inside the run workdir. |
read_file, write_file, and list_dir are workdir-confined at the builtin layer: every path argument is resolved against the run workdir, and absolute paths and .. escapes are rejected on the real (symlink-resolved) target.
run_command is not workdir-sandboxed. It runs a child process, and a child process can read and write anywhere the runner process can reach, so its TRUE filesystem confinement is the configured OS sandbox (docker/e2b/modal), not the builtin. The builtin layer applies only defense-in-depth: argv is a list run with shell=False (no shell string, no interpolation), and argv[0] must be a bare command name - absolute paths, path separators, and known shell/interpreters (sh, bash, python, env, ...) are rejected, and the survivor is resolved on PATH and run by its resolved absolute path. Because true confinement is the OS sandbox, run_command is exposed only when the run has an OS sandbox provider configured, or the operator explicitly opts in with BERNSTEIN_BUILTIN_ALLOW_RUN_COMMAND=1. Under the bare local/worktree path with no opt-in, run_command is withheld and the three file tools remain available.
The builtins never become the default; gateway stays the default and any value other than "builtin" selects it. The builtins enforce these properties, each covered by tests in tests/unit/adapters/test_openai_agents_builtins.py:
- Path confinement (file tools). Every path argument is resolved against the run workdir. Absolute paths and
..escapes are rejected: the real (symlink-resolved) target must stay inside the real workdir. - Restricted exec (
run_command).argvis a list run withshell=False(no shell string, no interpolation), andargv[0]is a bare command name resolved onPATH; absolute paths, path separators, and shell/interpreters are rejected. Availability is gated on an OS sandbox provider or the explicit opt-in. - Audited. Every call emits a
tool_calland atool_resultevent to the runner's line-delimited JSON event stream, tagged"tool_source": "builtin", with the tool name, arguments, and outcome - so a run with no MCP gateway stays auditable and replayable.
See build_builtin_tools in src/bernstein/adapters/openai_agents_builtins.py.