Files
the-custodian/runtime/agent.py
tegwick 2fdbcb5d7a feat(CUST-WP-0001): implement Custodian Agent Runtime bootstrap
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>
2026-03-12 22:36:24 +01:00

135 lines
4.8 KiB
Python

"""Custodian Agent Runtime — single OODA cycle entry point.
Usage:
uv run python agent.py --domain custodian
uv run python agent.py --all
uv run python agent.py --domain custodian --dry-run
uv run python agent.py --domain custodian --llm gemini
The agent runs one complete Observe → Orient → Decide → Act cycle and exits.
All output is printed to stdout. Errors are non-fatal where possible.
See ADR-002 for architecture decisions.
"""
from __future__ import annotations
import argparse
import datetime
import sys
from pathlib import Path
from actions import execute, parse_plan
from context import API_BASE, build_context, fetch_state, load_constitution
try:
from llm_connect import RunConfig, create_adapter
_HAS_LLM_CONNECT = True
except ImportError:
_HAS_LLM_CONNECT = False
def run(
domain: str | None,
dry_run: bool,
llm_provider: str,
api_base: str = API_BASE,
) -> int:
"""Execute one OODA cycle. Returns exit code (0 = ok, 1 = error)."""
scope = f"domain={domain}" if domain else "all domains"
print(f"[custodian-agent] {datetime.datetime.now().isoformat(timespec='seconds')} scope={scope}")
# --- Observe ---
print("[observe] fetching state from state-hub…")
state = fetch_state(domain=domain, api_base=api_base)
if not state:
print("[observe] WARNING: state-hub unreachable or returned empty state. "
"Proceeding with empty context (graceful degradation).")
# --- Orient ---
print("[orient] loading constitution and building context…")
constitution = load_constitution()
context = build_context(state, constitution)
# --- Decide ---
if not _HAS_LLM_CONNECT:
print("[decide] ERROR: llm-connect not available. Run `uv sync` first.", file=sys.stderr)
return 1
print(f"[decide] calling LLM via provider={llm_provider!r}")
try:
adapter = create_adapter(llm_provider)
config = RunConfig(temperature=0.3, max_tokens=2000)
llm_response = adapter.execute_prompt(context, config)
response_text = llm_response.content
except Exception as exc:
print(f"[decide] ERROR: LLM call failed: {exc}", file=sys.stderr)
return 1
# Save reasoning trace to working memory
_save_session_note(response_text, domain)
# --- Act ---
plan = parse_plan(response_text)
mode = "dry-run" if dry_run else "live"
print(f"[act] executing plan ({mode}): "
f"{len(plan['progress_events'])} events, "
f"{len(plan['tasks_to_update'])} task updates, "
f"{len(plan['tasks_to_flag'])} flags")
results = execute(plan, api_base=api_base, dry_run=dry_run)
for r in results:
print(f" {r}")
print(f"[custodian-agent] done — {len(results)} actions.")
return 0
def _save_session_note(response_text: str, domain: str | None) -> None:
"""Append the LLM reasoning trace to working memory (append-only)."""
memory_dir = Path(__file__).parent.parent / "memory" / "working"
if not memory_dir.exists():
return
timestamp = datetime.datetime.now().strftime("%Y-%m-%dT%H%M%S")
scope = domain or "all"
note_path = memory_dir / f"agent-session-{timestamp}-{scope}.md"
try:
note_path.write_text(
f"---\ntype: agent-session-note\nscope: {scope}\ntimestamp: {timestamp}\n---\n\n"
+ response_text,
encoding="utf-8",
)
except Exception:
pass # Non-fatal — memory write failure does not stop the cycle
def main() -> None:
parser = argparse.ArgumentParser(
description="Custodian Agent — single OODA cycle (Observe→Orient→Decide→Act)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run python agent.py --domain custodian
uv run python agent.py --domain custodian --dry-run
uv run python agent.py --all --llm gemini
""",
)
scope = parser.add_mutually_exclusive_group(required=True)
scope.add_argument("--domain", metavar="SLUG",
help="Focus on a single domain (e.g. custodian, railiance)")
scope.add_argument("--all", action="store_true",
help="Run over full cross-domain state summary")
parser.add_argument("--dry-run", action="store_true",
help="Print planned actions without executing them")
parser.add_argument("--llm", default="claude-code", metavar="PROVIDER",
help="LLM provider: claude-code (default), gemini, openrouter, openai")
parser.add_argument("--api-base", default=API_BASE, metavar="URL",
help=f"State-hub API base URL (default: {API_BASE})")
args = parser.parse_args()
domain = args.domain if not args.all else None
sys.exit(run(domain, args.dry_run, args.llm, args.api_base))
if __name__ == "__main__":
main()