Bernstein Configuration Reference
July 25, 2026 · View on GitHub
Bernstein configuration comes from three places:
bernstein.yaml(project seed/run config).sdd/config.yaml(workspace runtime defaults, created bybernstein init)- Environment variables (
BERNSTEIN_*)
This document focuses on practical settings contributors actually use today.
All magic numbers, timeouts, and thresholds are centralized in src/bernstein/core/defaults.py and can be overridden via the tuning: section in bernstein.yaml or via environment variables.
1) Project config: bernstein.yaml
bernstein.yaml is the main run-time input. Typical keys include:
goalcells(number of parallel work cells; work is decomposed fromgoal)workspacerole_model_policylocal_endpointsstoragenotify(webhook/email/desktop notification settings)network(IP allowlist)tuning(override defaults fromcore/defaults.py)
Unknown top-level keys are ignored but logged: the parser emits a warning
naming the unrecognised key and, where possible, the closest known key
(for example tasks suggests cells). A typo such as max_agnets no
longer silently drops the setting; check the run log if a section seems
to have no effect.
Minimal example:
goal: "Implement API auth and integration tests"
role_model_policy:
backend:
cli: qwen
model: coder-model
effort: high
security:
cli: gemini
model: gemini-pro
effort: high
# Internal LLM for orchestrator scheduling decisions (plan decomposition,
# cost estimation, auto-decompose). Accepts ANY supported adapter name.
internal_llm_provider: gemini
internal_llm_model: gemini-pro
internal_llm_provider - Orchestrator scheduling model
The orchestrator uses a lightweight LLM for internal decisions: task decomposition, cost estimation, difficulty scoring, and plan optimization. This is not the agent - it's the scheduler's brain.
Any registered adapter CLI can serve as the internal LLM provider:
| Provider | Model example | Notes |
|---|---|---|
gemini | gemini-pro | Free tier, 1M context, strong reasoning |
qwen | coder-model | Free via Qwen OAuth, good for coding tasks |
claude | claude-sonnet-4-6 | Strongest reasoning, requires Claude Code CLI |
codex | gpt-5-mini | OpenAI models via Codex CLI |
ollama | deepseek-r1:70b | Fully local, no API calls |
goose | claude-sonnet-4-6 | Block's Goose CLI |
aider | claude-sonnet-4-6 | Aider CLI (any provider backend) |
openrouter | nvidia/nemotron-3-super-120b-a12b | API-based, requires OPENROUTER_API_KEY |
Set via bernstein.yaml:
internal_llm_provider: gemini # adapter name
internal_llm_model: gemini-pro # model passed to the CLI
Or via environment:
export BERNSTEIN_INTERNAL_LLM_PROVIDER=qwen
export BERNSTEIN_INTERNAL_LLM_MODEL=coder-model
role_model_policy - Per-role agent configuration
Each role can use a different CLI adapter and model:
role_model_policy:
manager:
cli: qwen # which CLI agent to spawn
model: coder-model # model name passed to that CLI
effort: max # controls max_turns and budget
backend:
cli: gemini
model: gemini-pro
effort: high
docs:
cli: claude
model: sonnet
effort: medium
When cli: auto is set at the top level, the orchestrator picks the best available adapter per role based on the role_model_policy. When a specific cli: is set per role, that adapter is used exclusively for that role.
2) Workspace runtime defaults: .sdd/config.yaml
Created by:
bernstein init
Typical defaults:
- server port
- max workers/agents
- default model/effort
This file is local runtime state; bernstein.yaml remains the portable project config.
3) Environment variables
Environment variables are useful in CI and automation. Common variables:
| Variable | Purpose |
|---|---|
BERNSTEIN_BIND_HOST | Server bind address (default 127.0.0.1) |
BERNSTEIN_PORT | Server port (default 8052) |
BERNSTEIN_STORAGE_BACKEND | Storage backend (memory, postgres, redis) |
BERNSTEIN_DATABASE_URL | PostgreSQL DSN for postgres/redis backends |
BERNSTEIN_REDIS_URL | Redis URL for distributed locking backend |
BERNSTEIN_SKIP_GATES | Skip selected quality gates |
BERNSTEIN_SKIP_GATE_REASON | Audit reason when gates are skipped |
BERNSTEIN_WORKFLOW | Workflow mode override |
BERNSTEIN_ROUTING | Routing policy override |
BERNSTEIN_COMPLIANCE | Compliance preset override |
BERNSTEIN_QUIET | Quiet mode (reduced terminal output) |
BERNSTEIN_AUDIT | Enable extra audit behavior in run flow |
BERNSTEIN_STALL_THRESHOLD_S | Override the manager-stall deadline (see §4.2 below). Takes precedence over the tuning.orchestrator.stalled_manager_threshold_s yaml key. |
BERNSTEIN_MAX_TURNS | Override the SDK turn ceiling for the openai_agents runner (see §4.1). Takes precedence over tuning.agent.max_turns. |
BERNSTEIN_JANITOR_FUZZY_PATHS | Opt-in fuzzy matching for path_exists completion signals. Default 0 (off): exact-path matching is unchanged - the check means exactly the path written in the plan. Set to 1 to enable a fuzzy fallback on a literal miss: a pattern derived from the basename (**/<stem>*<suffix>) so a manager-guessed path (e.g. packages/db/test/foo.test.ts) can match a worker's repo-idiomatic path (e.g. packages/db/src/__tests__/foo.test.ts). A fuzzy match logs a WARNING naming the matched path. Explicit glob syntax written in the criterion itself (*, ?, [) is always honored, regardless of this flag. See TROUBLESHOOTING.md #21. |
BERNSTEIN_JANITOR_REOPEN_MAX | Max janitor-reopen cycles per task before a janitor-FAILed task is permanently failed instead of reopened under the same id. Default 2. A non-numeric value logs a warning and falls back to the default; negative values are clamped to 0 (fail immediately, never reopen). |
BERNSTEIN_QUIESCENCE_SETTLE_S | Settle delay (seconds) the orchestrator waits after a tick reaches quiescence (all tasks terminal) before the re-check that decides whether to self-stop. Default 2.0. Set 0 to skip the settle sleep entirely (used by tests to avoid a real per-tick delay). |
BERNSTEIN_STALLED_RUN_GRACE_S | Seconds the task-state fingerprint must stay unchanged before a quiescent run that produced zero terminal tasks is stopped (see §4.3 below). Default 1800.0. Takes precedence over tuning.orchestrator.stalled_run_grace_s. |
BERNSTEIN_STALLED_RUN_TICKS | Consecutive no-progress quiescent ticks required alongside the grace window before that run is stopped (see §4.3). Default 10. Takes precedence over tuning.orchestrator.stalled_run_ticks. |
BERNSTEIN_RUN_COMMAND_DEDUPE_WINDOW_S | Dedupe window (seconds) for the run_command builtin: an identical command (same hash) still in flight or finished within this window reuses the original's result instead of re-executing. Default 2.0. Set 0 to disable the dedupe guard entirely. |
BERNSTEIN_OPENAI_AGENTS_TOOL_SOURCE | Operator lever for the openai_agents runner's tool source. Set to builtin to make every spawn use the runner's workdir-sandboxed builtin tools (read_file/write_file/list_dir/run_command) even when the per-spawn mcp_config carries no tool_source key. An explicit mcp_config["tool_source"] value always wins over this env var. Unset by default. |
4) Tunable defaults: core/defaults.py
All magic numbers live in src/bernstein/core/defaults.py as typed dataclasses. Override at runtime via the tuning: section in bernstein.yaml:
tuning:
orchestrator:
tick_interval_s: 5.0 # default 3.0 (ORCHESTRATOR.tick_interval_s in core/defaults.py)
drain_timeout_s: 120.0
stale_claim_timeout_s: 1800.0
stalled_manager_threshold_s: 300.0
max_agent_runtime_s: 3600.0
spawn:
spawn_backoff_base_s: 60.0
agent:
max_turns: 200
slo:
error_budget_min_failures: 10
Key default groups:
| Group | Examples |
|---|---|
OrchestratorDefaults | tick_interval_s, drain_timeout_s, max_consecutive_failures, stale_claim_timeout_s, stalled_manager_threshold_s, max_agent_runtime_s, stalled_run_grace_s, stalled_run_ticks |
SpawnDefaults | spawn_backoff_base_s, spawn_backoff_max_s, max_spawn_failures |
TaskDefaults | Retry limits, deadline windows |
AgentDefaults | Heartbeat intervals, max dead agents kept, max_turns |
MemoryChainDefaults | default_scope (user/agent/run/app), retention_days (reporting horizon only; the append-only chain is always retained in full) |
SLODefaults | error_budget_min_failures |
4.1) Agent run-length limits (SDK turns, error budget, wall-clock)
Three previously-hardcoded ceilings on the openai_agents adapter path are
now tunable, with defaults unchanged so nothing breaks for existing users:
max_turns(AgentDefaults.max_turns, default30) - forwarded to the OpenAI Agents SDK'sRunner.run_sync(..., max_turns=...). The runner forwardsmax_turns=30by default; settuning.agent.max_turnstoNoneto omit the kwarg and fall back to the SDK's own default (10). A task that legitimately needs more turns beforeMaxTurnsExceeded(large-repo investigation, multi-file refactors) can raise this viatuning.agent.max_turns: 200or theBERNSTEIN_MAX_TURNSenv var (checked first).BERNSTEIN_MAX_TURNSmust parse as a positive integer -0, a negative value, or a non-numeric string is rejected with a warning and falls through totuning.agent.max_turns/the SDK default, rather than being forwarded toRunner.run_syncwhere it would fail every run on the first turn.error_budget_min_failures(SLODefaults.error_budget_min_failures, default3) - the floorErrorBudget.budget_totalnever goes below, even whentotal_tasks * (1 - slo_target)rounds lower. Raise it viatuning.slo.error_budget_min_failureswhen a run's early failures are infra-death retries (rate limits, transient auth) that shouldn't tripIncidentManager's auto-pause. A negative override is clamped to0rather than being allowed to suppress the SLO-target-derived budget.max_agent_runtime_s(OrchestratorDefaults.max_agent_runtime_s, default1800) - the starting wall-clock kill deadline for a spawned agent, now sourced throughOrchestratorConfigvia the sametuning.orchestrator.*mechanism asstale_claim_timeout_s. This is only the starting value - the agent lifecycle reaper already self-extends it by 600s per cycle up to a 5400s hard cap while the agent keeps heartbeating.
See defaults.py for the full list of parameters and their default values.
4.2) Manager-stall deadline
core/orchestration/stalled_manager.py aborts a run when the manager agent
has been alive for stalled_manager_threshold_s (default 170.0) with zero
child tasks posted to the task server. On a larger codebase, or a goal whose
acceptance criteria demand root-cause investigation before decomposition, the
manager can legitimately need longer than that before its first POST /tasks
call. Raise the deadline via either:
tuning:
orchestrator:
stalled_manager_threshold_s: 900.0
or the BERNSTEIN_STALL_THRESHOLD_S env var (checked first, so it overrides
the yaml value for a single run without editing bernstein.yaml). Precedence:
env var > yaml tuning.orchestrator.stalled_manager_threshold_s > the
170.0 default. Each tier must resolve to a positive, finite number of
seconds - an unparseable, zero, negative, nan, or inf value at a given
tier is rejected with a warning and falls through to the next tier, rather
than silently disabling the watchdog (fires on turn one) or making it a
permanent no-op.
If the resolved threshold reaches or exceeds AGENT.idle_log_age_threshold_s
(180.0 by default - a separate, currently-unwired idle-agent watchdog), a
warning is logged, since that watchdog would race the stalled-manager
diagnostic if it is ever wired into the tick loop. This check runs for the
env var, yaml, and default resolution paths alike, so raising the threshold
via BERNSTEIN_STALL_THRESHOLD_S - the primary way an operator raises it -
surfaces the warning too. The resolution (and this check) happens once per
orchestrator instance, not on every tick.
4.3) Zero-terminal run backstop
The orchestrator's ordinary self-stop fires when a tick reaches quiescence
(open_tasks == active_agents == 0) and at least one task has reached
done or failed. That gate is what stops tick #1 of a fresh run being read
as "the run finished". It also means a run that reaches quiescence having
finished nothing has no exit at all: it idles until the container is torn
down, and reports the run as healthy.
core/orchestration/run_stall.py supplies the missing terminal state. It
stops the run only when every one of the following holds:
- the tick is quiescent with zero
doneand zerofailedtasks; - at least one task is declared and at least one is actively unfinished
(
open,claimed,in_progress,orphaned) - an empty backlog is the pre-ingest startup window, and tasks that are only parked (planned,suspended,pending_approval,blocked,waiting_for_subtasks) are waiting by design; - no task is deferred by retry backoff (an
opentask with a futurecreated_at), since that work is still scheduled to arrive; - the
(task_id, status)fingerprint of the whole task set has been unchanged forstalled_run_grace_s(default1800.0); and - that same fingerprint has been observed on at least
stalled_run_ticks(default10) consecutive qualifying ticks, so a single wall-clock jump cannot end a run on its own.
An active hold (POST /orchestrator/holds) suppresses the stop exactly as it
suppresses the ordinary self-stop.
On a confirmed stall the unfinished tasks are marked failed with an explicit
reason, the final retrospective is regenerated, and the run stops. The report
therefore states that the goal was not met rather than tallying 0/0 and
reading healthy.
tuning:
orchestrator:
stalled_run_grace_s: 3600.0
stalled_run_ticks: 20
or the BERNSTEIN_STALLED_RUN_GRACE_S / BERNSTEIN_STALLED_RUN_TICKS env
vars, which are checked first.
The 1800.0 default is positioned against two fixed points rather than
picked round. It sits above stale_claim_timeout_s (900.0) so the
stale-claim release always gets its chance first - that path produces a real
failed task carrying its own reason, which is a strictly more informative
outcome - and below the CLI's default wait for run completion (3600)
so a synchronous bernstein run observes a genuine terminal state instead of
timing out against a still-idling orchestrator. Raising it past the CLI wait
reintroduces the second symptom.
Storage backends
Bernstein supports:
memory(default, JSONL persistence)postgresredis(Postgres + Redis locking topology)
bernstein.yaml example:
storage:
backend: redis
database_url: postgresql://user:pass@localhost/bernstein
redis_url: redis://localhost:6379
You can validate effective connectivity with:
bernstein doctor
Telemetry and metrics
- Prometheus metrics are exposed via
GET /metricson the server. - OTLP endpoint wiring exists via telemetry configuration in the core config model.
Treat telemetry as configurable: enabled only when endpoint/settings are provided.
Notifications
Seed-level notification settings support:
- webhook notifications
- SMTP email notifications
- optional desktop notifications
These are consumed by the notification manager and task lifecycle hooks.
Network and safety controls
Configuration surface includes:
- IP allowlist (
network.allowed_ips) - role/model routing policy
- quality-gate controls
- audit controls
For security-sensitive deployments, prefer explicit config in bernstein.yaml over implicit defaults.
Bootstrap and merge safety
GitHub backlog auto-sync (opt-in, off by default)
At bootstrap, Bernstein can pull open GitHub issues into .sdd/backlog/open/
before task scoping. This is opt-in and off by default so it cannot
silently fill the backlog and displace a seeded goal.
Enable it in bernstein.yaml:
github:
sync_backlog: true # default: false
Or override at runtime (wins over the config value):
BERNSTEIN_SYNC_GITHUB_BACKLOG=1 # truthy enables; falsy (0/false/off) disables
When disabled (the default), no issues are synced.
Goal-vs-backlog precedence
When a run starts, the planning source is chosen in this order:
- Prior session - resume and skip re-planning.
- Non-empty backlog - run the backlog tasks.
- Seed goal - inject the manager/worker task from
goal.
So a non-empty backlog takes precedence over the seed goal. This is kept for back-compat with backlog-driven runs, but Bernstein now prints a LOUD warning when a seeded goal is skipped because the backlog is non-empty. To run the goal instead:
- start from an empty backlog (clear
.sdd/backlog/open/), or - narrow the run with the
BERNSTEIN_TASK_FILTERsentinel so no backlog task matches.
Default-branch merge guard
Agent worktree work is merged into whatever branch is checked out at the repo
root. Bernstein refuses to merge or push agent work onto the repository's
default (protected) branch (main/master, resolved via origin/HEAD with
a init.defaultBranch fallback). The refusal is recorded to
.sdd/runtime/refused_merges.jsonl and the merge is reported as failed rather
than silently landing unreviewed commits on the trunk.
bernstein run also checks this before spawning any agent: when the
default branch is checked out and the override below is unset, the run aborts
immediately instead of letting every agent work and then discarding the
result at merge time. Modes that never merge back (--dry-run,
--plan-only) are unaffected. If a refusal still happens mid-run, it is
printed in the end-of-run summary and the run health verdict is downgraded to
UNHEALTHY.
Check out a non-default branch before merging, or opt in explicitly:
BERNSTEIN_ALLOW_MERGE_TO_DEFAULT_BRANCH=1
Always-allow rules (.bernstein/always_allow.yaml)
Always-allow rules short-circuit approval prompts when a tool invocation matches a known-safe signature. For example, allowing grep on src/* paths while still asking or denying grep on /etc.
Precedence: Always-allow rules take highest precedence - a match overrides any ASK or DENY from other guardrails. IMMUNE and SAFETY decisions (e.g. secret detection, immune-path enforcement) are never overridden.
Rule schema
# .bernstein/always_allow.yaml
- id: safe-grep-src # unique kebab-case ID
tool: grep # tool name to match (case-insensitive)
input_pattern: "src/.*" # regex (if contains .* or ^ or $) or glob
input_field: path # which arg to match against (default: path)
content_patterns: # optional: ALL must be present in full_content
- "--include=*.py"
- "--recursive"
description: "Recursive Python grep on src/ only"
Alternatively, embed under an always_allow: key in .bernstein/rules.yaml.
Pattern syntax
| Pattern | Interpreted as |
|---|---|
src/.* | Regex (contains .*) |
^tests/ | Regex (anchored) |
tests/* | Glob |
content_patterns
When content_patterns is specified, all listed strings must appear in the full tool invocation content for the rule to fire. This enables narrower constraints, e.g. allowing grep only when both --include=*.py and the path are present.
Precedence summary
IMMUNE / SAFETY - never overridden (secrets, immune paths)
ALWAYS-ALLOW - overrides ASK and DENY when a rule matches
DENY - blocks by rule
ASK - prompts user
ALLOW - default pass-through
Runtime bridges
Bernstein's production bridge configuration lives in bernstein.yaml.
The supported runtime bridge surface is:
bridges:
openclaw:
enabled: true
url: wss://gateway.openclaw.ai/ws
api_key: ${OPENCLAW_API_KEY}
agent_id: bernstein-shared-workspace
workspace_mode: shared_workspace
fallback_to_local: true
connect_timeout_s: 10
request_timeout_s: 30.0
session_prefix: bernstein-
max_log_bytes: 1048576
model_override: null
bridges.openclaw is opt-in and intended for shared_workspace deployments
only. Bernstein remains the scheduler, verification owner, and merge owner;
OpenClaw executes against the same repository or shared filesystem that
Bernstein can inspect locally.
Field reference:
| Field | Meaning |
|---|---|
enabled | Turn the bridge on for orchestration runs |
url | OpenClaw Gateway WebSocket URL (ws:// or wss://) |
api_key | Gateway API key; ${VAR} environment substitution is supported |
agent_id | OpenClaw agent identifier to target |
workspace_mode | Must be shared_workspace in the current implementation |
fallback_to_local | Allow local CLI fallback only if bridge spawn fails before remote acceptance |
connect_timeout_s | Gateway connect/auth timeout |
request_timeout_s | Per-run wait timeout used by the bridge client |
session_prefix | Prefix for Bernstein-owned remote session keys |
max_log_bytes | Maximum log bytes returned by bridge log reads |
model_override | Optional model pin forwarded to the bridge request |
Notes on older bridge docs
Older notes that mention .bernstein/config.toml, bridge-specific extra
fields, or OpenClaw /v1/sandboxes REST semantics are obsolete. The repo-truth
production config surface is bernstein.yaml + .sdd/config.yaml +
BERNSTEIN_* environment variables, and the OpenClaw runtime path is the
Gateway WebSocket bridge implemented in Bernstein.
local_endpoints - Local-model worker tier
Named OpenAI-compatible endpoint profiles for local runtimes (ollama, LM
Studio, MLX servers). Role entries reference a profile by name and inherit
its base_url/model/api_key_env:
local_endpoints:
workhorse:
base_url: http://127.0.0.1:11434/v1
model: qwen2.5-coder:7b-instruct-q4_K_M
engine: ollama # free-form label recorded in the certification receipt
timeout: 120 # request timeout in seconds
# api_key_env: LOCAL_LLM_API_KEY # NAME of an env var, never a literal key
role_model_policy:
linter: { endpoint: workhorse }
test_writer: { endpoint: workhorse }
Low-stakes roles (linter, test_writer, triage, doc_sweeper) run on a
profile without further ceremony. Every other role is merge-critical and
requires a signed certification receipt produced by
bernstein doctor --endpoint <url>; an uncertified profile assigned to a
gated role fails config validation with the exact command to run. See
Local endpoints for the conformance
subset, role tiers, and verified configurations.
Provider availability (provider_availability)
Per-role provider fallback chains with conformance floors. Before dispatch
the scheduler health-probes the declared chain (probe results cached for
probe_ttl_minutes) and picks the first healthy element; the decision is
recorded as a routing receipt in the audit chain.
provider_availability:
probe_ttl_minutes: 5
probes_enabled: true # false for offline runs
roles:
developer:
conformance_floor: advanced
chain:
- {adapter: claude, model: opus, conformance: expert}
- {adapter: codex, model: gpt-5.2, conformance: advanced}
A chain element whose conformance is below the role's
conformance_floor fails config validation. Validate declared chains with
bernstein doctor --failover-drill. Full guide:
Provider availability & failover.
Fleet config plane (#2550)
Three named-configuration primitives share one discipline: every write, rotation, and activation is an audit-chain event, every claim-time read is pinned into task lineage, and history, divergence, and replay all resolve from the chain rather than from live state. All three surfaces work offline; none require a running server.
Variables - bernstein var
A fleet variable is a named JSON value whose identity is its audit-chain segment.
bernstein var set threshold 5 # records a fleet.var_set chain event
bernstein var set threshold 9 # old->new value hashes chained
bernstein var get threshold # -> 9
bernstein var history threshold # every write, offline from the chain
Each set records the old and new value hashes plus a per-name write
ordinal. A claim-time read (in the run path) pins (name, value_hash, chain_position) into the reading task's lineage spine, and replay resolves
that pinned hash - never the live value - so a run replayed after N further
mutations reads byte-identically. When two workers read different values,
var history explains it from the chain alone: the write that landed
between their two pinned positions. Values live under
.sdd/fleet/variables/ (content-addressed blobs plus a name index).
Connection documents - bernstein conn
A connection document is a typed, named record (prod-github, team-slack)
that task specs reference by name. It carries no secret material - only a
broker secret reference, a scope, and connector defaults - and is signed
with the local Ed25519 install identity.
bernstein conn create prod-github --secret github_pat --scope repo:read
bernstein conn list
bernstein conn rotate prod-github --secret github_pat_v2 # zero spec edits
bernstein conn audit prod-github # every resolving task, offline
Rotation re-points every consumer at the next mint. Resolution runs only through the secrets-broker mint path, so the raw secret is minted into a short-lived token and registered for redaction; it never reaches an agent environment or artifact. A document copied to another install refuses to resolve (its signature does not verify against the copy's local identity), and the refusal is itself an audit event. See secrets-broker.md.
Operating contexts - bernstein ctx
An operating context atomically pins server URL, store DSN, adapter
defaults, and a budget-envelope name as one named unit. (The context
command name is taken by the worker context-capsule surface (#2545), so this
fleet surface is ctx.)
bernstein ctx create staging --server-url https://staging --set budget=42
bernstein ctx use staging # atomic activation, recorded on the chain
bernstein ctx show # the active context + its settings hash
bernstein ctx list
Activation inserts one context layer into the precedence chain, between
the project and global layers (see the precedence note below), and records a
fleet.context_activate event embedding the canonical effective-settings
hash. Two installs with identical context content produce a byte-identical
canonical document and an equal hash, so a run carries a configuration
identity a verifier recomputes; config drift becomes a detected hash
divergence with a named cause. With no context active, the existing
four-layer precedence and the bernstein profile perf command are
unchanged. Contexts live under .sdd/fleet/contexts/, with active.json
recording the activation.
Precedence with a context active (highest first): session > project > context > global > default.
Verification
bernstein audit verify covers all three families through the HMAC chain
and an additional semantic pillar that checks variable write-ordinal
contiguity, value-hash lineage, and connection reference integrity. See
audit-log.md.