Guardrails
The rules that do not bend, and what each one is protecting against.
These are constraints on the system, not style preferences. Each exists because the failure it prevents is expensive.
Architecture is not changed silently
A boundary moved without discussion is a boundary nobody can rely on. Propose it first.
The model gets no direct access
No database connection, no raw HTTP client, no network. It receives tool results that a Python service has already authorised, validated and sanitised.
Every external call is bounded
A timeout and explicit error handling on every call. Only idempotent reads may retry. Tool steps, model iterations, retries and retrieval size all carry hard limits — there is no unrestricted agent loop. Every one of them is listed below, with the setting that configures it.
Nothing is claimed until it is confirmed
An action is reported as successful only after the upstream PlanCover API says so. "It looks like that went through" is not an acceptable answer about a claim.
Typed schemas at every boundary
Pydantic models in, Pydantic models out. Routes hold no business logic; graph definitions hold no data access.
State stays out of the process
Per-user and per-conversation state lives in Postgres. The process is disposable, which is what makes horizontal scaling and clean restarts possible.
The bounds
Every bound is on an edge, in a node, or in the executor — never in a prompt. A model cannot be asked nicely to stop.
| Bound | Setting | Default | Enforced by |
|---|---|---|---|
| Tool loop iterations | GRAPH_MAX_TOOL_STEPS | 4 | State counter, checked on the edge out of gather |
| Model calls per turn | GRAPH_MAX_MODEL_CALLS | 6 | State counter, checked at the top of gather |
| Framework backstop | GRAPH_RECURSION_LIMIT | 24 | Top-level recursion_limit; the framework default of 1000 is not a backstop |
| Per-node ceiling | NODE_TIMEOUT_SECONDS | 45 | add_node(timeout=…) — except confirm, which waits on a human |
| Per-tool timeout | TOOL_TIMEOUT_SECONDS | 10 | The executor, overridable per tool |
| Concurrent tool calls | TOOL_MAX_PARALLEL | 3 | A per-run semaphore; PlanCover allows 60 requests a minute |
| Tool result size | TOOL_MAX_RESULT_CHARS | 4000 | The sanitizer, on the way to the model |
| History replayed | MAX_HISTORY_TURNS / MAX_HISTORY_CHARS | 10 / 8000 | The context builder, at prompt-build time |
| Evidence used | MAX_EVIDENCE_CHUNKS / MAX_EVIDENCE_CHARS | 6 / 6000 | The RAG service and the context builder |
| Chunks retrieved | RETRIEVAL_TOP_K | 8 | The RAG service, capped by MAX_EVIDENCE_CHUNKS |
| Answer length | MAX_RESPONSE_TOKENS | 1024 | A model argument |
| Request size | — | 8000 chars, 50 turns | Pydantic, at the HTTP boundary |
| Messages per member | RATE_LIMIT_MESSAGES_PER_MINUTE | 20 | A fixed-window counter in Postgres |
| Confirmation window | CONFIRMATION_TTL_SECONDS | 600 | Checked before the pause, after it, and at the confirm endpoint |
| Heartbeat interval | SSE_HEARTBEAT_SECONDS | 15 | The SSE layer |
| Disconnect grace | DRAIN_GRACE_SECONDS | 5 | Drain first, then a hard cancel |
Running out of a budget is never a hang. A spent tool-step budget answers every outstanding tool call with a notice and routes to the answer; a spent model-call budget winds the loop down deliberately rather than waiting for the recursion backstop, which would be a hard error. The backstop sits above both, so it is only ever reached by a bug.
Security-sensitive code
Authentication, sessions, the tool executor, sanitizers and webhook handlers are flagged for human review before they merge. Automated review is a supplement, never the gate.