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>
135 lines
4.8 KiB
Python
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()
|