|
| 1 | +"""OpenCode CLI adapter for lagent.""" |
| 2 | + |
| 3 | +import asyncio |
| 4 | +import json |
| 5 | +import os |
| 6 | +from typing import Dict, List, Optional |
| 7 | + |
| 8 | +from .cli_adapter import CLIAgentAdapter |
| 9 | + |
| 10 | + |
| 11 | +class OpenCodeCLIAdapter(CLIAgentAdapter): |
| 12 | + """Wraps the ``opencode run`` CLI as an AsyncExternalAgent. |
| 13 | +
|
| 14 | + Each call invokes:: |
| 15 | +
|
| 16 | + opencode run --format json --dir <working_dir> --model <provider/model> -- <task> |
| 17 | +
|
| 18 | + OpenCode emits JSON-lines events. The adapter captures ``sessionID`` from |
| 19 | + those events and resumes subsequent calls with ``--session <sessionID>``. |
| 20 | +
|
| 21 | + The model endpoint is supplied via ``OPENCODE_CONFIG_CONTENT``. When a |
| 22 | + :class:`SessionClient` proxy is attached, the OpenCode ``openai`` provider |
| 23 | + is pointed at ``<proxy.url>/v1`` and uses the proxy session key. |
| 24 | + """ |
| 25 | + |
| 26 | + def __init__( |
| 27 | + self, |
| 28 | + model: Optional[str] = None, |
| 29 | + provider: str = "openai-compatible", |
| 30 | + provider_npm: Optional[str] = "@ai-sdk/openai-compatible", |
| 31 | + agent: Optional[str] = None, |
| 32 | + system_prompt: Optional[str] = None, |
| 33 | + dangerously_skip_permissions: bool = False, |
| 34 | + thinking: bool = False, |
| 35 | + share: str = "disabled", |
| 36 | + autoupdate: bool = False, |
| 37 | + opencode_home: str = "/tmp/opencode-home", |
| 38 | + cwd: Optional[str] = None, |
| 39 | + binary: str = "opencode", |
| 40 | + **kwargs, |
| 41 | + ): |
| 42 | + kwargs.setdefault('name', 'opencode-cli') |
| 43 | + kwargs.setdefault('description', 'OpenCode (CLI mode)') |
| 44 | + if cwd is not None: |
| 45 | + working_dir = kwargs.get('working_dir') |
| 46 | + if working_dir is not None and working_dir != cwd: |
| 47 | + raise ValueError( |
| 48 | + 'Specify only one of cwd and working_dir, or use the same value.' |
| 49 | + ) |
| 50 | + kwargs['working_dir'] = cwd |
| 51 | + super().__init__(binary=binary, **kwargs) |
| 52 | + self.model = model |
| 53 | + self.provider = provider |
| 54 | + self.provider_npm = provider_npm |
| 55 | + self.agent = agent |
| 56 | + self.system_prompt = system_prompt |
| 57 | + self.dangerously_skip_permissions = dangerously_skip_permissions |
| 58 | + self.thinking = thinking |
| 59 | + self.share = share |
| 60 | + self.autoupdate = autoupdate |
| 61 | + self.opencode_home = opencode_home |
| 62 | + self._cli_session_id: Optional[str] = None |
| 63 | + self._last_events: List[dict] = [] |
| 64 | + |
| 65 | + def _model_arg(self, env: Optional[Dict[str, str]] = None) -> Optional[str]: |
| 66 | + env = env or os.environ |
| 67 | + model = self.model or env.get("RL_LLM_MODEL") |
| 68 | + if not model: |
| 69 | + return None |
| 70 | + if "/" in model: |
| 71 | + return model |
| 72 | + return f"{self.provider}/{model}" |
| 73 | + |
| 74 | + def _model_id(self, env: Optional[Dict[str, str]] = None) -> Optional[str]: |
| 75 | + env = env or os.environ |
| 76 | + model = self.model or env.get("RL_LLM_MODEL") |
| 77 | + if not model: |
| 78 | + return None |
| 79 | + return model.split("/", 1)[1] if "/" in model else model |
| 80 | + |
| 81 | + @staticmethod |
| 82 | + def _with_v1(base_url: str) -> str: |
| 83 | + base_url = base_url.rstrip("/") |
| 84 | + if base_url.endswith("/v1"): |
| 85 | + return base_url |
| 86 | + return f"{base_url}/v1" |
| 87 | + |
| 88 | + def _opencode_config(self, env: Dict[str, str]) -> dict: |
| 89 | + raw = env.get("OPENCODE_CONFIG_CONTENT") |
| 90 | + if raw: |
| 91 | + try: |
| 92 | + config = json.loads(raw) |
| 93 | + if not isinstance(config, dict): |
| 94 | + config = {} |
| 95 | + except json.JSONDecodeError: |
| 96 | + config = {} |
| 97 | + else: |
| 98 | + config = {} |
| 99 | + |
| 100 | + model_arg = self._model_arg(env) |
| 101 | + model_id = self._model_id(env) |
| 102 | + provider = self.provider |
| 103 | + |
| 104 | + if self.proxy: |
| 105 | + base_url = self._with_v1(self.proxy.url) |
| 106 | + api_key = f"sk-proxy-{self.session_id}" |
| 107 | + else: |
| 108 | + base_url = env.get("RL_LLM_BASE_URL") or env.get("OPENAI_BASE_URL") or "" |
| 109 | + api_key = env.get("RL_LLM_API_KEY") or env.get("OPENAI_API_KEY") or "sk-admin" |
| 110 | + |
| 111 | + if model_arg: |
| 112 | + config["model"] = model_arg |
| 113 | + config["enabled_providers"] = [provider] |
| 114 | + config["share"] = self.share |
| 115 | + config["autoupdate"] = self.autoupdate |
| 116 | + |
| 117 | + provider_cfg = config.setdefault("provider", {}).setdefault(provider, {}) |
| 118 | + provider_cfg.setdefault("name", provider) |
| 119 | + if self.provider_npm: |
| 120 | + provider_cfg.setdefault("npm", self.provider_npm) |
| 121 | + options = provider_cfg.setdefault("options", {}) |
| 122 | + if base_url: |
| 123 | + options["baseURL"] = base_url |
| 124 | + if api_key: |
| 125 | + options["apiKey"] = api_key |
| 126 | + |
| 127 | + if model_id: |
| 128 | + models = provider_cfg.setdefault("models", {}) |
| 129 | + models.setdefault( |
| 130 | + model_id, |
| 131 | + { |
| 132 | + "name": model_id, |
| 133 | + "tool_call": True, |
| 134 | + "reasoning": True, |
| 135 | + }, |
| 136 | + ) |
| 137 | + |
| 138 | + if self.system_prompt: |
| 139 | + agent_name = self.agent or "build" |
| 140 | + config.setdefault("agent", {}).setdefault(agent_name, {})[ |
| 141 | + "prompt" |
| 142 | + ] = self.system_prompt |
| 143 | + config.setdefault("default_agent", agent_name) |
| 144 | + |
| 145 | + return config |
| 146 | + |
| 147 | + def _build_env(self) -> dict: |
| 148 | + env = super()._build_env() |
| 149 | + home = self.opencode_home |
| 150 | + env["HOME"] = home |
| 151 | + env["XDG_DATA_HOME"] = os.path.join(home, ".local", "share") |
| 152 | + env["XDG_CONFIG_HOME"] = os.path.join(home, ".config") |
| 153 | + env["XDG_CACHE_HOME"] = os.path.join(home, ".cache") |
| 154 | + env["OPENCODE_CONFIG_CONTENT"] = json.dumps( |
| 155 | + self._opencode_config(env), ensure_ascii=False |
| 156 | + ) |
| 157 | + return env |
| 158 | + |
| 159 | + def _build_argv( |
| 160 | + self, task: str, env: Optional[Dict[str, str]] = None |
| 161 | + ) -> List[str]: |
| 162 | + argv = [self.binary, 'run', '--format', 'json'] |
| 163 | + if self.dangerously_skip_permissions: |
| 164 | + argv += ['--dangerously-skip-permissions'] |
| 165 | + if self.working_dir: |
| 166 | + argv += ['--dir', self.working_dir] |
| 167 | + env = env or self._build_env() |
| 168 | + model_arg = self._model_arg(env) |
| 169 | + if model_arg: |
| 170 | + argv += ['--model', model_arg] |
| 171 | + if self.agent: |
| 172 | + argv += ['--agent', self.agent] |
| 173 | + if self.thinking: |
| 174 | + argv += ['--thinking'] |
| 175 | + if self._cli_session_id: |
| 176 | + argv += ['--session', self._cli_session_id] |
| 177 | + argv += self.extra_args |
| 178 | + argv += ['--', task] |
| 179 | + return argv |
| 180 | + |
| 181 | + async def run_external_async(self, task: str, **kwargs) -> str: |
| 182 | + env = self._build_env() |
| 183 | + argv = self._build_argv(task, env) |
| 184 | + proc = await asyncio.create_subprocess_exec( |
| 185 | + *argv, |
| 186 | + stdout=asyncio.subprocess.PIPE, |
| 187 | + stderr=asyncio.subprocess.PIPE, |
| 188 | + cwd=self.working_dir, |
| 189 | + env=env, |
| 190 | + ) |
| 191 | + |
| 192 | + try: |
| 193 | + stdout_b, stderr_b = await asyncio.wait_for( |
| 194 | + proc.communicate(), timeout=self.timeout |
| 195 | + ) |
| 196 | + except asyncio.TimeoutError: |
| 197 | + proc.kill() |
| 198 | + await proc.wait() |
| 199 | + raise TimeoutError(f"`{self.binary}` timed out after {self.timeout}s") |
| 200 | + |
| 201 | + stdout = stdout_b.decode('utf-8', errors='replace') |
| 202 | + stderr = stderr_b.decode('utf-8', errors='replace') |
| 203 | + |
| 204 | + try: |
| 205 | + result = self.parse_output(stdout, stderr) |
| 206 | + except Exception as exc: |
| 207 | + if stderr.strip(): |
| 208 | + raise RuntimeError(f"{exc}\nstderr: {stderr[:2000]}") from exc |
| 209 | + raise |
| 210 | + |
| 211 | + if proc.returncode != 0: |
| 212 | + detail = result or stderr.strip() or stdout.strip() |
| 213 | + raise RuntimeError( |
| 214 | + f"`{self.binary}` exited with code {proc.returncode}: " |
| 215 | + f"{detail[:2000]}" |
| 216 | + ) |
| 217 | + |
| 218 | + if isinstance(result, str) and len(result) > self.max_output_chars: |
| 219 | + return result[: self.max_output_chars] + '\n...(truncated)' |
| 220 | + return result |
| 221 | + |
| 222 | + @staticmethod |
| 223 | + def _extract_session_id(event: dict) -> Optional[str]: |
| 224 | + props = event.get('properties') if isinstance(event.get('properties'), dict) else {} |
| 225 | + for key in ('sessionID', 'session_id', 'sessionId'): |
| 226 | + if isinstance(event.get(key), str): |
| 227 | + return event[key] |
| 228 | + if isinstance(props.get(key), str): |
| 229 | + return props[key] |
| 230 | + session = event.get('session') or props.get('session') |
| 231 | + if isinstance(session, dict) and isinstance(session.get('id'), str): |
| 232 | + return session['id'] |
| 233 | + return None |
| 234 | + |
| 235 | + @staticmethod |
| 236 | + def _error_message(payload: dict) -> str: |
| 237 | + error = payload.get('error') |
| 238 | + if isinstance(error, dict): |
| 239 | + data = error.get('data') |
| 240 | + if isinstance(data, dict) and data.get('message'): |
| 241 | + return str(data['message']) |
| 242 | + if error.get('message'): |
| 243 | + return str(error['message']) |
| 244 | + if error.get('name'): |
| 245 | + return json.dumps(error, ensure_ascii=False) |
| 246 | + if payload.get('message'): |
| 247 | + return str(payload['message']) |
| 248 | + return json.dumps(payload, ensure_ascii=False) |
| 249 | + |
| 250 | + def _default_parse(self, stdout: str, stderr: str) -> str: |
| 251 | + events: List[dict] = [] |
| 252 | + text_by_id: Dict[str, str] = {} |
| 253 | + last_text = '' |
| 254 | + error_payload: Optional[dict] = None |
| 255 | + |
| 256 | + for line in stdout.splitlines(): |
| 257 | + line = line.strip() |
| 258 | + if not line: |
| 259 | + continue |
| 260 | + try: |
| 261 | + event = json.loads(line) |
| 262 | + except json.JSONDecodeError: |
| 263 | + continue |
| 264 | + if not isinstance(event, dict): |
| 265 | + continue |
| 266 | + events.append(event) |
| 267 | + |
| 268 | + sid = self._extract_session_id(event) |
| 269 | + if sid: |
| 270 | + self._cli_session_id = sid |
| 271 | + |
| 272 | + payload = event.get('properties') if isinstance(event.get('properties'), dict) else event |
| 273 | + etype = event.get('type') or payload.get('type') or '' |
| 274 | + |
| 275 | + if etype in ('error', 'session.error', 'session.next.step.failed'): |
| 276 | + error_payload = payload |
| 277 | + |
| 278 | + if etype == 'message.part.updated': |
| 279 | + part = payload.get('part') if isinstance(payload.get('part'), dict) else {} |
| 280 | + if part.get('type') == 'text' and isinstance(part.get('text'), str): |
| 281 | + last_text = part['text'] |
| 282 | + elif etype == 'message.part.delta': |
| 283 | + part_id = payload.get('partID') or payload.get('part_id') |
| 284 | + delta = payload.get('delta') |
| 285 | + if isinstance(part_id, str) and isinstance(delta, str): |
| 286 | + text_by_id[part_id] = text_by_id.get(part_id, '') + delta |
| 287 | + last_text = text_by_id[part_id] |
| 288 | + elif etype == 'session.next.text.delta': |
| 289 | + text_id = payload.get('textID') or payload.get('text_id') |
| 290 | + delta = payload.get('delta') |
| 291 | + if isinstance(text_id, str) and isinstance(delta, str): |
| 292 | + text_by_id[text_id] = text_by_id.get(text_id, '') + delta |
| 293 | + last_text = text_by_id[text_id] |
| 294 | + elif etype == 'session.next.text.ended' and isinstance(payload.get('text'), str): |
| 295 | + last_text = payload['text'] |
| 296 | + elif isinstance(payload.get('message'), str): |
| 297 | + last_text = payload['message'] |
| 298 | + elif isinstance(payload.get('text'), str) and etype not in ( |
| 299 | + 'session.next.reasoning.delta', |
| 300 | + ): |
| 301 | + last_text = payload['text'] |
| 302 | + |
| 303 | + self._last_events = events |
| 304 | + |
| 305 | + if error_payload and not last_text: |
| 306 | + raise RuntimeError( |
| 307 | + f"opencode error: {self._error_message(error_payload)[:2000]}" |
| 308 | + ) |
| 309 | + |
| 310 | + if last_text: |
| 311 | + return last_text |
| 312 | + if events: |
| 313 | + return stdout.strip() |
| 314 | + result = stdout.strip() |
| 315 | + if stderr.strip(): |
| 316 | + result += f'\n[stderr]: {stderr.strip()}' |
| 317 | + return result |
| 318 | + |
| 319 | + def reset_session(self) -> None: |
| 320 | + """Forget the captured OpenCode session id; the next call starts fresh.""" |
| 321 | + self._cli_session_id = None |
0 commit comments