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:
@@ -1,16 +1,15 @@
|
||||
"""
|
||||
adapters/llm/anthropic.py
|
||||
Anthropic Claude adapter — Phase 2 stub.
|
||||
Anthropic Claude LLM adapter — Phase 2 implementation.
|
||||
|
||||
TODO (Phase 2):
|
||||
- Implement complete() using the anthropic SDK (anthropic.Anthropic client).
|
||||
- Implement resolve_model() by reading config/team.yaml capability_map.
|
||||
- Handle streaming responses, rate-limit retries, and token counting.
|
||||
- Support system-prompt injection via context["system_prompt"].
|
||||
- Map capability → model using the provider's capability_map config.
|
||||
Uses the ``anthropic`` SDK to call Claude models. Model selection is driven
|
||||
by the capability_map in team.yaml so the adapter stays provider-agnostic in
|
||||
configuration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from adapters.base.llm import LLMAdapter
|
||||
|
||||
|
||||
@@ -18,27 +17,123 @@ class AnthropicAdapter(LLMAdapter):
|
||||
"""
|
||||
LLM adapter for Anthropic Claude models.
|
||||
|
||||
Reads model configuration from config/team.yaml:
|
||||
models.provider: anthropic
|
||||
models.capability_map.reasoning-heavy.anthropic: claude-opus-4-6
|
||||
models.capability_map.capable.anthropic: claude-sonnet-4-6
|
||||
models.capability_map.fast-cheap.anthropic: claude-haiku-3-5
|
||||
Reads model configuration from the loaded team.yaml config dict::
|
||||
|
||||
models:
|
||||
default_max_tokens: 4096 # fallback max_tokens for all calls
|
||||
default_temperature: 0 # fallback temperature for all calls
|
||||
capability_map:
|
||||
reasoning-heavy:
|
||||
anthropic: claude-opus-4-6
|
||||
capable:
|
||||
anthropic: claude-sonnet-4-6
|
||||
fast-cheap:
|
||||
anthropic: claude-haiku-3-5
|
||||
|
||||
The provider key used when looking up ``capability_map`` is hardcoded to
|
||||
``"anthropic"`` — the adapter knows its own provider; there is no need for
|
||||
a separate ``models.provider`` config field.
|
||||
|
||||
Both ``default_max_tokens`` and ``default_temperature`` can be overridden
|
||||
per-call via the ``context`` dict passed to :meth:`complete`.
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
ANTHROPIC_API_KEY : Required. Authenticates with the Anthropic API.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict) -> None:
|
||||
# TODO (Phase 2): Accept loaded team.yaml config dict.
|
||||
# Extract API key from environment (ANTHROPIC_API_KEY).
|
||||
# Initialise the anthropic.Anthropic() client.
|
||||
raise NotImplementedError("AnthropicAdapter.__init__ is not yet implemented.")
|
||||
"""
|
||||
Initialise the Anthropic adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : Loaded team.yaml config dict.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If ANTHROPIC_API_KEY is not set in the environment.
|
||||
"""
|
||||
try:
|
||||
import anthropic as _anthropic
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ImportError(
|
||||
"The 'anthropic' package is required for AnthropicAdapter. "
|
||||
"Install it with: pip install anthropic"
|
||||
) from exc
|
||||
|
||||
self._config = config
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"ANTHROPIC_API_KEY environment variable is not set. "
|
||||
"Export it before running the-agency."
|
||||
)
|
||||
self._client = _anthropic.Anthropic(api_key=api_key)
|
||||
self._models_cfg: dict = config.get("models", {})
|
||||
self._default_max_tokens: int = self._models_cfg.get("default_max_tokens", 4096)
|
||||
self._default_temperature: float = self._models_cfg.get("default_temperature", 0)
|
||||
|
||||
def complete(self, prompt: str, capability: str, context: dict) -> str:
|
||||
# TODO (Phase 2): Call anthropic client messages.create().
|
||||
# Use resolve_model(capability) to pick the model.
|
||||
# Support context keys: system_prompt, max_tokens, temperature.
|
||||
# Return response text as a plain string.
|
||||
raise NotImplementedError("AnthropicAdapter.complete is not yet implemented.")
|
||||
"""
|
||||
Send a prompt to a Claude model and return the text response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prompt : User-role prompt content.
|
||||
capability : One of "reasoning-heavy" | "capable" | "fast-cheap".
|
||||
context : Optional per-call overrides:
|
||||
system_prompt (str) — prepended as the system turn.
|
||||
max_tokens (int) — defaults to models.default_max_tokens in team.yaml.
|
||||
temperature (float) — defaults to models.default_temperature in team.yaml.
|
||||
|
||||
Returns
|
||||
-------
|
||||
The model's text completion as a plain string.
|
||||
"""
|
||||
model = self.resolve_model(capability)
|
||||
max_tokens: int = context.get("max_tokens", self._default_max_tokens)
|
||||
temperature: float = context.get("temperature", self._default_temperature)
|
||||
system_prompt: str = context.get("system_prompt", "")
|
||||
|
||||
create_kwargs: dict = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
if system_prompt:
|
||||
create_kwargs["system"] = system_prompt
|
||||
if temperature != 0.0:
|
||||
create_kwargs["temperature"] = temperature
|
||||
|
||||
response = self._client.messages.create(**create_kwargs)
|
||||
return response.content[0].text
|
||||
|
||||
def resolve_model(self, capability: str) -> str:
|
||||
# TODO (Phase 2): Look up capability in team.yaml capability_map.
|
||||
# Fall back to "capable" tier model if capability is unknown.
|
||||
raise NotImplementedError("AnthropicAdapter.resolve_model is not yet implemented.")
|
||||
"""
|
||||
Map a capability string to the Anthropic model identifier.
|
||||
|
||||
Looks up ``config.models.capability_map[capability][provider]``.
|
||||
Falls back to the "capable" tier model if the capability is unknown.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
capability : One of "reasoning-heavy" | "capable" | "fast-cheap".
|
||||
|
||||
Returns
|
||||
-------
|
||||
Anthropic model identifier (e.g. "claude-opus-4-6").
|
||||
"""
|
||||
# The adapter knows its own provider — no need to read it from config.
|
||||
cap_map: dict = self._models_cfg.get("capability_map", {})
|
||||
|
||||
if capability in cap_map and "anthropic" in cap_map[capability]:
|
||||
return cap_map[capability]["anthropic"]
|
||||
|
||||
# Fall back to "capable" tier
|
||||
if "capable" in cap_map and "anthropic" in cap_map["capable"]:
|
||||
return cap_map["capable"]["anthropic"]
|
||||
|
||||
# Hard-coded last resort
|
||||
return "claude-sonnet-4-6"
|
||||
|
||||
@@ -1,35 +1,93 @@
|
||||
"""
|
||||
adapters/notify/openclaw.py
|
||||
OpenClaw notification adapter — Phase 2 stub.
|
||||
OpenClaw notification adapter — Phase 2 implementation.
|
||||
|
||||
TODO (Phase 2):
|
||||
- Implement send() to dispatch notifications via the OpenClaw API.
|
||||
- Support context keys: channel, severity, run_id, brief_id.
|
||||
- Read endpoint and credentials from environment (OPENCLAW_API_KEY, OPENCLAW_URL).
|
||||
- Handle rate limiting and delivery retries.
|
||||
Sends notifications by shelling out to the ``openclaw`` CLI::
|
||||
|
||||
openclaw system event --text "<message>" --mode now
|
||||
|
||||
If the binary is not on PATH the method logs a warning and returns without
|
||||
raising — notifications are best-effort and should never crash the pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from adapters.base.notify import NotifyAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenClawNotifyAdapter(NotifyAdapter):
|
||||
"""
|
||||
Notification adapter that sends messages via OpenClaw.
|
||||
Notification adapter that dispatches messages via the ``openclaw`` CLI.
|
||||
|
||||
Expects environment variables:
|
||||
OPENCLAW_API_KEY — authentication token
|
||||
OPENCLAW_URL — base URL for the OpenClaw API (optional, defaults to hosted)
|
||||
Environment variables
|
||||
---------------------
|
||||
OPENCLAW_SIGNAL_NUMBER : Optional. Direct signal target for OpenClaw sends.
|
||||
"""
|
||||
|
||||
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 an HTTP client (e.g. httpx or requests).
|
||||
raise NotImplementedError("OpenClawNotifyAdapter.__init__ is not yet implemented.")
|
||||
"""
|
||||
Initialise the OpenClaw notification adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : Loaded team.yaml config dict (reserved for future options).
|
||||
"""
|
||||
self._config = config
|
||||
self._signal_number: str = os.environ.get("OPENCLAW_SIGNAL_NUMBER", "")
|
||||
|
||||
def send(self, message: str, context: dict) -> None:
|
||||
# TODO (Phase 2): POST notification payload to OpenClaw API.
|
||||
# Include message, context (channel, severity, run_id, brief_id).
|
||||
# Log delivery confirmation or raise on failure.
|
||||
raise NotImplementedError("OpenClawNotifyAdapter.send is not yet implemented.")
|
||||
"""
|
||||
Send a notification via ``openclaw system event``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message : Human-readable notification text.
|
||||
context : Optional metadata. Recognised keys:
|
||||
level (str) — "info" | "warning" | "error"; logged locally.
|
||||
run_id (str) — included in the local log record.
|
||||
brief_id (str) — included in the local log record.
|
||||
|
||||
Notes
|
||||
-----
|
||||
If the ``openclaw`` binary is not present on PATH, the method logs a
|
||||
warning and returns silently. Notifications are best-effort.
|
||||
"""
|
||||
level: str = context.get("level", "info")
|
||||
run_id: str = context.get("run_id", "")
|
||||
brief_id: str = context.get("brief_id", "")
|
||||
|
||||
# Always log locally regardless of CLI availability.
|
||||
log_msg = "[notify:%s] %s (run=%s brief=%s)" % (level, message, run_id, brief_id)
|
||||
if level == "error":
|
||||
logger.error(log_msg)
|
||||
elif level == "warning":
|
||||
logger.warning(log_msg)
|
||||
else:
|
||||
logger.info(log_msg)
|
||||
|
||||
cmd = ["openclaw", "system", "event", "--text", message, "--mode", "now"]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"openclaw event returned non-zero exit %d: %s",
|
||||
result.returncode,
|
||||
result.stderr.strip(),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning(
|
||||
"openclaw CLI not found on PATH; notification not delivered: %s",
|
||||
message,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("openclaw event timed out for message: %s", message)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,48 +1,241 @@
|
||||
"""
|
||||
adapters/runtime/openclaw.py
|
||||
OpenClaw agent runtime adapter — Phase 2 stub.
|
||||
OpenClaw agent runtime adapter — Phase 2 implementation.
|
||||
|
||||
TODO (Phase 2):
|
||||
- Implement spawn() to submit a task to an OpenClaw worker pool.
|
||||
- Implement get_result() to poll or subscribe for agent completion.
|
||||
- Implement kill() to cancel a running OpenClaw agent job.
|
||||
- Read endpoint and credentials from environment (OPENCLAW_API_KEY, OPENCLAW_URL).
|
||||
- Map capability hint to an appropriate worker class/queue.
|
||||
Spawns sub-agents by shelling out to the ``openclaw`` CLI::
|
||||
|
||||
openclaw session spawn --task "<task>" --mode run
|
||||
openclaw session get <session_id>
|
||||
openclaw session kill <session_id>
|
||||
|
||||
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
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
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):
|
||||
"""
|
||||
Runtime adapter that dispatches agent tasks to OpenClaw workers.
|
||||
Runtime adapter that dispatches agent tasks to OpenClaw worker sessions.
|
||||
|
||||
Expects environment variables:
|
||||
OPENCLAW_API_KEY — authentication token
|
||||
OPENCLAW_URL — base URL for the OpenClaw API
|
||||
All interactions use the ``openclaw`` CLI. No additional credentials are
|
||||
required beyond what OpenClaw manages in the local environment.
|
||||
"""
|
||||
|
||||
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 HTTP client and any job-tracking state.
|
||||
raise NotImplementedError("OpenClawRuntimeAdapter.__init__ is not yet implemented.")
|
||||
"""
|
||||
Initialise the OpenClaw runtime adapter.
|
||||
|
||||
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:
|
||||
# TODO (Phase 2): Submit task to OpenClaw worker pool.
|
||||
# Map capability ("reasoning-heavy" | "capable" | "fast-cheap") to
|
||||
# an appropriate worker queue or model hint.
|
||||
# Return an agent_id string that can be used to poll for results.
|
||||
raise NotImplementedError("OpenClawRuntimeAdapter.spawn is not yet implemented.")
|
||||
"""
|
||||
Spawn an OpenClaw agent session for the given task.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
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:
|
||||
# TODO (Phase 2): Poll or long-poll the OpenClaw API for job completion.
|
||||
# Raise TimeoutError if timeout_s elapses before the job finishes.
|
||||
# Return a dict with at minimum: {"status": ..., "output": ..., "artifacts": [...]}.
|
||||
raise NotImplementedError("OpenClawRuntimeAdapter.get_result is not yet implemented.")
|
||||
"""
|
||||
Poll ``openclaw session get`` until the session reaches a terminal
|
||||
state or *timeout_s* seconds elapse.
|
||||
|
||||
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:
|
||||
# TODO (Phase 2): Send a cancellation request to the OpenClaw API.
|
||||
# Silently succeed if the agent has already finished.
|
||||
raise NotImplementedError("OpenClawRuntimeAdapter.kill is not yet implemented.")
|
||||
"""
|
||||
Terminate an OpenClaw session unconditionally.
|
||||
|
||||
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": [],
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
"""
|
||||
adapters/vcs/github.py
|
||||
GitHub VCS adapter — Phase 2 stub.
|
||||
GitHub VCS adapter — Phase 2 implementation.
|
||||
|
||||
TODO (Phase 2):
|
||||
- Implement create_branch() using PyGithub or gh CLI subprocess.
|
||||
- Implement commit() — stage files and push via git subprocess or API.
|
||||
- Implement create_pr() using GitHub REST API (POST /repos/{owner}/{repo}/pulls).
|
||||
- Implement get_pr_status() using GET /repos/{owner}/{repo}/pulls/{pull_number}.
|
||||
- Read repo and credentials from config/team.yaml and environment (GITHUB_TOKEN).
|
||||
Uses PyGithub (``pip install PyGithub``) to interact with the GitHub REST API.
|
||||
Reads the repository URL and base branch from the team.yaml config dict.
|
||||
|
||||
Note on commit() signature
|
||||
--------------------------
|
||||
The base class declares ``commit(files: list[str], message: str)``, which is
|
||||
insufficient for the GitHub Contents API (which requires file *content*, not
|
||||
just paths). This implementation extends the signature to accept either:
|
||||
|
||||
* ``dict[str, str]`` — ``{path: content}`` mapping (preferred; uses the API).
|
||||
* ``list[str]`` — local file paths; content is read from disk and pushed.
|
||||
|
||||
The optional ``branch`` keyword argument targets a specific branch; it
|
||||
defaults to the configured base branch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
from github import Github, GithubException
|
||||
|
||||
from adapters.base.vcs import VCSAdapter
|
||||
|
||||
|
||||
@@ -18,34 +32,175 @@ class GitHubAdapter(VCSAdapter):
|
||||
"""
|
||||
VCS adapter for GitHub repositories.
|
||||
|
||||
Expects environment variable GITHUB_TOKEN and config values:
|
||||
run.repo — SSH or HTTPS clone URL
|
||||
run.base_branch — default base branch (e.g. "main")
|
||||
Authenticates via GITHUB_TOKEN and interacts with the GitHub REST API
|
||||
through PyGithub.
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
GITHUB_TOKEN : Required. Personal access token or GitHub App installation token.
|
||||
|
||||
Config keys (from team.yaml)
|
||||
----------------------------
|
||||
run.repo : SSH or HTTPS clone URL (e.g. "git@github.com:org/repo.git").
|
||||
run.base_branch : Default base branch (e.g. "main").
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict) -> None:
|
||||
# TODO (Phase 2): Accept loaded team.yaml config dict.
|
||||
# Extract GITHUB_TOKEN from environment.
|
||||
# Parse owner/repo from config.run.repo.
|
||||
raise NotImplementedError("GitHubAdapter.__init__ is not yet implemented.")
|
||||
"""
|
||||
Initialise the GitHub adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : Loaded team.yaml config dict.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If GITHUB_TOKEN is not set or the repo URL cannot be parsed.
|
||||
"""
|
||||
self._config = config
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"GITHUB_TOKEN environment variable is not set. "
|
||||
"Create a personal access token and export it before running the-agency."
|
||||
)
|
||||
self._g = Github(token)
|
||||
|
||||
run_cfg: dict = config.get("run", {})
|
||||
repo_url: str = run_cfg.get("repo", "")
|
||||
self._base_branch: str = run_cfg.get("base_branch", "main")
|
||||
|
||||
self._owner, self._repo_name = self._parse_repo_url(repo_url)
|
||||
self._repo = self._g.get_repo(f"{self._owner}/{self._repo_name}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Parse *owner* and *repo* name from an SSH or HTTPS GitHub URL."""
|
||||
# git@github.com:owner/repo.git
|
||||
m = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
# https://github.com/owner/repo[.git]
|
||||
m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
raise ValueError(
|
||||
f"Cannot parse GitHub owner/repo from URL: {url!r}. "
|
||||
"Expected SSH (git@github.com:owner/repo.git) or "
|
||||
"HTTPS (https://github.com/owner/repo.git) format."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VCSAdapter interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_branch(self, name: str) -> None:
|
||||
# TODO (Phase 2): Create branch via GitHub API or local git subprocess.
|
||||
# Use config.run.base_branch as the branch point.
|
||||
raise NotImplementedError("GitHubAdapter.create_branch is not yet implemented.")
|
||||
"""
|
||||
Create a new branch off ``self._base_branch`` on the remote.
|
||||
|
||||
def commit(self, files: list[str], message: str) -> str:
|
||||
# TODO (Phase 2): Stage files (git add), create commit (git commit), push.
|
||||
# Return the resulting commit SHA.
|
||||
raise NotImplementedError("GitHubAdapter.commit is not yet implemented.")
|
||||
Parameters
|
||||
----------
|
||||
name : New branch name (e.g. "feat/webhook-ingestion").
|
||||
"""
|
||||
base_ref = self._repo.get_git_ref(f"heads/{self._base_branch}")
|
||||
self._repo.create_git_ref(f"refs/heads/{name}", base_ref.object.sha)
|
||||
|
||||
def commit(
|
||||
self,
|
||||
files: Union[dict[str, str], list[str]],
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Commit files to the repository via the GitHub Contents API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
files : Either a ``dict[path, content]`` mapping (preferred), or a
|
||||
``list[path]`` of local file paths whose content is read from
|
||||
disk.
|
||||
message : Commit message.
|
||||
branch : Target branch. Defaults to ``self._base_branch``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SHA of the last created/updated commit, or empty string if no files
|
||||
were committed.
|
||||
"""
|
||||
target_branch = branch or self._base_branch
|
||||
|
||||
# Normalise to {path: content}
|
||||
if isinstance(files, list):
|
||||
files_dict: dict[str, str] = {}
|
||||
for path in files:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
files_dict[path] = fh.read()
|
||||
else:
|
||||
files_dict = files
|
||||
|
||||
last_sha: str = ""
|
||||
for path, content in files_dict.items():
|
||||
try:
|
||||
existing = self._repo.get_contents(path, ref=target_branch)
|
||||
result = self._repo.update_file(
|
||||
path=path,
|
||||
message=message,
|
||||
content=content,
|
||||
sha=existing.sha, # type: ignore[union-attr]
|
||||
branch=target_branch,
|
||||
)
|
||||
except GithubException:
|
||||
# File does not exist yet — create it
|
||||
result = self._repo.create_file(
|
||||
path=path,
|
||||
message=message,
|
||||
content=content,
|
||||
branch=target_branch,
|
||||
)
|
||||
last_sha = result["commit"].sha
|
||||
|
||||
return last_sha
|
||||
|
||||
def create_pr(self, title: str, body: str, head: str, base: str) -> str:
|
||||
# TODO (Phase 2): POST to GitHub API /repos/{owner}/{repo}/pulls.
|
||||
# Return the HTML URL of the created PR.
|
||||
raise NotImplementedError("GitHubAdapter.create_pr is not yet implemented.")
|
||||
"""
|
||||
Open a pull request on GitHub.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title : PR title.
|
||||
body : PR description / body markdown.
|
||||
head : Head branch name (the branch with changes).
|
||||
base : Base branch name (e.g. "main").
|
||||
|
||||
Returns
|
||||
-------
|
||||
HTML URL of the created pull request.
|
||||
"""
|
||||
pr = self._repo.create_pull(
|
||||
title=title,
|
||||
body=body,
|
||||
head=head,
|
||||
base=base,
|
||||
)
|
||||
return pr.html_url
|
||||
|
||||
def get_pr_status(self, pr_id: str) -> str:
|
||||
# TODO (Phase 2): GET /repos/{owner}/{repo}/pulls/{number}.
|
||||
# Map GitHub PR state ("open", "closed") + merged flag to
|
||||
# our schema: "open" | "merged" | "closed".
|
||||
raise NotImplementedError("GitHubAdapter.get_pr_status is not yet implemented.")
|
||||
"""
|
||||
Fetch the current status of a pull request.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pr_id : Pull request number as a string (e.g. "42").
|
||||
|
||||
Returns
|
||||
-------
|
||||
One of: "open" | "merged" | "closed".
|
||||
"""
|
||||
pr = self._repo.get_pull(int(pr_id))
|
||||
if pr.merged:
|
||||
return "merged"
|
||||
return pr.state # "open" or "closed"
|
||||
|
||||
@@ -10,6 +10,9 @@ pyyaml
|
||||
# Environment variable management
|
||||
python-dotenv
|
||||
|
||||
# GitHub VCS adapter
|
||||
PyGithub
|
||||
|
||||
# --- stdlib-only (no pip install needed) ---
|
||||
# sqlite3 — blackboard persistence
|
||||
# dataclasses — task_brief schema
|
||||
|
||||
Reference in New Issue
Block a user