"""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()