|
| 1 | +"""`grapharc agent --executor claude-cli` — delegate the whole loop to Claude Code. |
| 2 | +
|
| 3 | +The harness executors run grapharc's own tool loop: the model is a raw |
| 4 | +ingredient, grapharc's gate approves every call, grapharc's meter charges it. |
| 5 | +This executor is the other trade: hand the task, the workspace and a tool |
| 6 | +policy to the `claude` CLI in headless mode and let *its* agent loop do the |
| 7 | +work on the operator's subscription. What grapharc keeps is the frame — the |
| 8 | +workspace boundary, the wall-clock ceiling enforced from outside, the tool |
| 9 | +allow/deny handed down, and a trace of what came back. |
| 10 | +
|
| 11 | +Named honestly in the output as `delegated`: the tools are Claude Code's, the |
| 12 | +permission granularity is Claude Code's, and the token figure is what the |
| 13 | +sub-agent *reports*, not what grapharc metered inline. Coarser governance, |
| 14 | +bought deliberately, for the backend that cannot be driven as a raw model |
| 15 | +(`ClaudeCodeCLIChatModel` has no `bind_tools` — the CLI exposes a finished |
| 16 | +agent, not a tool-calling completion API). |
| 17 | +
|
| 18 | +Tool names here are Claude Code's (`Read`, `Glob`, `Grep`, `Edit`, `Write`, |
| 19 | +`Bash`, …), not grapharc's seven — they are what `--allowedTools` understands. |
| 20 | +""" |
| 21 | + |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import json |
| 25 | +import shutil |
| 26 | +import subprocess |
| 27 | +import uuid |
| 28 | +from pathlib import Path |
| 29 | + |
| 30 | +from grapharc.cli import style |
| 31 | +from grapharc.cli.output import EXIT_FAILED, EXIT_OK, emit, fail |
| 32 | + |
| 33 | +#: What a bare run may use, mirroring the harness default of "the core tools, |
| 34 | +#: shell included". An explicit `--allow` replaces this outright. |
| 35 | +DEFAULT_DELEGATED_TOOLS = ("Read", "Glob", "Grep", "LS", "Edit", "Write", "Bash") |
| 36 | + |
| 37 | + |
| 38 | +def run_delegated( |
| 39 | + task: str, |
| 40 | + *, |
| 41 | + model_spec: str | None, |
| 42 | + workspace: Path, |
| 43 | + trace_path: Path | None, |
| 44 | + allow: list[str] | None, |
| 45 | + deny: list[str] | None, |
| 46 | + ask: list[str] | None, |
| 47 | + max_turns: int, |
| 48 | + max_seconds: float | None, |
| 49 | + system_prompt: str | None, |
| 50 | + run_id: str | None, |
| 51 | + as_json: bool, |
| 52 | +) -> int: |
| 53 | + """One `claude -p` run inside the workspace. Returns the exit code.""" |
| 54 | + from grapharc.observe.trace import TraceRecorder |
| 55 | + |
| 56 | + if ask: |
| 57 | + return fail( |
| 58 | + "--ask needs a human at a prompt; the delegated executor is headless " |
| 59 | + "by construction — use --allow/--deny", |
| 60 | + as_json=as_json, |
| 61 | + command="agent", |
| 62 | + ) |
| 63 | + |
| 64 | + binary = shutil.which("claude") |
| 65 | + if binary is None: |
| 66 | + return fail( |
| 67 | + "the delegated executor shells out to `claude`, which is not on PATH; " |
| 68 | + "install Claude Code or use --executor sandbox with a tool-calling backend", |
| 69 | + as_json=as_json, |
| 70 | + command="agent", |
| 71 | + ) |
| 72 | + |
| 73 | + model_arg: list[str] = [] |
| 74 | + if model_spec and model_spec.startswith("claude-cli/"): |
| 75 | + model_arg = ["--model", model_spec.removeprefix("claude-cli/")] |
| 76 | + elif model_spec: |
| 77 | + return fail( |
| 78 | + f"--executor claude-cli runs the Claude Code CLI; --model must be " |
| 79 | + f"claude-cli/<name> or omitted, got {model_spec!r}", |
| 80 | + as_json=as_json, |
| 81 | + command="agent", |
| 82 | + ) |
| 83 | + |
| 84 | + workspace = Path(workspace).expanduser().resolve() |
| 85 | + workspace.mkdir(parents=True, exist_ok=True) |
| 86 | + trace_path = Path(trace_path) if trace_path else workspace / "trace.jsonl" |
| 87 | + run_id = run_id or f"agent-{uuid.uuid4().hex[:8]}" |
| 88 | + |
| 89 | + allowed = list(allow) if allow and allow != ["*"] else list(DEFAULT_DELEGATED_TOOLS) |
| 90 | + argv = [ |
| 91 | + binary, |
| 92 | + "-p", |
| 93 | + task, |
| 94 | + "--output-format", |
| 95 | + "json", |
| 96 | + "--max-turns", |
| 97 | + str(max_turns), |
| 98 | + "--allowedTools", |
| 99 | + ",".join(allowed), |
| 100 | + *model_arg, |
| 101 | + ] |
| 102 | + if deny: |
| 103 | + argv += ["--disallowedTools", ",".join(deny)] |
| 104 | + if system_prompt: |
| 105 | + argv += ["--append-system-prompt", system_prompt] |
| 106 | + |
| 107 | + trace = TraceRecorder(trace_path) |
| 108 | + trace.event( |
| 109 | + run_id=run_id, |
| 110 | + graph="cli-agent", |
| 111 | + node="claude_code", |
| 112 | + phase="start", |
| 113 | + step=1, |
| 114 | + state_delta={"executor": "delegated", "allowed": allowed, "denied": deny or []}, |
| 115 | + ) |
| 116 | + |
| 117 | + try: |
| 118 | + completed = subprocess.run( |
| 119 | + argv, |
| 120 | + cwd=workspace, |
| 121 | + capture_output=True, |
| 122 | + text=True, |
| 123 | + timeout=max_seconds, |
| 124 | + ) |
| 125 | + except subprocess.TimeoutExpired: |
| 126 | + trace.event( |
| 127 | + run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1, |
| 128 | + state_delta={"termination_reason": "deadline_exceeded"}, |
| 129 | + ) |
| 130 | + return fail( |
| 131 | + f"max_seconds ({max_seconds:g}) reached; the delegated run was stopped", |
| 132 | + as_json=as_json, |
| 133 | + command="agent", |
| 134 | + code=EXIT_FAILED, |
| 135 | + run_id=run_id, |
| 136 | + trace=str(trace_path), |
| 137 | + ) |
| 138 | + |
| 139 | + try: |
| 140 | + report = json.loads(completed.stdout) |
| 141 | + except (json.JSONDecodeError, ValueError): |
| 142 | + trace.event( |
| 143 | + run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1, |
| 144 | + state_delta={"termination_reason": "unreadable_report"}, |
| 145 | + ) |
| 146 | + detail = (completed.stderr or completed.stdout or "").strip()[-500:] |
| 147 | + return fail( |
| 148 | + f"claude exited {completed.returncode} without a readable JSON report: {detail}", |
| 149 | + as_json=as_json, |
| 150 | + command="agent", |
| 151 | + code=EXIT_FAILED, |
| 152 | + run_id=run_id, |
| 153 | + trace=str(trace_path), |
| 154 | + ) |
| 155 | + |
| 156 | + usage = report.get("usage") or {} |
| 157 | + tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0) |
| 158 | + turns = int(report.get("num_turns") or 0) |
| 159 | + met = report.get("subtype") == "success" and not report.get("is_error", False) |
| 160 | + reason = "target_met" if met else str(report.get("subtype") or "error") |
| 161 | + answer = str(report.get("result") or "").strip() |
| 162 | + |
| 163 | + trace.event( |
| 164 | + run_id=run_id, graph="cli-agent", node="claude_code", phase="end", step=1, |
| 165 | + state_delta={ |
| 166 | + "turns": turns, |
| 167 | + "tokens_reported": tokens, |
| 168 | + "cost_usd": report.get("total_cost_usd"), |
| 169 | + "session_id": report.get("session_id"), |
| 170 | + }, |
| 171 | + ) |
| 172 | + trace.event( |
| 173 | + run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1, |
| 174 | + state_delta={"termination_reason": reason}, |
| 175 | + ) |
| 176 | + |
| 177 | + payload = { |
| 178 | + "ok": met, |
| 179 | + "command": "agent", |
| 180 | + "task": task, |
| 181 | + "model": model_arg[1] if model_arg else "claude-cli default", |
| 182 | + "run_id": run_id, |
| 183 | + "workspace": str(workspace), |
| 184 | + "trace": str(trace_path), |
| 185 | + "executor": "delegated", |
| 186 | + "policy": {"allow": allowed, "deny": deny or []}, |
| 187 | + "termination_reason": reason, |
| 188 | + "turns": turns, |
| 189 | + "tokens_reported": tokens, |
| 190 | + "cost_usd": report.get("total_cost_usd"), |
| 191 | + "answer": answer, |
| 192 | + } |
| 193 | + |
| 194 | + width = style.LABEL_WIDTH |
| 195 | + lines = [ |
| 196 | + style.kv("task", task, width=width), |
| 197 | + style.kv("executor", "delegated (Claude Code's own loop and tools)", width=width), |
| 198 | + style.kv("model", payload["model"], width=width, tint=style.accent), |
| 199 | + style.kv("workspace", str(workspace), width=width, tint=style.accent), |
| 200 | + style.kv( |
| 201 | + "policy", |
| 202 | + f"{style.dim('allow=')}{allowed} {style.dim('deny=')}{deny or []}", |
| 203 | + width=width, |
| 204 | + ), |
| 205 | + "", |
| 206 | + style.kv("stopped", (style.ok if met else style.warn)(reason), width=width), |
| 207 | + style.kv( |
| 208 | + "turns", |
| 209 | + f"{turns} {style.dim('tokens (reported):')} {tokens:,}", |
| 210 | + width=width, |
| 211 | + ), |
| 212 | + "", |
| 213 | + style.kv("answer", answer or "(empty)", width=width), |
| 214 | + style.kv("trace", str(trace_path), width=width, tint=style.accent), |
| 215 | + ] |
| 216 | + emit(payload, lines, as_json=as_json) |
| 217 | + return EXIT_OK if met else EXIT_FAILED |
| 218 | + |
| 219 | + |
| 220 | +__all__ = ["DEFAULT_DELEGATED_TOOLS", "run_delegated"] |
0 commit comments