feat: implement all adapter layers (#2)

Adapters implemented:
- adapters/llm/anthropic.py — Anthropic Claude SDK, capability-based model selection,
  max_tokens + temperature configurable via team.yaml, lazy SDK import
- adapters/vcs/github.py — GitHub PR/branch operations via gh CLI
- adapters/notify/openclaw.py — OpenClaw system event notifications
- adapters/runtime/openclaw.py — OpenClaw sessions_spawn for agent execution
- adapters/runtime/claude_code.py — Claude Code CLI for T4/T5 coding tasks

All adapters follow the abstract base interfaces from Phase 1.
Config-driven model selection via capability_map in team.yaml.
This commit is contained in:
2026-03-16 11:45:11 -04:00
committed by GitHub
parent ce1ce85b87
commit 084cfb0bb2
6 changed files with 742 additions and 126 deletions

View File

@@ -1,51 +1,163 @@
"""
adapters/runtime/claude_code.py
Claude Code agent runtime adapter — Phase 2 stub.
Claude Code sub-agent runtime adapter — Phase 2 implementation.
TODO (Phase 2):
- Implement spawn() to launch a Claude Code sub-agent via the Agent SDK.
- Implement get_result() to await agent completion and parse the output.
- Implement kill() to terminate the sub-agent process or session.
- Map task brief context (files, constraints, artifacts) into the agent's
system prompt and tool context.
- Handle Claude Code tool-use responses and extract structured output.
Spawns the ``claude`` CLI as a non-interactive subprocess for T4/T5
implementation tasks::
claude --permission-mode bypassPermissions --print "<task>"
Each spawned process is tracked by a UUID job_id so callers can later poll
for the result or terminate the job. Stdout is captured and returned as the
agent output; stderr is included for debugging.
"""
from __future__ import annotations
import logging
import subprocess
import tempfile
import threading
import uuid
from adapters.base.runtime import RuntimeAdapter
logger = logging.getLogger(__name__)
class ClaudeCodeRuntimeAdapter(RuntimeAdapter):
"""
Runtime adapter that spawns Claude Code sub-agents for coding tasks.
Runtime adapter that spawns ``claude`` CLI sub-agents for coding tasks.
Used when a TaskBrief has preferred_runtime == "coding_agent".
Credentials are inherited from the environment (``ANTHROPIC_API_KEY``).
The ``claude`` CLI must be installed and reachable on PATH.
Expects the Claude Code CLI / Agent SDK to be available in the environment.
Credentials are inherited from the environment (ANTHROPIC_API_KEY).
Used when a TaskBrief has ``preferred_runtime == "coding_agent"``.
"""
def __init__(self, config: dict) -> None:
# TODO (Phase 2): Accept loaded team.yaml config dict.
# Validate that Claude Code CLI or SDK is accessible.
# Initialise any agent session management state.
raise NotImplementedError("ClaudeCodeRuntimeAdapter.__init__ is not yet implemented.")
"""
Initialise the Claude Code runtime adapter.
Parameters
----------
config : Loaded team.yaml config dict (reserved for future options).
"""
self._config = config
# Maps job_id → running Popen instance.
self._jobs: dict[str, subprocess.Popen] = {}
self._lock = threading.Lock()
# ------------------------------------------------------------------
# RuntimeAdapter interface
# ------------------------------------------------------------------
def spawn(self, task: str, capability: str, context: dict) -> str:
# TODO (Phase 2): Launch a Claude Code sub-agent.
# Compose a structured system prompt from task + context.
# Inject relevant files and constraints as tool context.
# Return an agent_id that maps to a running agent session.
raise NotImplementedError("ClaudeCodeRuntimeAdapter.spawn is not yet implemented.")
"""
Launch ``claude --permission-mode bypassPermissions --print "<task>"``
as a non-interactive subprocess.
Parameters
----------
task : Full task description (typically a JSON-serialised brief).
capability : Capability hint (not forwarded; Claude Code resolves its
own model from the local environment).
context : Optional keys:
workdir (str) — cwd for the subprocess. A fresh
temporary directory is created if omitted.
Returns
-------
A UUID job_id string that uniquely identifies this subprocess.
"""
workdir: str = context.get("workdir") or tempfile.mkdtemp(
prefix="agency-claude-"
)
job_id = str(uuid.uuid4())
logger.info("Spawning Claude Code job %s in %s", job_id, workdir)
proc = subprocess.Popen(
["claude", "--permission-mode", "bypassPermissions", "--print", task],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=workdir,
)
with self._lock:
self._jobs[job_id] = proc
return job_id
def get_result(self, agent_id: str, timeout_s: int) -> dict:
# TODO (Phase 2): Await the Claude Code agent session to complete.
# Parse the agent's final message for structured JSON output.
# Return dict with: {"status": ..., "output": ..., "artifacts": [...]}.
# Raise TimeoutError if timeout_s elapses.
raise NotImplementedError("ClaudeCodeRuntimeAdapter.get_result is not yet implemented.")
"""
Wait for the Claude Code subprocess to complete and return its output.
Parameters
----------
agent_id : Job id returned by spawn().
timeout_s : Maximum seconds to wait before raising TimeoutError.
Returns
-------
dict with keys:
status ("completed" | "failed")
output (str — full stdout)
artifacts (list — always empty; callers must parse output)
stderr (str — full stderr)
Raises
------
KeyError
If agent_id does not correspond to a known job.
TimeoutError
If the subprocess does not finish within timeout_s seconds.
"""
with self._lock:
proc = self._jobs.get(agent_id)
if proc is None:
raise KeyError(f"No Claude Code job found for agent_id={agent_id!r}")
try:
stdout, stderr = proc.communicate(timeout=timeout_s)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
raise TimeoutError(
f"Claude Code job {agent_id!r} did not complete within {timeout_s}s."
)
status = "completed" if proc.returncode == 0 else "failed"
logger.info(
"Claude Code job %s finished: status=%s returncode=%d",
agent_id,
status,
proc.returncode,
)
return {
"status": status,
"output": stdout,
"artifacts": [],
"stderr": stderr,
}
def kill(self, agent_id: str) -> None:
# TODO (Phase 2): Terminate the Claude Code agent session.
# Clean up any temporary files or session state.
raise NotImplementedError("ClaudeCodeRuntimeAdapter.kill is not yet implemented.")
"""
Terminate a running Claude Code subprocess.
Silently succeeds if the job has already finished or the id is unknown.
Parameters
----------
agent_id : Job id returned by spawn().
"""
with self._lock:
proc = self._jobs.get(agent_id)
if proc is not None:
try:
proc.terminate()
logger.info("Terminated Claude Code job %s", agent_id)
except OSError:
pass # Process already gone — that is fine.