|
25 | 25 | import shutil |
26 | 26 | import subprocess |
27 | 27 | import uuid |
| 28 | +from dataclasses import dataclass |
28 | 29 | from pathlib import Path |
29 | 30 |
|
30 | 31 | from grapharc.cli import style |
|
35 | 36 | DEFAULT_DELEGATED_TOOLS = ("Read", "Glob", "Grep", "LS", "Edit", "Write", "Bash") |
36 | 37 |
|
37 | 38 |
|
| 39 | + |
| 40 | +# --------------------------------------------------------------------------- |
| 41 | +# The reusable core. `run_delegated` below is the CLI's presentation of it, and |
| 42 | +# `grapharc.harness.agent.AgentNode` calls it directly when its backend is the |
| 43 | +# Claude CLI — one implementation, so the two paths cannot drift on what |
| 44 | +# actually gets spawned. |
| 45 | + |
| 46 | + |
| 47 | +class DelegationError(Exception): |
| 48 | + """A delegated run could not be started, or came back unreadable. |
| 49 | +
|
| 50 | + `reason` is the machine-readable form, written to the trace so a run that |
| 51 | + failed here is distinguishable afterwards from one that ran and refused. |
| 52 | + """ |
| 53 | + |
| 54 | + def __init__(self, message: str, *, reason: str) -> None: |
| 55 | + super().__init__(message) |
| 56 | + self.reason = reason |
| 57 | + |
| 58 | + |
| 59 | +@dataclass(frozen=True) |
| 60 | +class DelegatedRun: |
| 61 | + """What Claude Code reported back. Every number here is *its* figure. |
| 62 | +
|
| 63 | + `tokens_reported` is named for what it is: the sub-agent's own count, not |
| 64 | + something GraphARC metered call by call. Nothing in this object was |
| 65 | + observed by the permission engine. |
| 66 | + """ |
| 67 | + |
| 68 | + ok: bool |
| 69 | + answer: str |
| 70 | + reason: str |
| 71 | + turns: int |
| 72 | + tokens_reported: int |
| 73 | + cost_usd: float | None |
| 74 | + session_id: str | None |
| 75 | + allowed: list[str] | None # None == no --allowedTools restriction at all |
| 76 | + denied: list[str] |
| 77 | + |
| 78 | + |
| 79 | +def delegate_task( |
| 80 | + task: str, |
| 81 | + *, |
| 82 | + workspace: Path, |
| 83 | + model: str | None = None, |
| 84 | + allow: list[str] | None = None, |
| 85 | + deny: list[str] | None = None, |
| 86 | + max_turns: int = 20, |
| 87 | + max_seconds: float | None = None, |
| 88 | + system_prompt: str | None = None, |
| 89 | + permission_mode: str | None = None, |
| 90 | +) -> DelegatedRun: |
| 91 | + """Run one headless `claude -p` agent loop in `workspace`. |
| 92 | +
|
| 93 | + Two separate axes, and conflating them is a trap worth naming. `allow` |
| 94 | + controls *which* tools exist; `permission_mode` controls whether the ones |
| 95 | + that mutate anything are allowed to run without a human answering a prompt. |
| 96 | + Omitting `--allowedTools` does **not** mean "every tool": it means Claude |
| 97 | + Code's own default gating, and headless there is nobody to approve a Write, |
| 98 | + so the sub-agent reports back that it could not create the file. Measured, |
| 99 | + not assumed. `permission_mode="bypassPermissions"` is what actually means |
| 100 | + "everything", and it means it literally — no checks at all. |
| 101 | +
|
| 102 | + Either way the caller is responsible for having said so out loud; |
| 103 | + `AgentNode` warns at construction and marks every trace event. |
| 104 | + """ |
| 105 | + binary = shutil.which("claude") |
| 106 | + if binary is None: |
| 107 | + raise DelegationError( |
| 108 | + "the delegated executor shells out to `claude`, which is not on PATH; " |
| 109 | + "install Claude Code or use a tool-calling backend", |
| 110 | + reason="claude_not_found", |
| 111 | + ) |
| 112 | + |
| 113 | + workspace = Path(workspace).expanduser().resolve() |
| 114 | + workspace.mkdir(parents=True, exist_ok=True) |
| 115 | + |
| 116 | + argv = [binary, "-p", task, "--output-format", "json", "--max-turns", str(max_turns)] |
| 117 | + if allow is not None: |
| 118 | + argv += ["--allowedTools", ",".join(allow)] |
| 119 | + if deny: |
| 120 | + argv += ["--disallowedTools", ",".join(deny)] |
| 121 | + if permission_mode: |
| 122 | + argv += ["--permission-mode", permission_mode] |
| 123 | + if model: |
| 124 | + argv += ["--model", model] |
| 125 | + if system_prompt: |
| 126 | + argv += ["--append-system-prompt", system_prompt] |
| 127 | + |
| 128 | + try: |
| 129 | + completed = subprocess.run( |
| 130 | + argv, cwd=workspace, capture_output=True, text=True, timeout=max_seconds |
| 131 | + ) |
| 132 | + except subprocess.TimeoutExpired as exc: |
| 133 | + raise DelegationError( |
| 134 | + f"max_seconds ({max_seconds}) reached; the delegated run was stopped", |
| 135 | + reason="deadline_exceeded", |
| 136 | + ) from exc |
| 137 | + |
| 138 | + try: |
| 139 | + report = json.loads(completed.stdout) |
| 140 | + except (json.JSONDecodeError, ValueError) as exc: |
| 141 | + detail = (completed.stderr or completed.stdout or "").strip()[-500:] |
| 142 | + raise DelegationError( |
| 143 | + f"claude exited {completed.returncode} without a readable JSON report: {detail}", |
| 144 | + reason="unreadable_report", |
| 145 | + ) from exc |
| 146 | + |
| 147 | + usage = report.get("usage") or {} |
| 148 | + met = report.get("subtype") == "success" and not report.get("is_error", False) |
| 149 | + return DelegatedRun( |
| 150 | + ok=met, |
| 151 | + answer=str(report.get("result") or "").strip(), |
| 152 | + reason="target_met" if met else str(report.get("subtype") or "error"), |
| 153 | + turns=int(report.get("num_turns") or 0), |
| 154 | + tokens_reported=int(usage.get("input_tokens") or 0) |
| 155 | + + int(usage.get("output_tokens") or 0), |
| 156 | + cost_usd=report.get("total_cost_usd"), |
| 157 | + session_id=report.get("session_id"), |
| 158 | + allowed=list(allow) if allow is not None else None, |
| 159 | + denied=list(deny or []), |
| 160 | + ) |
| 161 | + |
| 162 | + |
38 | 163 | def run_delegated( |
39 | 164 | task: str, |
40 | 165 | *, |
@@ -217,4 +342,10 @@ def run_delegated( |
217 | 342 | return EXIT_OK if met else EXIT_FAILED |
218 | 343 |
|
219 | 344 |
|
220 | | -__all__ = ["DEFAULT_DELEGATED_TOOLS", "run_delegated"] |
| 345 | +__all__ = [ |
| 346 | + "DEFAULT_DELEGATED_TOOLS", |
| 347 | + "DelegatedRun", |
| 348 | + "DelegationError", |
| 349 | + "delegate_task", |
| 350 | + "run_delegated", |
| 351 | +] |
0 commit comments