feat(adapter/runtime): implement OpenClaw and ClaudeCode runtime adapters
OpenClawRuntimeAdapter: - spawn() shells out to `openclaw session spawn --task <t> --mode run` - get_result() polls `openclaw session get <id>` until terminal status or timeout - kill() calls `openclaw session kill <id>`, silently succeeds if finished - Parses JSON or raw-text session IDs; raises NotImplementedError with helpful message when openclaw CLI is absent from PATH ClaudeCodeRuntimeAdapter: - spawn() launches `claude --permission-mode bypassPermissions --print <task>` in a temp dir (or context["workdir"]), returns a UUID job_id - Tracks all Popen instances in a thread-safe dict - get_result() calls communicate(timeout=...), raises TimeoutError on timeout - kill() terminates the Popen; silently ignores already-finished processes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +1,163 @@
|
|||||||
"""
|
"""
|
||||||
adapters/runtime/claude_code.py
|
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):
|
Spawns the ``claude`` CLI as a non-interactive subprocess for T4/T5
|
||||||
- Implement spawn() to launch a Claude Code sub-agent via the Agent SDK.
|
implementation tasks::
|
||||||
- Implement get_result() to await agent completion and parse the output.
|
|
||||||
- Implement kill() to terminate the sub-agent process or session.
|
claude --permission-mode bypassPermissions --print "<task>"
|
||||||
- Map task brief context (files, constraints, artifacts) into the agent's
|
|
||||||
system prompt and tool context.
|
Each spawned process is tracked by a UUID job_id so callers can later poll
|
||||||
- Handle Claude Code tool-use responses and extract structured output.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
from adapters.base.runtime import RuntimeAdapter
|
from adapters.base.runtime import RuntimeAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCodeRuntimeAdapter(RuntimeAdapter):
|
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.
|
Used when a TaskBrief has ``preferred_runtime == "coding_agent"``.
|
||||||
Credentials are inherited from the environment (ANTHROPIC_API_KEY).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: dict) -> None:
|
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 the Claude Code runtime adapter.
|
||||||
# Initialise any agent session management state.
|
|
||||||
raise NotImplementedError("ClaudeCodeRuntimeAdapter.__init__ is not yet implemented.")
|
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:
|
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.
|
Launch ``claude --permission-mode bypassPermissions --print "<task>"``
|
||||||
# Inject relevant files and constraints as tool context.
|
as a non-interactive subprocess.
|
||||||
# Return an agent_id that maps to a running agent session.
|
|
||||||
raise NotImplementedError("ClaudeCodeRuntimeAdapter.spawn is not yet implemented.")
|
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:
|
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.
|
Wait for the Claude Code subprocess to complete and return its output.
|
||||||
# Return dict with: {"status": ..., "output": ..., "artifacts": [...]}.
|
|
||||||
# Raise TimeoutError if timeout_s elapses.
|
Parameters
|
||||||
raise NotImplementedError("ClaudeCodeRuntimeAdapter.get_result is not yet implemented.")
|
----------
|
||||||
|
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:
|
def kill(self, agent_id: str) -> None:
|
||||||
# TODO (Phase 2): Terminate the Claude Code agent session.
|
"""
|
||||||
# Clean up any temporary files or session state.
|
Terminate a running Claude Code subprocess.
|
||||||
raise NotImplementedError("ClaudeCodeRuntimeAdapter.kill is not yet implemented.")
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -1,48 +1,241 @@
|
|||||||
"""
|
"""
|
||||||
adapters/runtime/openclaw.py
|
adapters/runtime/openclaw.py
|
||||||
OpenClaw agent runtime adapter — Phase 2 stub.
|
OpenClaw agent runtime adapter — Phase 2 implementation.
|
||||||
|
|
||||||
TODO (Phase 2):
|
Spawns sub-agents by shelling out to the ``openclaw`` CLI::
|
||||||
- Implement spawn() to submit a task to an OpenClaw worker pool.
|
|
||||||
- Implement get_result() to poll or subscribe for agent completion.
|
openclaw session spawn --task "<task>" --mode run
|
||||||
- Implement kill() to cancel a running OpenClaw agent job.
|
openclaw session get <session_id>
|
||||||
- Read endpoint and credentials from environment (OPENCLAW_API_KEY, OPENCLAW_URL).
|
openclaw session kill <session_id>
|
||||||
- Map capability hint to an appropriate worker class/queue.
|
|
||||||
|
If the ``openclaw`` binary is unavailable, all methods raise
|
||||||
|
``NotImplementedError`` with a helpful message rather than crashing with a
|
||||||
|
raw ``FileNotFoundError``.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
from adapters.base.runtime import RuntimeAdapter
|
from adapters.base.runtime import RuntimeAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Status strings from the openclaw CLI that indicate a session has finished.
|
||||||
|
_TERMINAL_STATUSES = frozenset(
|
||||||
|
{"done", "completed", "failed", "partial", "blocked", "error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OpenClawRuntimeAdapter(RuntimeAdapter):
|
class OpenClawRuntimeAdapter(RuntimeAdapter):
|
||||||
"""
|
"""
|
||||||
Runtime adapter that dispatches agent tasks to OpenClaw workers.
|
Runtime adapter that dispatches agent tasks to OpenClaw worker sessions.
|
||||||
|
|
||||||
Expects environment variables:
|
All interactions use the ``openclaw`` CLI. No additional credentials are
|
||||||
OPENCLAW_API_KEY — authentication token
|
required beyond what OpenClaw manages in the local environment.
|
||||||
OPENCLAW_URL — base URL for the OpenClaw API
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: dict) -> None:
|
def __init__(self, config: dict) -> None:
|
||||||
# TODO (Phase 2): Accept loaded team.yaml config dict.
|
"""
|
||||||
# Extract OPENCLAW_API_KEY and OPENCLAW_URL from environment.
|
Initialise the OpenClaw runtime adapter.
|
||||||
# Initialise HTTP client and any job-tracking state.
|
|
||||||
raise NotImplementedError("OpenClawRuntimeAdapter.__init__ is not yet implemented.")
|
Parameters
|
||||||
|
----------
|
||||||
|
config : Loaded team.yaml config dict (reserved for future options).
|
||||||
|
"""
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# RuntimeAdapter interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def spawn(self, task: str, capability: str, context: dict) -> str:
|
def spawn(self, task: str, capability: str, context: dict) -> str:
|
||||||
# TODO (Phase 2): Submit task to OpenClaw worker pool.
|
"""
|
||||||
# Map capability ("reasoning-heavy" | "capable" | "fast-cheap") to
|
Spawn an OpenClaw agent session for the given task.
|
||||||
# an appropriate worker queue or model hint.
|
|
||||||
# Return an agent_id string that can be used to poll for results.
|
Parameters
|
||||||
raise NotImplementedError("OpenClawRuntimeAdapter.spawn is not yet implemented.")
|
----------
|
||||||
|
task : Natural-language task description.
|
||||||
|
capability : Capability hint ("reasoning-heavy" | "capable" | "fast-cheap").
|
||||||
|
Passed informally; actual routing is handled by OpenClaw.
|
||||||
|
context : Arbitrary context bag (currently unused by this adapter).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
session_id string parsed from the CLI output.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
NotImplementedError
|
||||||
|
If the ``openclaw`` CLI is not available on PATH.
|
||||||
|
RuntimeError
|
||||||
|
If the session_id cannot be parsed from the CLI output.
|
||||||
|
"""
|
||||||
|
# TODO: map capability to an openclaw worker tier / model hint if the
|
||||||
|
# openclaw CLI gains that flag in a future release.
|
||||||
|
cmd = ["openclaw", "session", "spawn", "--task", task, "--mode", "run"]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"openclaw CLI not found on PATH. "
|
||||||
|
"Install OpenClaw or configure a different runtime adapter "
|
||||||
|
"(e.g. adapters.runtime.claude_code.ClaudeCodeRuntimeAdapter)."
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"openclaw session spawn failed (exit {exc.returncode}): "
|
||||||
|
f"{exc.stderr.strip()}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return self._parse_session_id(result.stdout)
|
||||||
|
|
||||||
def get_result(self, agent_id: str, timeout_s: int) -> dict:
|
def get_result(self, agent_id: str, timeout_s: int) -> dict:
|
||||||
# TODO (Phase 2): Poll or long-poll the OpenClaw API for job completion.
|
"""
|
||||||
# Raise TimeoutError if timeout_s elapses before the job finishes.
|
Poll ``openclaw session get`` until the session reaches a terminal
|
||||||
# Return a dict with at minimum: {"status": ..., "output": ..., "artifacts": [...]}.
|
state or *timeout_s* seconds elapse.
|
||||||
raise NotImplementedError("OpenClawRuntimeAdapter.get_result is not yet implemented.")
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
agent_id : Session ID returned by spawn().
|
||||||
|
timeout_s : Maximum seconds to wait before raising TimeoutError.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys: ``status``, ``output``, ``artifacts``.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TimeoutError
|
||||||
|
If the session does not finish within timeout_s seconds.
|
||||||
|
NotImplementedError
|
||||||
|
If the ``openclaw`` CLI is not available on PATH.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
poll_interval = 2.0
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["openclaw", "session", "get", agent_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"openclaw CLI not found on PATH. "
|
||||||
|
"Install OpenClaw or switch to a different runtime adapter."
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.debug("openclaw session get timed out; will retry")
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
parsed = self._parse_get_output(result.stdout)
|
||||||
|
if parsed.get("status", "").lower() in _TERMINAL_STATUSES:
|
||||||
|
return parsed
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"openclaw session get returned exit=%d; retrying. stderr=%s",
|
||||||
|
result.returncode,
|
||||||
|
result.stderr.strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Agent {agent_id!r} did not complete within {timeout_s}s."
|
||||||
|
)
|
||||||
|
|
||||||
def kill(self, agent_id: str) -> None:
|
def kill(self, agent_id: str) -> None:
|
||||||
# TODO (Phase 2): Send a cancellation request to the OpenClaw API.
|
"""
|
||||||
# Silently succeed if the agent has already finished.
|
Terminate an OpenClaw session unconditionally.
|
||||||
raise NotImplementedError("OpenClawRuntimeAdapter.kill is not yet implemented.")
|
|
||||||
|
Silently succeeds if the session has already finished.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
agent_id : Session ID returned by spawn().
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
NotImplementedError
|
||||||
|
If the ``openclaw`` CLI is not available on PATH.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["openclaw", "session", "kill", agent_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"openclaw CLI not found on PATH. "
|
||||||
|
"Install OpenClaw or switch to a different runtime adapter."
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("openclaw session kill timed out for agent %s", agent_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_session_id(self, output: str) -> str:
|
||||||
|
"""Extract a session_id from the raw stdout of ``openclaw session spawn``."""
|
||||||
|
output = output.strip()
|
||||||
|
|
||||||
|
# Prefer structured JSON output.
|
||||||
|
try:
|
||||||
|
data = json.loads(output)
|
||||||
|
for key in ("session_id", "sessionId", "id"):
|
||||||
|
if key in data:
|
||||||
|
return str(data[key])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Regex: look for "session_id: <id>" or similar.
|
||||||
|
m = re.search(
|
||||||
|
r"(?:session[_\s]?id|sessionId)[:\s]+([a-zA-Z0-9_\-]+)",
|
||||||
|
output,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
# Last resort: return the first non-empty line.
|
||||||
|
lines = [ln.strip() for ln in output.splitlines() if ln.strip()]
|
||||||
|
if lines:
|
||||||
|
return lines[0]
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not parse session_id from openclaw output: {output!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_get_output(self, output: str) -> dict:
|
||||||
|
"""Parse the stdout of ``openclaw session get`` into a result dict."""
|
||||||
|
output = output.strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(output)
|
||||||
|
return {
|
||||||
|
"status": data.get("status", "done"),
|
||||||
|
"output": data.get("output", output),
|
||||||
|
"artifacts": data.get("artifacts", []),
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
# Non-JSON output — treat as completed with raw text output.
|
||||||
|
return {
|
||||||
|
"status": "done",
|
||||||
|
"output": output,
|
||||||
|
"artifacts": [],
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user