logic-engine / ace /integrations /claude_code.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
8.73 kB
"""Claude Code integration — execute step, result type, and trace converter."""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Tuple
from ..core.context import ACEStepContext
from ..implementations.prompts import wrap_skillbook_for_external_agent
logger = logging.getLogger(__name__)
CLAUDE_CODE_AVAILABLE = shutil.which("claude") is not None
# ---------------------------------------------------------------------------
# Input / Output types
# ---------------------------------------------------------------------------
@dataclass
class ClaudeCodeResult:
"""Output from a Claude Code CLI execution.
This is the integration-specific result — not yet in ACE trace format.
Use ``ClaudeCodeToTrace`` to convert to a standardised trace dict.
"""
task: str
success: bool
output: str = ""
execution_trace: str = ""
returncode: int = 0
error: Optional[str] = None
# ---------------------------------------------------------------------------
# Execute step
# ---------------------------------------------------------------------------
class ClaudeCodeExecuteStep:
"""INJECT skillbook context and EXECUTE via Claude Code CLI.
Reads a task string from ``ctx.sample``, writes a ``ClaudeCodeResult``
to ``ctx.trace``.
"""
requires = frozenset({"sample", "skillbook"})
provides = frozenset({"trace"})
def __init__(
self,
working_dir: Optional[str] = None,
timeout: int = 600,
model: Optional[str] = None,
allowed_tools: Optional[list[str]] = None,
) -> None:
if not CLAUDE_CODE_AVAILABLE:
raise RuntimeError(
"Claude Code CLI not found. Install from: https://claude.ai/code"
)
self.working_dir = Path(working_dir).resolve() if working_dir else Path.cwd()
self.timeout = timeout
self.model = model
self.allowed_tools = allowed_tools
def __call__(self, ctx: ACEStepContext) -> ACEStepContext:
task: str = ctx.sample
# -- INJECT --
prompt = self._inject(task, ctx.skillbook)
# -- EXECUTE --
result = self._execute(task, prompt)
return ctx.replace(trace=result)
# ------------------------------------------------------------------
# Injection
# ------------------------------------------------------------------
@staticmethod
def _inject(task: str, skillbook: Any) -> str:
if skillbook is None:
return task
context = wrap_skillbook_for_external_agent(skillbook)
if not context:
return task
return f"{task}\n\n{context}"
# ------------------------------------------------------------------
# Execution
# ------------------------------------------------------------------
def _execute(self, task: str, prompt: str) -> ClaudeCodeResult:
cmd: list[str] = [
"claude",
"--print",
"--output-format=stream-json",
"--verbose",
"--dangerously-skip-permissions",
]
if self.model:
cmd.extend(["--model", self.model])
if self.allowed_tools:
for tool in self.allowed_tools:
cmd.extend(["--allowedTools", tool])
env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
try:
result = subprocess.run(
cmd,
input=prompt,
text=True,
cwd=str(self.working_dir),
capture_output=True,
timeout=self.timeout,
env=env,
)
execution_trace, summary = self._parse_stream_json(result.stdout)
return ClaudeCodeResult(
task=task,
success=result.returncode == 0,
output=summary,
execution_trace=execution_trace,
returncode=result.returncode,
error=result.stderr[:500] if result.returncode != 0 else None,
)
except subprocess.TimeoutExpired:
return ClaudeCodeResult(
task=task,
success=False,
returncode=-1,
error=f"Execution timed out after {self.timeout}s",
)
except Exception as exc:
return ClaudeCodeResult(
task=task,
success=False,
returncode=-1,
error=str(exc),
)
# ------------------------------------------------------------------
# Stream-JSON parser
# ------------------------------------------------------------------
@staticmethod
def _parse_stream_json(stdout: str) -> Tuple[str, str]:
"""Parse stream-json output. Returns ``(execution_trace, summary)``."""
trace_parts: list[str] = []
final_text = ""
step_num = 0
for line in stdout.split("\n"):
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") != "assistant":
continue
for block in event.get("message", {}).get("content", []):
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "text":
text = block.get("text", "")
if text.strip():
trace_parts.append(f"[Reasoning] {text[:300]}")
final_text = text
elif block_type == "tool_use":
step_num += 1
tool_name = block.get("name", "unknown")
tool_input = block.get("input", {})
if tool_name in ("Read", "Glob", "Grep"):
target = tool_input.get("file_path") or tool_input.get(
"pattern", ""
)
trace_parts.append(f"[Step {step_num}] {tool_name}: {target}")
elif tool_name in ("Write", "Edit"):
target = tool_input.get("file_path", "")
trace_parts.append(f"[Step {step_num}] {tool_name}: {target}")
elif tool_name == "Bash":
cmd = tool_input.get("command", "")[:80]
trace_parts.append(f"[Step {step_num}] Bash: {cmd}")
else:
trace_parts.append(f"[Step {step_num}] {tool_name}")
execution_trace = (
"\n".join(trace_parts) if trace_parts else "(No trace captured)"
)
if final_text:
paragraphs = [p.strip() for p in final_text.split("\n\n") if p.strip()]
summary = paragraphs[-1][:300] if paragraphs else final_text[:300]
else:
summary = f"Completed {step_num} steps"
return execution_trace, summary
# ---------------------------------------------------------------------------
# Convert step — ClaudeCodeResult → standardised trace dict
# ---------------------------------------------------------------------------
class ClaudeCodeToTrace:
"""Convert a ``ClaudeCodeResult`` on ``ctx.trace`` to the standardised
trace dict that the learning tail (``ReflectStep``) expects.
Trace dict keys: ``question``, ``reasoning``, ``answer``, ``skill_ids``,
``feedback``, ``ground_truth``.
"""
requires = frozenset({"trace"})
provides = frozenset({"trace"})
def __call__(self, ctx: ACEStepContext) -> ACEStepContext:
r: ClaudeCodeResult = ctx.trace # type: ignore[assignment]
status = "succeeded" if r.success else "failed"
feedback = f"Claude Code task {status}"
if r.error:
feedback += f"\nError: {r.error}"
trace: dict = {
"question": r.task,
"reasoning": r.execution_trace,
"answer": r.output,
"skill_ids": [],
"feedback": feedback,
"ground_truth": None,
}
return ctx.replace(trace=trace)
__all__ = [
"ClaudeCodeExecuteStep",
"ClaudeCodeResult",
"ClaudeCodeToTrace",
]