Skip to content

Commit 4c31921

Browse files
Add a delegated executor: agent --executor claude-cli
The claude CLI cannot be driven as a raw model (no bind_tools; it exposes a finished agent, not a tool-calling completion API), so give it the other role: --executor claude-cli hands the task, workspace and tool policy to Claude Code's headless loop on the operator's subscription. What grapharc keeps is the frame — workspace boundary, wall clock enforced from outside, --allow/--deny mapped to --allowedTools/--disallowedTools (names are Claude Code's), and start/end/stop recorded to the trace with the reported turn and token figures. The output names the trade: executor "delegated", tokens "(reported)" — coarser governance, bought deliberately. --ask is refused headless (nobody to ask); a non-claude-cli --model is refused rather than silently ignored, except the openrouter default, which argparse makes indistinguishable from --model being omitted. From Slack, --executor accepts sandbox and claude-cli, never local, and a delegated run with no explicit tool globs gets --deny Bash injected: an unsandboxed host shell is not something a bare Slack message should carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a7fed81 commit 4c31921

7 files changed

Lines changed: 432 additions & 13 deletions

File tree

docs/cookbook/07-slack.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,26 @@ What the gate does to every Slack-launched agent, non-negotiably:
164164
`--allow` / `--deny` tool globs pass through and are repeatable; deny beats
165165
allow, as in the CLI.
166166

167+
### The delegated executor
168+
169+
`--executor claude-cli` hands the whole task to Claude Code's own headless
170+
agent on the operator's subscription — no API key, no `bind_tools`, because
171+
the loop and the tools are Claude Code's, not grapharc's:
172+
173+
```
174+
@grapharc agent "summarise the markdown here" --executor claude-cli --workspace .
175+
```
176+
177+
Honest trade, stated plainly: governance is coarser (Claude Code's permission
178+
model, not grapharc's per-call gate), and the token figure is what the
179+
sub-agent reports rather than what a meter charged inline. The frame stays
180+
grapharc's — workspace confined, wall clock enforced from outside, the run
181+
recorded to the trace. From Slack, tool names are Claude Code's (`Read`,
182+
`Grep`, `Edit`, `Bash`, …), and a delegated run with no explicit
183+
`--allow`/`--deny` gets `--deny Bash` injected: an unsandboxed shell on the
184+
host is not something a bare Slack message should carry. `--executor local`
185+
stays unreachable from Slack.
186+
167187
## The honest caveats
168188

169189
- **The bot is alive while the process is.** Laptop lid closed means commands

grapharc/cli/agent.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,28 @@ def run_agent(
139139
an exhausted budget, an error — exits 1, because a script that ran an agent
140140
needs to know the task was not finished without parsing the reason first.
141141
"""
142+
if executor == "claude-cli":
143+
# The whole loop is Claude Code's; nothing below (registry, harness,
144+
# gateway model) applies. `--model` semantics shift too: the delegated
145+
# run cannot use the openrouter default, so only an explicit
146+
# claude-cli/<name> is forwarded.
147+
from grapharc.cli.delegate import run_delegated
148+
149+
return run_delegated(
150+
task,
151+
model_spec=None if model_spec == DEFAULT_MODEL else model_spec,
152+
workspace=workspace,
153+
trace_path=trace_path,
154+
allow=allow,
155+
deny=deny,
156+
ask=ask,
157+
max_turns=max_turns,
158+
max_seconds=max_seconds,
159+
system_prompt=system_prompt,
160+
run_id=run_id,
161+
as_json=as_json,
162+
)
163+
142164
from grapharc.harness import AgentConfigError, AgentNode, Harness, LocalExecutor
143165
from grapharc.harness.agent import DEFAULT_SYSTEM_PROMPT
144166
from grapharc.observe.trace import TraceRecorder

grapharc/cli/delegate.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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"]

grapharc/cli/main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -807,9 +807,13 @@ def build_parser() -> argparse.ArgumentParser:
807807
)
808808
agent.add_argument(
809809
"--executor",
810-
choices=("sandbox", "local"),
810+
choices=("sandbox", "local", "claude-cli"),
811811
default="sandbox",
812-
help="local runs tools in this process with no confinement (default: sandbox)",
812+
help=(
813+
"local runs tools in this process with no confinement; claude-cli "
814+
"delegates the whole loop to Claude Code's headless agent on your "
815+
"subscription (default: sandbox)"
816+
),
813817
)
814818
agent.add_argument("--system-prompt", default=None)
815819
agent.set_defaults(handler=_cmd_agent)

grapharc/slack/command.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ class CommandSpec:
108108
"--max-seconds": False,
109109
},
110110
model_flags=frozenset({"--model"}),
111+
# `local` (no confinement) stays unreachable; `claude-cli` delegates to
112+
# Claude Code's own sandboxed loop, which the injection below tempers.
113+
choice_flags={"--executor": frozenset({"sandbox", "claude-cli"})},
111114
),
112115
"replay": CommandSpec(path_positionals=frozenset({0})),
113116
"diff": CommandSpec(path_positionals=frozenset({0})),
@@ -219,9 +222,7 @@ def parse_command(
219222
index += 2
220223
if flag in spec.choice_flags and value not in spec.choice_flags[flag]:
221224
allowed = ", ".join(f"`{v}`" for v in sorted(spec.choice_flags[flag]))
222-
raise SlackCommandError(
223-
f"`{flag}` accepts only the shipped registries from Slack: {allowed}"
224-
)
225+
raise SlackCommandError(f"`{flag}` from Slack accepts only: {allowed}")
225226
if is_path:
226227
_confined(value, workdir)
227228
argv.extend([flag, value])
@@ -244,5 +245,12 @@ def parse_command(
244245
# to just under the timeout so the graceful mechanism fires first.
245246
if "--max-seconds" not in argv and timeout_seconds is not None:
246247
argv.extend(["--max-seconds", str(max(5.0, timeout_seconds - 10.0))])
248+
# A delegated run uses Claude Code's tools, and its Bash is a real
249+
# shell on the host with no grapharc sandbox around it. From Slack
250+
# that defaults off; a requester who set explicit globs made a
251+
# deliberate policy and keeps it (deny still beats allow downstream).
252+
delegated = "--executor" in argv and argv[argv.index("--executor") + 1] == "claude-cli"
253+
if delegated and "--allow" not in argv and "--deny" not in argv:
254+
argv.extend(["--deny", "Bash"])
247255

248256
return argv

0 commit comments

Comments
 (0)