T2 complete: OODA loop skeleton with LLM integration, bounded actions, and 32 offline unit tests. Deliverables: - runtime/agent.py — CLI entry point (--domain/--all/--dry-run/--llm) - runtime/context.py — Observe: fetch_state + build_context - runtime/actions.py — Act: parse_plan + execute (3 sanctioned writes) - runtime/README.md — usage guide and architecture overview - runtime/tests/ — 32 tests, fully offline - runtime/pyproject.toml — standalone package with llm-connect dep - canon/architecture/adr-002-custodian-agent-runtime-design.md Key design decisions (ADR-002): - Lives in runtime/ (not a new repo) — tight canon/state-hub coupling - ClaudeCodeAdapter by default (local-first, no API key) - Single-pass synchronous OODA for v0.1 simplicity - Exactly 3 sanctioned write ops: add_progress_event, update_task_status, flag_for_human - LLM returns JSON block in markdown for structured+auditable output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4.7 KiB
id, type, title, status, decided_by, date, tags
| id | type | title | status | decided_by | date | tags | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| ADR-002 | architecture-decision-record | Custodian Agent Runtime — v0.1 Bootstrap Design | accepted | Bernd Worsch | 2026-03-12 |
|
ADR-002: Custodian Agent Runtime — v0.1 Bootstrap Design
Status
Accepted.
Context
CUST-WP-0001 requires a first working skeleton of the Custodian as an acting agent: a loop that observes project state, reasons about it, and executes bounded write operations — without human interaction for each step.
The dependencies (llm-connect, operational Railiance infra) are now resolved. This ADR captures the five key architectural decisions for the v0.1 bootstrap.
Decisions
D1 — Location: runtime/ inside the-custodian (not a new repo)
Decision: The runtime lives under the-custodian/runtime/ as a standalone
Python package (pyproject.toml, own venv) rather than a new repository.
Rationale: The runtime is tightly coupled to canon (reads constitution,
memory) and the state-hub (its primary coordination layer). A separate repo
adds friction with no v0.1 benefit. The existing runtime/ scaffold confirms
the original intent. Extraction to its own repo is deferred to when the
runtime has stable boundaries and multiple consumers.
D2 — OODA loop: single-pass synchronous CLI
Decision: One run() call = one complete Observe → Orient → Decide → Act
cycle. Entry point is a CLI (agent.py) invoked manually or by a cron job.
Observe — HTTP GET to state-hub: state summary or domain summary
Orient — Load constitution + build structured LLM context prompt
Decide — Single LLM call (via llm-connect) returns a JSON action plan
Act — Execute only sanctioned write operations from the plan
Rationale: Async event loops and daemons add operational complexity that v0.1 doesn't need. A single-pass CLI is testable, debuggable, and can be scheduled externally. The transition to an event-driven loop is Phase 2.
D3 — LLM backend: ClaudeCodeAdapter by default
Decision: The runtime uses llm_connect.ClaudeCodeAdapter as its default
LLM backend (shells out to claude --print). Provider is configurable via
--llm flag to support gemini, openrouter, or openai.
Rationale: ClaudeCodeAdapter requires no API key and honours the
Local-First value (V2). All current deployments have Claude Code available.
The llm-connect abstraction means switching providers is a one-line change.
D4 — Action constraint: three sanctioned write operations only
Decision: The agent may execute exactly three state-hub write operations without human approval:
add_progress_event— append an observation to the event logupdate_task_status— mark a task done/in_progress (reversible)flag_for_human— raise an intervention flag (escalation, not action)
All other operations (create workstream, record decision, resolve decision, write to canon) require human approval before execution.
Rationale: Constitution §3/§4 require bounded agency. The three operations are either append-only (progress events), reversible (task status), or explicitly escalating (flag). They cannot produce irreversible harm.
D5 — LLM response format: JSON block in markdown
Decision: The LLM is prompted to return a Markdown response with a fenced ```json block containing the structured action plan:
{
"observations": ["..."],
"progress_events": [
{"summary": "...", "workstream_id": "...", "event_type": "note"}
],
"tasks_to_update": [
{"task_id": "...", "status": "done"}
],
"tasks_to_flag": [
{"task_id": "...", "note": "..."}
]
}
The surrounding Markdown is preserved as a human-readable reasoning trace
and written to memory/working/ as a session note.
Rationale: JSON blocks are robust to extraction (delimited), LLMs produce them reliably with clear instructions, and the surrounding prose gives Bernd an auditable reasoning trace without requiring a separate reasoning step.
Consequences
runtime/becomes a standalone Python package;make agent-run DOMAIN=xinvokes it.- The runtime has no DB schema changes and no new API endpoints — it is a pure client of the existing state-hub HTTP API.
- Autonomous actions are limited to append-only writes and escalations. Any expansion of the action surface requires a new ADR and human approval.
- The v0.1 loop is single-user (Bernd). Multi-agent expansion is Phase 2+.
Deferred
- Async event loop / daemon mode (Phase 2)
- RAG over canon (Phase 1 roadmap item)
- Tool adapters beyond state-hub HTTP (planned in
runtime/tool_adapters/) - Deployment on Railiance k3s as a scheduled CronJob