|
53 | 53 | import types |
54 | 54 | import typing |
55 | 55 | import uuid |
| 56 | +import warnings |
56 | 57 | from collections.abc import Callable, Mapping |
57 | 58 | from enum import StrEnum |
| 59 | +from pathlib import Path |
58 | 60 | from typing import Any |
59 | 61 |
|
60 | 62 | from langchain_core.language_models.chat_models import BaseChatModel |
@@ -297,6 +299,48 @@ def _coerce_args(raw: Any) -> dict[str, Any]: |
297 | 299 | raise TypeError(f"tool arguments must be a JSON object, got {type(raw).__name__}") |
298 | 300 |
|
299 | 301 |
|
| 302 | + |
| 303 | +class DelegatedToolUseWarning(UserWarning): |
| 304 | + """An `AgentNode` is running Claude Code's tool loop instead of GraphARC's. |
| 305 | +
|
| 306 | + Its own category so it can be filtered, asserted on in tests, or turned |
| 307 | + into an error with `-W error::grapharc.harness.agent.DelegatedToolUseWarning` |
| 308 | + by anyone who wants the old refusal back. |
| 309 | + """ |
| 310 | + |
| 311 | + |
| 312 | +#: What the delegated loop runs under. `bypassPermissions` is Claude Code's |
| 313 | +#: "no checks at all" mode, and it is deliberate: omitting `--allowedTools` |
| 314 | +#: leaves its default gating in place, and headless there is no one to approve |
| 315 | +#: a Write — the sub-agent simply reports that it could not create the file. |
| 316 | +#: "Every tool Claude Code has" only means that with this set. |
| 317 | +DELEGATED_PERMISSION_MODE = "bypassPermissions" |
| 318 | + |
| 319 | +_DELEGATION_WARNING = ( |
| 320 | + "agent node {name!r} is backed by the Claude CLI, which has no tool-calling " |
| 321 | + "wire format, so GraphARC cannot run its own tool loop over it. The whole " |
| 322 | + "loop is delegated to Claude Code's headless agent, which means: it uses " |
| 323 | + "EVERY tool Claude Code has (Bash, Write, WebFetch, Task, ...) under its " |
| 324 | + "bypassPermissions mode, so those calls are NOT checked by this graph's " |
| 325 | + "permission policy, NOT confined by the sandbox executor, and NOT gated by " |
| 326 | + "Claude Code's own prompts either. The token figure is what the sub-agent " |
| 327 | + "reports rather than what GraphARC metered. The workspace boundary and the wall-clock " |
| 328 | + "ceiling still apply. Every trace event from this node is marked " |
| 329 | + "executor=delegated so the run stays auditable; use a tool-calling backend " |
| 330 | + "(openrouter/*, openai/*, ollama/*) for a governed loop." |
| 331 | +) |
| 332 | + |
| 333 | + |
| 334 | +def _is_claude_cli(model: Any) -> bool: |
| 335 | + """Is this the Claude CLI backend? |
| 336 | +
|
| 337 | + Matched on `_llm_type` rather than `isinstance`, so this module does not |
| 338 | + import the gateway, and rather than "does it lack bind_tools" — which is |
| 339 | + also true of `ScriptedChatModel` and would silently delegate every mock. |
| 340 | + """ |
| 341 | + return getattr(model, "_llm_type", None) == "grapharc-claude-cli" |
| 342 | + |
| 343 | + |
300 | 344 | class AgentNode: |
301 | 345 | """A tool-using agent loop, shaped as a GraphARC node. |
302 | 346 |
|
@@ -341,6 +385,16 @@ def __init__( |
341 | 385 | self.prompt_fn = prompt_fn |
342 | 386 | self.trace = trace |
343 | 387 | self.max_tool_result_chars = max_tool_result_chars |
| 388 | + #: True when the backend is the Claude CLI, which has no tool-calling |
| 389 | + #: wire format and therefore cannot be driven as a raw model. The loop |
| 390 | + #: is handed to Claude Code instead — see `_run_delegated`. |
| 391 | + self.delegated = _is_claude_cli(model) |
| 392 | + if self.delegated: |
| 393 | + warnings.warn( |
| 394 | + _DELEGATION_WARNING.format(name=name), |
| 395 | + DelegatedToolUseWarning, |
| 396 | + stacklevel=2, |
| 397 | + ) |
344 | 398 |
|
345 | 399 | @property |
346 | 400 | def writes(self) -> set[str]: |
@@ -385,6 +439,9 @@ def run(self, prompt: str, ctx: RunContext | None = None) -> AgentResult: |
385 | 439 | run_id=uuid.uuid4().hex[:12], graph=self.name, meter=BudgetMeter(Budget()) |
386 | 440 | ) |
387 | 441 |
|
| 442 | + if self.delegated: |
| 443 | + return self._run_delegated(prompt, ctx) |
| 444 | + |
388 | 445 | model = self._bind_tools() |
389 | 446 | messages: list[BaseMessage] = [ |
390 | 447 | SystemMessage(content=self.system_prompt), |
@@ -524,6 +581,89 @@ def run(self, prompt: str, ctx: RunContext | None = None) -> AgentResult: |
524 | 581 |
|
525 | 582 | # -- internals ------------------------------------------------------------ |
526 | 583 |
|
| 584 | + def _run_delegated(self, prompt: str, ctx: RunContext) -> AgentResult: |
| 585 | + """Hand the whole task to Claude Code's headless agent. |
| 586 | +
|
| 587 | + The trade is stated in `_DELEGATION_WARNING` and repeated on every trace |
| 588 | + event this writes, because a warning at construction is gone by the time |
| 589 | + anyone reads the run back. `executor="delegated"` on the events is what |
| 590 | + stops a reader six months later from assuming this graph's permission |
| 591 | + policy saw these tool calls. It did not. |
| 592 | +
|
| 593 | + The workspace boundary and the wall-clock ceiling still hold: the CLI is |
| 594 | + spawned with `cwd` set to the harness workspace, and `max_seconds` is |
| 595 | + enforced from outside by the subprocess timeout. Everything finer than |
| 596 | + that is Claude Code's. |
| 597 | + """ |
| 598 | + from grapharc.cli.delegate import DelegationError, delegate_task |
| 599 | + |
| 600 | + remaining = ctx.meter.remaining_seconds() if ctx.meter else None |
| 601 | + step = 1 |
| 602 | + if self.trace is not None: |
| 603 | + self.trace.event( |
| 604 | + run_id=ctx.run_id, graph=ctx.graph, node=self.name, phase="model", |
| 605 | + step=step, thread_id=ctx.thread_id, attempt=ctx.attempt, |
| 606 | + state_delta={"executor": "delegated", "tools": "all of Claude Code's", |
| 607 | + "permission_mode": DELEGATED_PERMISSION_MODE, |
| 608 | + "governed_by": "Claude Code, not this graph's policy"}, |
| 609 | + ) |
| 610 | + try: |
| 611 | + workspace = getattr(self.harness.executor, "workspace", None) |
| 612 | + if workspace is None: |
| 613 | + raise DelegationError( |
| 614 | + "the delegated executor needs a workspace directory, and this " |
| 615 | + f"harness's executor ({type(self.harness.executor).__name__}) " |
| 616 | + "does not expose one", |
| 617 | + reason="no_workspace", |
| 618 | + ) |
| 619 | + run = delegate_task( |
| 620 | + prompt, |
| 621 | + workspace=Path(workspace), |
| 622 | + max_turns=self.max_iterations, |
| 623 | + max_seconds=remaining, |
| 624 | + system_prompt=self.system_prompt, |
| 625 | + permission_mode=DELEGATED_PERMISSION_MODE, |
| 626 | + ) |
| 627 | + except DelegationError as exc: |
| 628 | + if self.trace is not None: |
| 629 | + self.trace.event( |
| 630 | + run_id=ctx.run_id, graph=ctx.graph, node=self.name, phase="stop", |
| 631 | + step=step, thread_id=ctx.thread_id, attempt=ctx.attempt, |
| 632 | + state_delta={"executor": "delegated", "termination_reason": exc.reason}, |
| 633 | + error=str(exc), |
| 634 | + ) |
| 635 | + return AgentResult( |
| 636 | + termination_reason=StopReason.ERROR, iterations=0, note=str(exc) |
| 637 | + ) |
| 638 | + |
| 639 | + # The sub-agent's own count, charged so a budget is not simply blind to |
| 640 | + # a delegated node — but named `tokens_reported` everywhere it surfaces, |
| 641 | + # because GraphARC did not meter these call by call. |
| 642 | + if ctx.meter and run.tokens_reported: |
| 643 | + ctx.meter.charge_tokens(run.tokens_reported) |
| 644 | + reason = StopReason.TARGET_MET if run.ok else StopReason.ERROR |
| 645 | + if self.trace is not None: |
| 646 | + self.trace.event( |
| 647 | + run_id=ctx.run_id, graph=ctx.graph, node=self.name, phase="stop", |
| 648 | + step=step, thread_id=ctx.thread_id, attempt=ctx.attempt, |
| 649 | + tokens=run.tokens_reported or None, |
| 650 | + cost_usd=run.cost_usd, |
| 651 | + state_delta={"executor": "delegated", "termination_reason": reason.value, |
| 652 | + "turns": run.turns, "tokens_reported": run.tokens_reported, |
| 653 | + "session_id": run.session_id}, |
| 654 | + ) |
| 655 | + return AgentResult( |
| 656 | + output=run.answer if run.ok else "", |
| 657 | + partial_output="" if run.ok else run.answer, |
| 658 | + termination_reason=reason, |
| 659 | + iterations=run.turns, |
| 660 | + note=( |
| 661 | + f"delegated to Claude Code: {run.turns} turn(s), " |
| 662 | + f"{run.tokens_reported:,} tokens reported by the sub-agent, " |
| 663 | + "tool calls not checked by this graph's policy" |
| 664 | + ), |
| 665 | + ) |
| 666 | + |
527 | 667 | def _bind_tools(self) -> Any: |
528 | 668 | """Bind the policy-filtered tool set. Denied tools are never described.""" |
529 | 669 | schemas = tool_schemas(self.harness) |
|
0 commit comments