Skip to content

Commit df9fa75

Browse files
Hyperion-shuoshenshuo
andauthored
Add OpenCode CLI adapter for external agent workflows (#348)
* open code tb2 eval can run * Add cwd alias to opencode adapter --------- Co-authored-by: shenshuo <shenshuo@pjlab.org.cn>
1 parent 13217f5 commit df9fa75

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

lagent/adapters/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .claude_code import ClaudeCodeAdapter
33
from .claude_code_sdk import ClaudeCodeSDKAdapter
44
from .cli_adapter import CLIAgentAdapter
5+
from .opencode_cli import OpenCodeCLIAdapter
56
from .mini_swe_agent import MiniSWEAgentAdapter
67
from .proxy import SessionClient
78
from .sdk_adapter import SDKAgentAdapter
@@ -10,6 +11,7 @@
1011
'BaseExternalAgent',
1112
'AsyncExternalAgent',
1213
'CLIAgentAdapter',
14+
'OpenCodeCLIAdapter',
1315
'ClaudeCodeAdapter',
1416
'ClaudeCodeSDKAdapter',
1517
'SDKAgentAdapter',

lagent/adapters/opencode_cli.py

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
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

Comments
 (0)