Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/cookbook/07-slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,26 @@ What the gate does to every Slack-launched agent, non-negotiably:
`--allow` / `--deny` tool globs pass through and are repeatable; deny beats
allow, as in the CLI.

### The delegated executor

`--executor claude-cli` hands the whole task to Claude Code's own headless
agent on the operator's subscription — no API key, no `bind_tools`, because
the loop and the tools are Claude Code's, not grapharc's:

```
@grapharc agent "summarise the markdown here" --executor claude-cli --workspace .
```

Honest trade, stated plainly: governance is coarser (Claude Code's permission
model, not grapharc's per-call gate), and the token figure is what the
sub-agent reports rather than what a meter charged inline. The frame stays
grapharc's — workspace confined, wall clock enforced from outside, the run
recorded to the trace. From Slack, tool names are Claude Code's (`Read`,
`Grep`, `Edit`, `Bash`, …), and a delegated run with no explicit
`--allow`/`--deny` gets `--deny Bash` injected: an unsandboxed shell on the
host is not something a bare Slack message should carry. `--executor local`
stays unreachable from Slack.

## The honest caveats

- **The bot is alive while the process is.** Laptop lid closed means commands
Expand Down
22 changes: 22 additions & 0 deletions grapharc/cli/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,28 @@ def run_agent(
an exhausted budget, an error — exits 1, because a script that ran an agent
needs to know the task was not finished without parsing the reason first.
"""
if executor == "claude-cli":
# The whole loop is Claude Code's; nothing below (registry, harness,
# gateway model) applies. `--model` semantics shift too: the delegated
# run cannot use the openrouter default, so only an explicit
# claude-cli/<name> is forwarded.
from grapharc.cli.delegate import run_delegated

return run_delegated(
task,
model_spec=None if model_spec == DEFAULT_MODEL else model_spec,
workspace=workspace,
trace_path=trace_path,
allow=allow,
deny=deny,
ask=ask,
max_turns=max_turns,
max_seconds=max_seconds,
system_prompt=system_prompt,
run_id=run_id,
as_json=as_json,
)

from grapharc.harness import AgentConfigError, AgentNode, Harness, LocalExecutor
from grapharc.harness.agent import DEFAULT_SYSTEM_PROMPT
from grapharc.observe.trace import TraceRecorder
Expand Down
220 changes: 220 additions & 0 deletions grapharc/cli/delegate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""`grapharc agent --executor claude-cli` — delegate the whole loop to Claude Code.

The harness executors run grapharc's own tool loop: the model is a raw
ingredient, grapharc's gate approves every call, grapharc's meter charges it.
This executor is the other trade: hand the task, the workspace and a tool
policy to the `claude` CLI in headless mode and let *its* agent loop do the
work on the operator's subscription. What grapharc keeps is the frame — the
workspace boundary, the wall-clock ceiling enforced from outside, the tool
allow/deny handed down, and a trace of what came back.

Named honestly in the output as `delegated`: the tools are Claude Code's, the
permission granularity is Claude Code's, and the token figure is what the
sub-agent *reports*, not what grapharc metered inline. Coarser governance,
bought deliberately, for the backend that cannot be driven as a raw model
(`ClaudeCodeCLIChatModel` has no `bind_tools` — the CLI exposes a finished
agent, not a tool-calling completion API).

Tool names here are Claude Code's (`Read`, `Glob`, `Grep`, `Edit`, `Write`,
`Bash`, …), not grapharc's seven — they are what `--allowedTools` understands.
"""

from __future__ import annotations

import json
import shutil
import subprocess
import uuid
from pathlib import Path

from grapharc.cli import style
from grapharc.cli.output import EXIT_FAILED, EXIT_OK, emit, fail

#: What a bare run may use, mirroring the harness default of "the core tools,
#: shell included". An explicit `--allow` replaces this outright.
DEFAULT_DELEGATED_TOOLS = ("Read", "Glob", "Grep", "LS", "Edit", "Write", "Bash")


def run_delegated(
task: str,
*,
model_spec: str | None,
workspace: Path,
trace_path: Path | None,
allow: list[str] | None,
deny: list[str] | None,
ask: list[str] | None,
max_turns: int,
max_seconds: float | None,
system_prompt: str | None,
run_id: str | None,
as_json: bool,
) -> int:
"""One `claude -p` run inside the workspace. Returns the exit code."""
from grapharc.observe.trace import TraceRecorder

if ask:
return fail(
"--ask needs a human at a prompt; the delegated executor is headless "
"by construction — use --allow/--deny",
as_json=as_json,
command="agent",
)

binary = shutil.which("claude")
if binary is None:
return fail(
"the delegated executor shells out to `claude`, which is not on PATH; "
"install Claude Code or use --executor sandbox with a tool-calling backend",
as_json=as_json,
command="agent",
)

model_arg: list[str] = []
if model_spec and model_spec.startswith("claude-cli/"):
model_arg = ["--model", model_spec.removeprefix("claude-cli/")]
elif model_spec:
return fail(
f"--executor claude-cli runs the Claude Code CLI; --model must be "
f"claude-cli/<name> or omitted, got {model_spec!r}",
as_json=as_json,
command="agent",
)

workspace = Path(workspace).expanduser().resolve()
workspace.mkdir(parents=True, exist_ok=True)
trace_path = Path(trace_path) if trace_path else workspace / "trace.jsonl"
run_id = run_id or f"agent-{uuid.uuid4().hex[:8]}"

allowed = list(allow) if allow and allow != ["*"] else list(DEFAULT_DELEGATED_TOOLS)
argv = [
binary,
"-p",
task,
"--output-format",
"json",
"--max-turns",
str(max_turns),
"--allowedTools",
",".join(allowed),
*model_arg,
]
if deny:
argv += ["--disallowedTools", ",".join(deny)]
if system_prompt:
argv += ["--append-system-prompt", system_prompt]

trace = TraceRecorder(trace_path)
trace.event(
run_id=run_id,
graph="cli-agent",
node="claude_code",
phase="start",
step=1,
state_delta={"executor": "delegated", "allowed": allowed, "denied": deny or []},
)

try:
completed = subprocess.run(
argv,
cwd=workspace,
capture_output=True,
text=True,
timeout=max_seconds,
)
except subprocess.TimeoutExpired:
trace.event(
run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1,
state_delta={"termination_reason": "deadline_exceeded"},
)
return fail(
f"max_seconds ({max_seconds:g}) reached; the delegated run was stopped",
as_json=as_json,
command="agent",
code=EXIT_FAILED,
run_id=run_id,
trace=str(trace_path),
)

try:
report = json.loads(completed.stdout)
except (json.JSONDecodeError, ValueError):
trace.event(
run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1,
state_delta={"termination_reason": "unreadable_report"},
)
detail = (completed.stderr or completed.stdout or "").strip()[-500:]
return fail(
f"claude exited {completed.returncode} without a readable JSON report: {detail}",
as_json=as_json,
command="agent",
code=EXIT_FAILED,
run_id=run_id,
trace=str(trace_path),
)

usage = report.get("usage") or {}
tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
turns = int(report.get("num_turns") or 0)
met = report.get("subtype") == "success" and not report.get("is_error", False)
reason = "target_met" if met else str(report.get("subtype") or "error")
answer = str(report.get("result") or "").strip()

trace.event(
run_id=run_id, graph="cli-agent", node="claude_code", phase="end", step=1,
state_delta={
"turns": turns,
"tokens_reported": tokens,
"cost_usd": report.get("total_cost_usd"),
"session_id": report.get("session_id"),
},
)
trace.event(
run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1,
state_delta={"termination_reason": reason},
)

payload = {
"ok": met,
"command": "agent",
"task": task,
"model": model_arg[1] if model_arg else "claude-cli default",
"run_id": run_id,
"workspace": str(workspace),
"trace": str(trace_path),
"executor": "delegated",
"policy": {"allow": allowed, "deny": deny or []},
"termination_reason": reason,
"turns": turns,
"tokens_reported": tokens,
"cost_usd": report.get("total_cost_usd"),
"answer": answer,
}

width = style.LABEL_WIDTH
lines = [
style.kv("task", task, width=width),
style.kv("executor", "delegated (Claude Code's own loop and tools)", width=width),
style.kv("model", payload["model"], width=width, tint=style.accent),
style.kv("workspace", str(workspace), width=width, tint=style.accent),
style.kv(
"policy",
f"{style.dim('allow=')}{allowed} {style.dim('deny=')}{deny or []}",
width=width,
),
"",
style.kv("stopped", (style.ok if met else style.warn)(reason), width=width),
style.kv(
"turns",
f"{turns} {style.dim('tokens (reported):')} {tokens:,}",
width=width,
),
"",
style.kv("answer", answer or "(empty)", width=width),
style.kv("trace", str(trace_path), width=width, tint=style.accent),
]
emit(payload, lines, as_json=as_json)
return EXIT_OK if met else EXIT_FAILED


__all__ = ["DEFAULT_DELEGATED_TOOLS", "run_delegated"]
8 changes: 6 additions & 2 deletions grapharc/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,13 @@ def build_parser() -> argparse.ArgumentParser:
)
agent.add_argument(
"--executor",
choices=("sandbox", "local"),
choices=("sandbox", "local", "claude-cli"),
default="sandbox",
help="local runs tools in this process with no confinement (default: sandbox)",
help=(
"local runs tools in this process with no confinement; claude-cli "
"delegates the whole loop to Claude Code's headless agent on your "
"subscription (default: sandbox)"
),
)
agent.add_argument("--system-prompt", default=None)
agent.set_defaults(handler=_cmd_agent)
Expand Down
14 changes: 11 additions & 3 deletions grapharc/slack/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ class CommandSpec:
"--max-seconds": False,
},
model_flags=frozenset({"--model"}),
# `local` (no confinement) stays unreachable; `claude-cli` delegates to
# Claude Code's own sandboxed loop, which the injection below tempers.
choice_flags={"--executor": frozenset({"sandbox", "claude-cli"})},
),
"replay": CommandSpec(path_positionals=frozenset({0})),
"diff": CommandSpec(path_positionals=frozenset({0})),
Expand Down Expand Up @@ -219,9 +222,7 @@ def parse_command(
index += 2
if flag in spec.choice_flags and value not in spec.choice_flags[flag]:
allowed = ", ".join(f"`{v}`" for v in sorted(spec.choice_flags[flag]))
raise SlackCommandError(
f"`{flag}` accepts only the shipped registries from Slack: {allowed}"
)
raise SlackCommandError(f"`{flag}` from Slack accepts only: {allowed}")
if is_path:
_confined(value, workdir)
argv.extend([flag, value])
Expand All @@ -244,5 +245,12 @@ def parse_command(
# to just under the timeout so the graceful mechanism fires first.
if "--max-seconds" not in argv and timeout_seconds is not None:
argv.extend(["--max-seconds", str(max(5.0, timeout_seconds - 10.0))])
# A delegated run uses Claude Code's tools, and its Bash is a real
# shell on the host with no grapharc sandbox around it. From Slack
# that defaults off; a requester who set explicit globs made a
# deliberate policy and keeps it (deny still beats allow downstream).
delegated = "--executor" in argv and argv[argv.index("--executor") + 1] == "claude-cli"
if delegated and "--allow" not in argv and "--deny" not in argv:
argv.extend(["--deny", "Bash"])

return argv
Loading
Loading