first commit
This commit is contained in:
868
agent/opencode_runtime.py
Normal file
868
agent/opencode_runtime.py
Normal file
@@ -0,0 +1,868 @@
|
||||
"""
|
||||
OpenCode Agent Runtime
|
||||
======================
|
||||
Wraps OpenCode CLI as the AI brain for Aetheel, inspired by OpenClaw's cli-runner.ts.
|
||||
|
||||
Two modes of operation:
|
||||
1. **SDK Mode** (preferred) — Connects to a running `opencode serve` instance
|
||||
via the official Python SDK (`opencode-ai`). Persistent sessions, low latency.
|
||||
2. **CLI Mode** (fallback) — Spawns `opencode run` as a subprocess for each
|
||||
request. No persistent server needed, but higher per-request latency.
|
||||
|
||||
Architecture (modeled after OpenClaw):
|
||||
- OpenClaw's `cli-runner.ts` runs CLI agents as subprocesses with configurable
|
||||
backends (claude-cli, codex-cli, etc.) via `runCommandWithTimeout()`.
|
||||
- OpenClaw's `cli-backends.ts` defines backend configs with command, args,
|
||||
output format, model aliases, session handling, etc.
|
||||
- We replicate this pattern for OpenCode, but leverage OpenCode's `serve` mode
|
||||
and its Python SDK for a cleaner integration.
|
||||
|
||||
Session Management:
|
||||
- Each Slack thread maps to an OpenCode session (via `conversation_id`).
|
||||
- Sessions are created on first message and reused for follow-ups.
|
||||
- This mirrors OpenClaw's session isolation strategy.
|
||||
|
||||
Usage:
|
||||
from agent.opencode_runtime import OpenCodeRuntime
|
||||
|
||||
runtime = OpenCodeRuntime(mode="sdk")
|
||||
response = runtime.chat("What is Python?", session_id="slack-thread-123")
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger("aetheel.agent")
|
||||
|
||||
|
||||
def _resolve_opencode_command(explicit: str | None = None) -> str:
|
||||
"""
|
||||
Resolve the opencode binary path.
|
||||
|
||||
Python subprocesses don't source ~/.zshrc or ~/.bashrc, so paths like
|
||||
~/.opencode/bin won't be in PATH. This function checks common install
|
||||
locations to find the binary automatically.
|
||||
|
||||
Priority:
|
||||
1. Explicit path (from OPENCODE_COMMAND env var)
|
||||
2. shutil.which (already in system PATH)
|
||||
3. ~/.opencode/bin/opencode (official installer default)
|
||||
4. ~/.local/bin/opencode (common Linux/macOS location)
|
||||
5. npm global installs (npx-style locations)
|
||||
"""
|
||||
cmd = explicit or "opencode"
|
||||
|
||||
# If explicit path is absolute and exists, use it directly
|
||||
if os.path.isabs(cmd) and os.path.isfile(cmd):
|
||||
return cmd
|
||||
|
||||
# Try system PATH first
|
||||
found = shutil.which(cmd)
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Check common install locations
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".opencode" / "bin" / "opencode", # official installer
|
||||
home / ".local" / "bin" / "opencode", # common Linux/macOS
|
||||
Path("/usr/local/bin/opencode"), # Homebrew / manual
|
||||
Path("/opt/homebrew/bin/opencode"), # Homebrew (Apple Silicon)
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
logger.info(f"Auto-discovered opencode at: {candidate}")
|
||||
return str(candidate)
|
||||
|
||||
# Return the original command (will fail at runtime with a clear error)
|
||||
return cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RuntimeMode(Enum):
|
||||
"""How the runtime connects to OpenCode."""
|
||||
|
||||
SDK = "sdk" # via opencode serve + Python SDK
|
||||
CLI = "cli" # via opencode run subprocess
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenCodeConfig:
|
||||
"""
|
||||
Configuration for the OpenCode runtime.
|
||||
Modeled after OpenClaw's CliBackendConfig in cli-backends.ts.
|
||||
"""
|
||||
|
||||
# Connection
|
||||
mode: RuntimeMode = RuntimeMode.CLI
|
||||
server_url: str = "http://localhost:4096"
|
||||
server_password: str | None = None
|
||||
server_username: str = "opencode"
|
||||
|
||||
# CLI settings (for CLI mode, mirroring OpenClaw's DEFAULT_CLAUDE_BACKEND)
|
||||
command: str = "opencode"
|
||||
timeout_seconds: int = 120
|
||||
|
||||
# Model
|
||||
model: str | None = None # e.g., "anthropic/claude-sonnet-4-20250514"
|
||||
provider: str | None = None # e.g., "anthropic"
|
||||
|
||||
# Agent behavior
|
||||
system_prompt: str | None = None
|
||||
workspace_dir: str | None = None
|
||||
format: str = "json" # output format: "default" (formatted) or "json" (raw events)
|
||||
|
||||
# Session
|
||||
auto_create_sessions: bool = True
|
||||
session_ttl_hours: int = 24
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OpenCodeConfig":
|
||||
"""Create config from environment variables."""
|
||||
mode_str = os.environ.get("OPENCODE_MODE", "cli").lower()
|
||||
mode = RuntimeMode.SDK if mode_str == "sdk" else RuntimeMode.CLI
|
||||
|
||||
return cls(
|
||||
mode=mode,
|
||||
server_url=os.environ.get(
|
||||
"OPENCODE_SERVER_URL", "http://localhost:4096"
|
||||
),
|
||||
server_password=os.environ.get("OPENCODE_SERVER_PASSWORD"),
|
||||
server_username=os.environ.get("OPENCODE_SERVER_USERNAME", "opencode"),
|
||||
command=_resolve_opencode_command(
|
||||
os.environ.get("OPENCODE_COMMAND")
|
||||
),
|
||||
timeout_seconds=int(os.environ.get("OPENCODE_TIMEOUT", "120")),
|
||||
model=os.environ.get("OPENCODE_MODEL"),
|
||||
provider=os.environ.get("OPENCODE_PROVIDER"),
|
||||
system_prompt=os.environ.get("OPENCODE_SYSTEM_PROMPT"),
|
||||
workspace_dir=os.environ.get(
|
||||
"OPENCODE_WORKSPACE",
|
||||
os.environ.get("AETHEEL_WORKSPACE"),
|
||||
),
|
||||
format=os.environ.get("OPENCODE_FORMAT", "json"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent Response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResponse:
|
||||
"""Response from the agent runtime."""
|
||||
|
||||
text: str
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
duration_ms: int = 0
|
||||
usage: dict | None = None
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.error is None and bool(self.text.strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""
|
||||
Maps external IDs (e.g., Slack thread_ts) to OpenCode session IDs.
|
||||
Mirrors OpenClaw's session isolation: each channel thread gets its own session.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._sessions: dict[str, dict] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, external_id: str) -> str | None:
|
||||
"""Get the OpenCode session ID for an external conversation ID."""
|
||||
with self._lock:
|
||||
entry = self._sessions.get(external_id)
|
||||
if entry:
|
||||
entry["last_used"] = time.time()
|
||||
return entry["session_id"]
|
||||
return None
|
||||
|
||||
def set(self, external_id: str, session_id: str) -> None:
|
||||
"""Map an external ID to an OpenCode session ID."""
|
||||
with self._lock:
|
||||
self._sessions[external_id] = {
|
||||
"session_id": session_id,
|
||||
"created": time.time(),
|
||||
"last_used": time.time(),
|
||||
}
|
||||
|
||||
def remove(self, external_id: str) -> None:
|
||||
"""Remove -a session mapping."""
|
||||
with self._lock:
|
||||
self._sessions.pop(external_id, None)
|
||||
|
||||
def cleanup(self, ttl_hours: int = 24) -> int:
|
||||
"""Remove stale sessions older than ttl_hours. Returns count removed."""
|
||||
cutoff = time.time() - (ttl_hours * 3600)
|
||||
removed = 0
|
||||
with self._lock:
|
||||
stale = [
|
||||
k
|
||||
for k, v in self._sessions.items()
|
||||
if v["last_used"] < cutoff
|
||||
]
|
||||
for k in stale:
|
||||
del self._sessions[k]
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._sessions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenCode Runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenCodeRuntime:
|
||||
"""
|
||||
OpenCode Agent Runtime — the AI brain for Aetheel.
|
||||
|
||||
Inspired by OpenClaw's `runCliAgent()` in cli-runner.ts:
|
||||
- Resolves the CLI backend config
|
||||
- Builds CLI args (model, session, system prompt, etc.)
|
||||
- Runs the command with a timeout
|
||||
- Parses the JSON or text output
|
||||
- Returns structured results
|
||||
|
||||
We adapt this for OpenCode's two modes:
|
||||
- SDK mode: uses the opencode-ai Python SDK to talk to `opencode serve`
|
||||
- CLI mode: spawns `opencode run` subprocess (like OpenClaw's approach)
|
||||
"""
|
||||
|
||||
def __init__(self, config: OpenCodeConfig | None = None):
|
||||
self._config = config or OpenCodeConfig.from_env()
|
||||
self._sessions = SessionStore()
|
||||
self._sdk_client = None
|
||||
self._sdk_available = False
|
||||
|
||||
# Validate OpenCode is available
|
||||
self._validate_installation()
|
||||
|
||||
# Try to initialize SDK client if in SDK mode
|
||||
if self._config.mode == RuntimeMode.SDK:
|
||||
self._init_sdk_client()
|
||||
|
||||
logger.info(
|
||||
f"OpenCode runtime initialized "
|
||||
f"(mode={self._config.mode.value}, "
|
||||
f"model={self._config.model or 'default'})"
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Public API
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def chat(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> AgentResponse:
|
||||
"""
|
||||
Send a message to the AI agent and get a response.
|
||||
|
||||
This is the main entry point, used by the Slack adapter's message handler.
|
||||
|
||||
Args:
|
||||
message: The user's message text
|
||||
conversation_id: External conversation ID (e.g., Slack thread_ts)
|
||||
for session isolation
|
||||
system_prompt: Optional per-request system prompt override
|
||||
|
||||
Returns:
|
||||
AgentResponse with the AI's reply
|
||||
"""
|
||||
started = time.time()
|
||||
|
||||
if not message.strip():
|
||||
return AgentResponse(
|
||||
text="", error="Empty message", duration_ms=0
|
||||
)
|
||||
|
||||
try:
|
||||
# Route to the appropriate mode
|
||||
if self._config.mode == RuntimeMode.SDK and self._sdk_available:
|
||||
result = self._chat_sdk(message, conversation_id, system_prompt)
|
||||
else:
|
||||
result = self._chat_cli(message, conversation_id, system_prompt)
|
||||
|
||||
result.duration_ms = int((time.time() - started) * 1000)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration_ms = int((time.time() - started) * 1000)
|
||||
logger.error(f"Agent error: {e}", exc_info=True)
|
||||
return AgentResponse(
|
||||
text="",
|
||||
error=str(e),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get the runtime status (for the /status command)."""
|
||||
status = {
|
||||
"mode": self._config.mode.value,
|
||||
"model": self._config.model or "default",
|
||||
"provider": self._config.provider or "auto",
|
||||
"active_sessions": self._sessions.count,
|
||||
"opencode_available": self._is_opencode_available(),
|
||||
}
|
||||
|
||||
if self._config.mode == RuntimeMode.SDK:
|
||||
status["server_url"] = self._config.server_url
|
||||
status["sdk_connected"] = self._sdk_available
|
||||
|
||||
return status
|
||||
|
||||
def cleanup_sessions(self) -> int:
|
||||
"""Clean up stale sessions. Returns count removed."""
|
||||
return self._sessions.cleanup(self._config.session_ttl_hours)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# CLI Mode: Subprocess execution
|
||||
# (mirrors OpenClaw's runCliAgent → runCommandWithTimeout pattern)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _chat_cli(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> AgentResponse:
|
||||
"""
|
||||
Run OpenCode in CLI mode via `opencode run`.
|
||||
|
||||
This mirrors OpenClaw's cli-runner.ts:
|
||||
1. Build the CLI args (like buildCliArgs)
|
||||
2. Run the command with a timeout
|
||||
3. Parse the output (like parseCliJson)
|
||||
4. Return structured results
|
||||
"""
|
||||
# Build CLI args — modeled after OpenClaw's buildCliArgs()
|
||||
args = self._build_cli_args(message, conversation_id, system_prompt)
|
||||
|
||||
logger.info(
|
||||
f"CLI exec: {self._config.command} run "
|
||||
f"(prompt_chars={len(message)}, "
|
||||
f"session={conversation_id or 'new'})"
|
||||
)
|
||||
|
||||
try:
|
||||
# Run the command — mirrors OpenClaw's runCommandWithTimeout()
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._config.timeout_seconds,
|
||||
cwd=self._config.workspace_dir or os.getcwd(),
|
||||
env=self._build_cli_env(),
|
||||
)
|
||||
|
||||
stdout = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
if result.returncode != 0:
|
||||
# Mirror OpenClaw's error classification
|
||||
error_text = stderr or stdout or "CLI command failed"
|
||||
logger.error(
|
||||
f"CLI failed (code={result.returncode}): {error_text[:200]}"
|
||||
)
|
||||
return AgentResponse(
|
||||
text="",
|
||||
error=f"OpenCode CLI error: {error_text[:500]}",
|
||||
)
|
||||
|
||||
# Parse the output — mirrors OpenClaw's parseCliJson/parseCliJsonl
|
||||
response_text = self._parse_cli_output(stdout)
|
||||
|
||||
if not response_text:
|
||||
response_text = stdout # fallback to raw output
|
||||
|
||||
# Extract session ID if returned
|
||||
session_id = self._extract_session_id(stdout)
|
||||
if session_id and conversation_id:
|
||||
self._sessions.set(conversation_id, session_id)
|
||||
|
||||
return AgentResponse(
|
||||
text=response_text,
|
||||
session_id=session_id,
|
||||
model=self._config.model,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
f"CLI timeout after {self._config.timeout_seconds}s"
|
||||
)
|
||||
return AgentResponse(
|
||||
text="",
|
||||
error=f"Request timed out after {self._config.timeout_seconds}s",
|
||||
)
|
||||
|
||||
def _build_cli_args(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build CLI arguments for `opencode run`.
|
||||
|
||||
Modeled after OpenClaw's buildCliArgs() in cli-runner/helpers.ts:
|
||||
- base args (command + run)
|
||||
- model arg (--model)
|
||||
- session arg (--session / --continue)
|
||||
- system prompt (prepended to message as XML block)
|
||||
- format arg (--format)
|
||||
- the prompt itself
|
||||
"""
|
||||
args = [self._config.command, "run"]
|
||||
|
||||
# Model selection
|
||||
if self._config.model:
|
||||
args.extend(["--model", self._config.model])
|
||||
|
||||
# Session continuity — like OpenClaw's sessionArg
|
||||
existing_session = None
|
||||
if conversation_id:
|
||||
existing_session = self._sessions.get(conversation_id)
|
||||
|
||||
if existing_session:
|
||||
# Continue an existing session
|
||||
args.extend(["--continue", "--session", existing_session])
|
||||
# For new conversations, OpenCode creates a new session automatically
|
||||
|
||||
# Output format — use JSON for structured parsing, default for plain text
|
||||
# Valid choices: "default" (formatted), "json" (raw JSON events)
|
||||
if self._config.format and self._config.format in ("default", "json"):
|
||||
args.extend(["--format", self._config.format])
|
||||
|
||||
# Build the full prompt — prepend system prompt if provided
|
||||
# opencode run doesn't have a --system-prompt flag, so we inject it
|
||||
# as an XML-tagged block before the user message
|
||||
if system_prompt:
|
||||
full_message = (
|
||||
f"<system_instructions>\n{system_prompt}\n</system_instructions>\n\n"
|
||||
f"<user_message>\n{message}\n</user_message>"
|
||||
)
|
||||
else:
|
||||
full_message = message
|
||||
|
||||
# The prompt message (must come last as a positional arg)
|
||||
args.append(full_message)
|
||||
|
||||
return args
|
||||
|
||||
def _build_cli_env(self) -> dict[str, str]:
|
||||
"""
|
||||
Build environment variables for the CLI subprocess.
|
||||
|
||||
Note: OpenCode reads OPENCODE_* env vars as config overrides and
|
||||
tries to parse their values as JSON. We must NOT set arbitrary
|
||||
OPENCODE_* vars here — only pass through the parent environment.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
return env
|
||||
|
||||
def _parse_cli_output(self, stdout: str) -> str:
|
||||
"""
|
||||
Parse CLI output to extract the response text.
|
||||
|
||||
OpenCode's `--format json` emits JSONL (one JSON object per line):
|
||||
{"type":"step_start", "sessionID":"ses_...", "part":{...}}
|
||||
{"type":"text", "sessionID":"ses_...", "part":{"type":"text","text":"Hello!"}}
|
||||
{"type":"step_finish","sessionID":"ses_...", "part":{"type":"step-finish",...}}
|
||||
|
||||
We extract text from events where type == "text" and part.text exists.
|
||||
"""
|
||||
if not stdout.strip():
|
||||
return ""
|
||||
|
||||
# Parse JSONL lines — collect text from "text" type events
|
||||
lines = stdout.strip().split("\n")
|
||||
texts = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
# OpenCode event format: extract text from part.text
|
||||
event_type = event.get("type", "")
|
||||
part = event.get("part", {})
|
||||
|
||||
if event_type == "text" and isinstance(part, dict):
|
||||
text = part.get("text", "")
|
||||
if text:
|
||||
texts.append(text)
|
||||
continue
|
||||
|
||||
# Fallback: try generic text extraction (for non-OpenCode formats)
|
||||
text = self._collect_text(event)
|
||||
if text:
|
||||
texts.append(text)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON — might be plain text output (--format default)
|
||||
texts.append(line)
|
||||
|
||||
if texts:
|
||||
return "\n".join(texts)
|
||||
|
||||
# Final fallback to raw text
|
||||
return stdout.strip()
|
||||
|
||||
def _collect_text(self, value: Any) -> str:
|
||||
"""
|
||||
Recursively collect text from a parsed JSON object.
|
||||
Adapted from OpenClaw's collectText() from helpers.ts,
|
||||
with awareness of OpenCode's event structure.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
|
||||
if isinstance(value, list):
|
||||
return "".join(self._collect_text(item) for item in value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
# Skip OpenCode event wrapper — dig into "part" first
|
||||
if "part" in value and isinstance(value["part"], dict):
|
||||
part = value["part"]
|
||||
if "text" in part and isinstance(part["text"], str):
|
||||
return part["text"]
|
||||
|
||||
# Try common text fields
|
||||
if "content" in value and isinstance(value["content"], str):
|
||||
return value["content"]
|
||||
if "content" in value and isinstance(value["content"], list):
|
||||
return "".join(
|
||||
self._collect_text(item) for item in value["content"]
|
||||
)
|
||||
if "message" in value and isinstance(value["message"], dict):
|
||||
return self._collect_text(value["message"])
|
||||
if "result" in value:
|
||||
return self._collect_text(value["result"])
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_session_id(self, stdout: str) -> str | None:
|
||||
"""
|
||||
Extract session ID from CLI output.
|
||||
|
||||
OpenCode includes sessionID in every JSONL event line:
|
||||
{"type":"text", "sessionID":"ses_abc123", ...}
|
||||
|
||||
We grab it from the first event that has one.
|
||||
"""
|
||||
lines = stdout.strip().split("\n")
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
# OpenCode format: top-level sessionID
|
||||
session_id = event.get("sessionID")
|
||||
if isinstance(session_id, str) and session_id.strip():
|
||||
return session_id.strip()
|
||||
|
||||
# Fallback: check nested part.sessionID
|
||||
part = event.get("part", {})
|
||||
if isinstance(part, dict):
|
||||
session_id = part.get("sessionID")
|
||||
if isinstance(session_id, str) and session_id.strip():
|
||||
return session_id.strip()
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# SDK Mode: OpenCode serve API
|
||||
# (enhanced version of CLI mode, using the official Python SDK)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _init_sdk_client(self) -> None:
|
||||
"""Initialize the OpenCode Python SDK client."""
|
||||
try:
|
||||
from opencode_ai import Opencode
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"base_url": self._config.server_url,
|
||||
}
|
||||
|
||||
if self._config.server_password:
|
||||
import httpx
|
||||
|
||||
kwargs["http_client"] = httpx.Client(
|
||||
auth=(
|
||||
self._config.server_username,
|
||||
self._config.server_password,
|
||||
)
|
||||
)
|
||||
|
||||
self._sdk_client = Opencode(**kwargs)
|
||||
|
||||
# Test connectivity
|
||||
try:
|
||||
self._sdk_client.app.get()
|
||||
self._sdk_available = True
|
||||
logger.info(
|
||||
f"SDK connected to {self._config.server_url}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"SDK connection test failed: {e}. "
|
||||
f"Will fall back to CLI mode."
|
||||
)
|
||||
self._sdk_available = False
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"opencode-ai SDK not installed. "
|
||||
"Install with: pip install opencode-ai. "
|
||||
"Falling back to CLI mode."
|
||||
)
|
||||
self._sdk_available = False
|
||||
|
||||
def _chat_sdk(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> AgentResponse:
|
||||
"""
|
||||
Chat using the OpenCode Python SDK.
|
||||
|
||||
Uses the server API:
|
||||
1. Create or reuse a session (POST /session)
|
||||
2. Send a message (POST /session/:id/message → client.session.chat)
|
||||
3. Parse the AssistantMessage response
|
||||
"""
|
||||
if not self._sdk_client:
|
||||
return self._chat_cli(message, conversation_id, system_prompt)
|
||||
|
||||
try:
|
||||
# Resolve or create session
|
||||
session_id = None
|
||||
if conversation_id:
|
||||
session_id = self._sessions.get(conversation_id)
|
||||
|
||||
if not session_id:
|
||||
# Create a new session
|
||||
session = self._sdk_client.session.create()
|
||||
session_id = session.id
|
||||
if conversation_id:
|
||||
self._sessions.set(conversation_id, session_id)
|
||||
logger.info(f"SDK: created session {session_id}")
|
||||
|
||||
# Build message parts
|
||||
parts = [{"type": "text", "text": message}]
|
||||
|
||||
# Build chat params
|
||||
chat_kwargs: dict[str, Any] = {"parts": parts}
|
||||
|
||||
if self._config.model:
|
||||
chat_kwargs["model"] = self._config.model
|
||||
|
||||
if system_prompt:
|
||||
chat_kwargs["system"] = system_prompt
|
||||
|
||||
# Send message and get response
|
||||
logger.info(
|
||||
f"SDK chat: session={session_id[:8]}... "
|
||||
f"prompt_chars={len(message)}"
|
||||
)
|
||||
|
||||
response = self._sdk_client.session.chat(
|
||||
session_id, **chat_kwargs
|
||||
)
|
||||
|
||||
# Extract text from the AssistantMessage response
|
||||
response_text = self._extract_sdk_response_text(response)
|
||||
|
||||
return AgentResponse(
|
||||
text=response_text,
|
||||
session_id=session_id,
|
||||
model=self._config.model,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"SDK chat failed: {e}. Falling back to CLI mode."
|
||||
)
|
||||
# Graceful fallback to CLI mode
|
||||
return self._chat_cli(message, conversation_id, system_prompt)
|
||||
|
||||
def _extract_sdk_response_text(self, response: Any) -> str:
|
||||
"""Extract text content from the SDK's AssistantMessage response."""
|
||||
# The response is an AssistantMessage which has parts
|
||||
if hasattr(response, "parts"):
|
||||
texts = []
|
||||
for part in response.parts:
|
||||
if hasattr(part, "text"):
|
||||
texts.append(part.text)
|
||||
elif hasattr(part, "content"):
|
||||
texts.append(str(part.content))
|
||||
return "\n".join(texts).strip()
|
||||
|
||||
# Fallback: try to get text directly
|
||||
if hasattr(response, "text"):
|
||||
return response.text.strip()
|
||||
|
||||
# Last resort: stringify
|
||||
return str(response).strip()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Validation & Utilities
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _validate_installation(self) -> None:
|
||||
"""Check that OpenCode CLI is installed and accessible."""
|
||||
cmd = self._config.command
|
||||
|
||||
# If the resolved command doesn't exist, try resolving again
|
||||
if not os.path.isfile(cmd) and not shutil.which(cmd):
|
||||
resolved = _resolve_opencode_command()
|
||||
if resolved != "opencode" and os.path.isfile(resolved):
|
||||
self._config.command = resolved
|
||||
logger.info(f"Resolved opencode binary: {resolved}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"'{cmd}' not found. "
|
||||
f"Install with: curl -fsSL https://opencode.ai/install | bash "
|
||||
f"or: npm install -g opencode-ai"
|
||||
)
|
||||
if self._config.mode == RuntimeMode.CLI:
|
||||
logger.warning(
|
||||
"CLI mode requires opencode to be installed. "
|
||||
"If using SDK mode, set OPENCODE_MODE=sdk."
|
||||
)
|
||||
|
||||
def _is_opencode_available(self) -> bool:
|
||||
"""Check if OpenCode CLI is available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self._config.command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System Prompt Builder
|
||||
# (Mirrors OpenClaw's buildSystemPrompt in cli-runner/helpers.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_aetheel_system_prompt(
|
||||
user_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
is_dm: bool = False,
|
||||
extra_context: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the system prompt for Aetheel.
|
||||
|
||||
Like OpenClaw's buildAgentSystemPrompt(), this constructs a comprehensive
|
||||
prompt that gives the AI its identity, capabilities, and context.
|
||||
"""
|
||||
lines = [
|
||||
"You are Aetheel — a personal AI assistant that lives inside Slack.",
|
||||
"",
|
||||
"# Identity",
|
||||
"- Your name is Aetheel",
|
||||
"- You ARE a Slack bot — you are already running inside Slack right now",
|
||||
"- You have your own Slack bot token and can send messages to any channel",
|
||||
"- You have a persistent memory system with identity files (SOUL.md, USER.md, MEMORY.md)",
|
||||
"- You can read and update your memory files across sessions",
|
||||
"",
|
||||
"# Your Capabilities",
|
||||
"- **Direct messaging**: You are already in the user's Slack workspace — no setup needed",
|
||||
"- **Memory**: You have SOUL.md (your personality), USER.md (user profile), MEMORY.md (long-term memory)",
|
||||
"- **Session logs**: Conversations are automatically saved to daily/ session files",
|
||||
"- **Reminders**: You can schedule messages to be sent later using action tags (see below)",
|
||||
"",
|
||||
"# Action Tags",
|
||||
"You can perform actions by including special tags in your response.",
|
||||
"The system will parse these tags and execute the actions automatically.",
|
||||
"",
|
||||
"## Reminders",
|
||||
"To schedule a reminder, include this tag anywhere in your response:",
|
||||
"```",
|
||||
"[ACTION:remind|<minutes>|<message>]",
|
||||
"```",
|
||||
"Example: `[ACTION:remind|2|Time to drink water! 💧]` — sends a Slack message in 2 minutes",
|
||||
"Example: `[ACTION:remind|30|Stand up and stretch! 🧘]` — sends a message in 30 minutes",
|
||||
"",
|
||||
"When scheduling a reminder, confirm to the user that it's been set,",
|
||||
"and include the action tag in your response (it will be hidden from the user).",
|
||||
"",
|
||||
"# Guidelines",
|
||||
"- Be helpful, concise, and friendly",
|
||||
"- Use Slack formatting (bold with *text*, code with `text`, etc.)",
|
||||
"- Keep responses focused and relevant",
|
||||
"- If you don't know something, say so honestly",
|
||||
"- Avoid extremely long responses unless asked for detail",
|
||||
"- NEVER ask for Slack tokens, webhook URLs, or API keys — you already have them",
|
||||
"- NEVER suggest the user 'set up' Slack — you ARE the Slack bot",
|
||||
"",
|
||||
"# Context",
|
||||
]
|
||||
|
||||
if user_name:
|
||||
lines.append(f"- You are chatting with: {user_name}")
|
||||
if channel_name and not is_dm:
|
||||
lines.append(f"- Channel: #{channel_name}")
|
||||
if is_dm:
|
||||
lines.append("- This is a direct message (private conversation)")
|
||||
|
||||
if extra_context:
|
||||
lines.append("")
|
||||
lines.append(extra_context)
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user