Choosing Your Configuration¶
Every knob in Civitas answers a question about your failure modes and load profile. This page is the decision guide: which setting, when, and why — with the trade-off you're accepting.
Restart strategy — "what should die together?"¶
| Your children are… | Strategy | Example |
|---|---|---|
| Independent — one's crash means nothing to the others | ONE_FOR_ONE (default, most common) |
A pool of scraper agents; unrelated service agents |
| Interdependent — they share warm state or a session and must reconverge together | ONE_FOR_ALL |
An agent + its dedicated cache agent; a trio that renegotiates a session on start |
| A pipeline — downstream output depends on upstream state | REST_FOR_ONE |
ingest → enrich → publish: if enrich crashes, publish must restart too, but ingest keeps running |
Rule of thumb: start with ONE_FOR_ONE. Reach for the others only when a restarted child
would produce wrong results because a sibling kept its old state.
supervision:
name: pipeline
strategy: REST_FOR_ONE
children:
- agent: { name: ingest, type: myapp.Ingest }
- agent: { name: enrich, type: myapp.Enrich }
- agent: { name: publish, type: myapp.Publish }
Restart budget & backoff — "how hard should we try?"¶
max_restarts / restart_window is the supervisor-wide intensity budget: more than
max_restarts crashes inside restart_window seconds ⇒ escalate to the parent (which restarts
this whole subtree with a fresh budget — v0.8.0).
| Situation | Setting | Why |
|---|---|---|
| Crashes are transient (network blips, rate limits) | max_restarts=5, restart_window=60, backoff="EXPONENTIAL" |
Ride out the blip; exponential + jitter prevents thundering-herd re-crashes |
| Crashes indicate bad input that will recur | Keep the budget small (max_restarts=2) |
Fail fast upward — restarts won't fix a poison message; handle it in on_error with SKIP instead |
| A child talks to a slow-to-recover dependency (DB failover ~30 s) | backoff="EXPONENTIAL", backoff_base=2.0, backoff_max=30.0 |
Restarting before the dependency is back just burns budget |
| Dev / tests | backoff="CONSTANT", backoff_base=0.01 |
Fast feedback |
Escalation is a ladder, not a cliff: child supervisor exhausts → parent restarts the subtree (fresh budget) → repeated subtree failures burn the parent's budget → and so on to the root. Nest supervisors so blast radius grows one level at a time.
handle_timeout — "can this agent hang?"¶
Opt-in per agent; off by default.
| Agent profile | Setting |
|---|---|
| Calls external services with client-side timeouts already configured | Leave off — double timeouts add confusion |
| LLM/tool chains with no reliable client timeout | handle_timeout ≈ 2–3× your slowest legitimate chain |
| Message-loop workers that should always be sub-second | handle_timeout=5.0 — cheap insurance against a stuck await |
Streaming producers (stream_reply) |
Leave off, or set ≥ max stream duration — the timeout bounds the whole handle() |
It catches stuck awaits only — blocking code never yields, so it's invisible to the watchdog (see Messaging → hazards).
Error handling — "what does this failure mean?"¶
on_error() is your failure classifier. Map exception → meaning → action:
async def on_error(self, error: Exception, message: Message) -> ErrorAction:
if isinstance(error, RateLimitError):
await asyncio.sleep(2 ** message.attempt) # backoff, then ordered in-place retry
return ErrorAction.RETRY
if isinstance(error, ValidationError):
logger.warning("poison message %s dropped", message.id)
return ErrorAction.SKIP # restarts won't fix bad input
if isinstance(error, TimeoutError): # handle_timeout fired
return ErrorAction.ESCALATE # hung = corrupted; let it crash
return ErrorAction.ESCALATE # unknown = crash (the safe default)
| Action | Use when | Cost you accept |
|---|---|---|
RETRY |
Transient, likely to succeed soon | Agent blocked between attempts (FIFO preserved) |
SKIP |
Poison input; restart can't help | The message's work never happens |
ESCALATE (default) |
Unknown / state may be corrupted | Restart: un-checkpointed state is discarded |
STOP |
The agent's job is done or unrecoverable by restart | Agent leaves the tree gracefully |
Transport — "how far apart are your agents?"¶
| Level | Transport | Choose when | You give up |
|---|---|---|---|
| 1 | in_process (default) |
One Python process is enough; dev, tests, most single-node apps | Nothing — start here |
| 2 | zmq |
Multi-process on one machine: isolate a CPU-heavy or crash-prone agent into a Worker | Brokerless — you manage the proxy addresses |
| 3 | nats |
Multi-machine, or you want a broker with reconnect/durability (jetstream: true) |
Running a NATS server |
The same agent code runs at every level — switch in topology.yaml, not in code. Move up a level
when: (a) one agent's CPU work stalls others (Level 1 → 2), or (b) you need horizontal scale or
machine-level fault isolation (→ 3).
State persistence — "what must survive?"¶
The contract: only checkpointed state survives a restart (details).
| Need | Store | Notes |
|---|---|---|
| Survive supervisor restarts (same process) | InMemoryStateStore (default) |
Zero setup; dies with the process |
| Survive process restarts / deploys | sqlite (contrib) |
Single node; give Workers the same db_path |
| Survive node loss; shared across machines | postgres (contrib) |
The production choice for distributed trees |
| State contains secrets / PII at rest | wrap with EncryptingStateStore (civitas[encryption]) |
Same CIVITAS_STATE_KEY on every process; key loss = data loss |
Checkpoint after completing a unit of work, not on every mutation — the checkpoint is your recovery point, and a half-done unit checkpointed is a half-done unit restored.
Mailbox sizing — "what's your burst profile?"¶
mailbox_size (default 1000) is your backpressure valve: a full mailbox blocks senders.
- Bursty producers, steady consumer: size ≥ your worst burst, or the burst back-propagates.
- Steady flow: default is fine; if the mailbox trends full, the agent is under-provisioned — scale with dynamic spawning or a Worker, don't just widen the buffer.
- Never size it "huge to be safe": a 100k-deep mailbox is 100k messages of latency and a restart-survival liability. Bounded-and-blocking is a feature; see backpressure hazards.
Static children vs. dynamic spawning — "is the population fixed?"¶
| Static children | DynamicSupervisor + spawn() |
|
|---|---|---|
| Population | Known at deploy time | Per-task / per-tenant / elastic |
| Config | Topology YAML | await self.spawn(WorkerAgent, f"job-{id}", config={...}) |
| Governance | — | max_children, per-spawner quotas, spawner_allowlist, on_spawn_requested veto hook |
Use dynamic spawning for fan-out work (one child per document/job/session); cap it: set
max_children to what your process can actually host, and per-spawner quotas
(max_children_per_spawner) when multiple agents share the pool. Prefer wait=False
(spawn_nowait) for bulk spawning — a slow on_start() otherwise blocks the spawn call.
Suspension — "when do humans gate the loop?"¶
runtime.suspend(name, reason) / resume(name, approver) is the built-in HITL primitive:
- Use it for: approval gates before irreversible actions; incident freeze ("stop that agent now, keep its mailbox"); staged rollouts.
- Semantics: takes effect at the next message boundary (never mid-
handle()); business messages buffer (senders eventually feel backpressure); heartbeats still ack'd (suspended ≠ crashed); the marker is checkpointed, so restarts stay suspended;resume()requires a named approver — that's your audit trail. - Don't use it as flow control — that's what bounded mailboxes are for.
await runtime.suspend("trader", reason="daily-loss-limit-hit")
# ... human reviews ...
await runtime.resume("trader", approver="risk-officer@example.com")
GenServer vs. AgentProcess — "is it an agent or a service?"¶
AgentProcess |
GenServer |
|
|---|---|---|
| Identity | Autonomous actor: reasons, calls LLMs/tools | Stateful service: registry, counter, session store, rate-limiter |
| API | handle() + send/ask |
handle_call (sync) / handle_cast (async) / handle_info (timers via send_after) |
| LLM/tools injected | Yes | No |
If you're writing if message.payload["op"] == "get": ... elif op == "put": ... inside an
AgentProcess, you wanted a GenServer.
Security — "which tier are you deploying?"¶
| Deployment | Turn on |
|---|---|
| Single process, trusted host | Nothing — in_process never crosses a wire |
| Multi-process/machine on a shared network | security.signing (Ed25519 per-agent message signing) + transport encryption (ZMQ CURVE / NATS TLS) |
| Exposed HTTP/WS/gRPC edge | Gateway auth: require_jwt and/or mTLS (client_cert_mode / mtls_source: proxy_header behind a TLS-terminating proxy) — see Gateway |
| Compliance / forensics | audit: sink (JSONL file, syslog, or OTLP) — every route, spawn, suspend/resume, and secret access is an event |
| Secrets in topology YAML | ${VAR} substitution — never literals in the file |
Observability — "console, or collector?"¶
- Dev: nothing to configure — spans print to console.
- Prod: set
OTEL_EXPORTER_OTLP_ENDPOINT→ Jaeger/Grafana/anything OTLP. Zero code changes. - Watch for:
civitas.agent.retryspans (transient-failure rate),supervisor.restartspans (crash rate — alert on bursts),civitas.handle.timeout=trueattribute (hung vs. buggy).
Worked profile: a production research service¶
transport: { type: nats, url: nats://prod:4222, jetstream: true }
plugins:
state: { type: postgres, config: { dsn: ${DATABASE_URL} } }
models:
- { type: anthropic, config: { default_model: claude-sonnet-4-6 } }
security:
signing: { enabled: true }
audit: { type: jsonl, path: /var/log/civitas/audit.jsonl }
supervision:
name: root
strategy: ONE_FOR_ONE # top level: services are independent
max_restarts: 5
restart_window: 60
backoff: EXPONENTIAL
children:
- agent: { name: api, type: myapp.Gateway } # HTTPGateway subclass
- supervisor:
name: research # the pipeline dies together
strategy: REST_FOR_ONE
children:
- agent: { name: planner, type: myapp.Planner, handle_timeout: 120 }
- agent: { name: researcher, type: myapp.Researcher, handle_timeout: 300, process: worker }
- agent: { name: writer, type: myapp.Writer, handle_timeout: 120 }
- name: jobs # elastic fan-out pool
type: dynamic_supervisor
max_children: 50
max_children_per_spawner: 10
Why each choice: NATS+JetStream (multi-machine, broker durability) · Postgres store (state
survives node loss) · signing on (shared network) · REST_FOR_ONE for the pipeline (writer output
depends on researcher state) · researcher in a Worker (heaviest CPU/IO, isolate it) ·
handle_timeout sized per stage · dynamic pool capped at what a node hosts.