py-opencode-wrapper 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_wrapper/__init__.py +38 -0
- opencode_wrapper/client.py +382 -0
- opencode_wrapper/config.py +109 -0
- opencode_wrapper/errors.py +39 -0
- opencode_wrapper/events.py +192 -0
- py_opencode_wrapper-0.1.2.dist-info/METADATA +161 -0
- py_opencode_wrapper-0.1.2.dist-info/RECORD +9 -0
- py_opencode_wrapper-0.1.2.dist-info/WHEEL +5 -0
- py_opencode_wrapper-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""OpenCode CLI async wrapper for Python orchestration."""
|
|
2
|
+
|
|
3
|
+
from opencode_wrapper.client import AsyncOpenCodeClient, build_argv, build_env, resolve_binary
|
|
4
|
+
from opencode_wrapper.config import RunConfig, validate_config_for_run, validate_permission_actions
|
|
5
|
+
from opencode_wrapper.errors import (
|
|
6
|
+
OpenCodeBinaryNotFoundError,
|
|
7
|
+
OpenCodeCancelledError,
|
|
8
|
+
OpenCodeError,
|
|
9
|
+
OpenCodeProcessError,
|
|
10
|
+
OpenCodeTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
from opencode_wrapper.events import (
|
|
13
|
+
RunResult,
|
|
14
|
+
TokenUsage,
|
|
15
|
+
aggregate_run_result,
|
|
16
|
+
parse_event_line,
|
|
17
|
+
run_result_fuzzy_text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncOpenCodeClient",
|
|
22
|
+
"RunConfig",
|
|
23
|
+
"RunResult",
|
|
24
|
+
"TokenUsage",
|
|
25
|
+
"aggregate_run_result",
|
|
26
|
+
"build_argv",
|
|
27
|
+
"build_env",
|
|
28
|
+
"parse_event_line",
|
|
29
|
+
"run_result_fuzzy_text",
|
|
30
|
+
"resolve_binary",
|
|
31
|
+
"validate_config_for_run",
|
|
32
|
+
"validate_permission_actions",
|
|
33
|
+
"OpenCodeError",
|
|
34
|
+
"OpenCodeBinaryNotFoundError",
|
|
35
|
+
"OpenCodeProcessError",
|
|
36
|
+
"OpenCodeTimeoutError",
|
|
37
|
+
"OpenCodeCancelledError",
|
|
38
|
+
]
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Async client: spawn ``opencode run --format json`` and stream parsed events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, AsyncIterator, Mapping
|
|
13
|
+
|
|
14
|
+
from opencode_wrapper.config import RunConfig, validate_config_for_run
|
|
15
|
+
from opencode_wrapper.errors import (
|
|
16
|
+
OpenCodeBinaryNotFoundError,
|
|
17
|
+
OpenCodeProcessError,
|
|
18
|
+
OpenCodeTimeoutError,
|
|
19
|
+
)
|
|
20
|
+
from opencode_wrapper.events import RunResult, aggregate_run_result, parse_event_line
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_binary(binary: str) -> str:
|
|
24
|
+
"""Resolve ``binary`` to an executable path."""
|
|
25
|
+
expanded = Path(binary).expanduser()
|
|
26
|
+
if expanded.is_file():
|
|
27
|
+
return str(expanded)
|
|
28
|
+
found = shutil.which(binary)
|
|
29
|
+
if found:
|
|
30
|
+
return found
|
|
31
|
+
raise OpenCodeBinaryNotFoundError(f"OpenCode binary not found: {binary!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_argv(
|
|
35
|
+
binary_resolved: str,
|
|
36
|
+
prompt: str,
|
|
37
|
+
run_cfg: RunConfig,
|
|
38
|
+
) -> list[str]:
|
|
39
|
+
"""Build ``opencode run`` argument list."""
|
|
40
|
+
cmd: list[str] = [binary_resolved, "run", "--format", "json"]
|
|
41
|
+
|
|
42
|
+
if run_cfg.print_logs:
|
|
43
|
+
cmd.append("--print-logs")
|
|
44
|
+
if run_cfg.log_level:
|
|
45
|
+
cmd.extend(["--log-level", run_cfg.log_level])
|
|
46
|
+
if run_cfg.command:
|
|
47
|
+
cmd.extend(["--command", run_cfg.command])
|
|
48
|
+
if run_cfg.continue_session:
|
|
49
|
+
cmd.append("--continue")
|
|
50
|
+
if run_cfg.session_id:
|
|
51
|
+
cmd.extend(["--session", run_cfg.session_id])
|
|
52
|
+
if run_cfg.fork:
|
|
53
|
+
cmd.append("--fork")
|
|
54
|
+
if run_cfg.share is True:
|
|
55
|
+
cmd.append("--share")
|
|
56
|
+
if run_cfg.model:
|
|
57
|
+
cmd.extend(["-m", run_cfg.model])
|
|
58
|
+
if run_cfg.agent:
|
|
59
|
+
cmd.extend(["--agent", run_cfg.agent])
|
|
60
|
+
for f in run_cfg.files:
|
|
61
|
+
cmd.extend(["-f", str(f)])
|
|
62
|
+
if run_cfg.title:
|
|
63
|
+
cmd.extend(["--title", run_cfg.title])
|
|
64
|
+
if run_cfg.attach:
|
|
65
|
+
cmd.extend(["--attach", run_cfg.attach])
|
|
66
|
+
if run_cfg.password:
|
|
67
|
+
cmd.extend(["-p", run_cfg.password])
|
|
68
|
+
if run_cfg.remote_dir:
|
|
69
|
+
cmd.extend(["--dir", run_cfg.remote_dir])
|
|
70
|
+
if run_cfg.port is not None:
|
|
71
|
+
cmd.extend(["--port", str(run_cfg.port)])
|
|
72
|
+
if run_cfg.variant:
|
|
73
|
+
cmd.extend(["--variant", run_cfg.variant])
|
|
74
|
+
if run_cfg.thinking is True:
|
|
75
|
+
cmd.append("--thinking")
|
|
76
|
+
|
|
77
|
+
if prompt:
|
|
78
|
+
cmd.append(prompt)
|
|
79
|
+
return cmd
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_env(run_cfg: RunConfig, base: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
83
|
+
env = dict(base if base is not None else os.environ)
|
|
84
|
+
if run_cfg.extra_env:
|
|
85
|
+
env.update(dict(run_cfg.extra_env))
|
|
86
|
+
content = run_cfg.opencode_config_content_json()
|
|
87
|
+
if content is not None:
|
|
88
|
+
env["OPENCODE_CONFIG_CONTENT"] = content
|
|
89
|
+
if run_cfg.disable_autoupdate:
|
|
90
|
+
env["OPENCODE_DISABLE_AUTOUPDATE"] = "1"
|
|
91
|
+
return env
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _readline_unlimited(reader: asyncio.StreamReader) -> bytes:
|
|
95
|
+
"""readline with no size limit, works around asyncio's default 64 KiB cap.
|
|
96
|
+
|
|
97
|
+
Uses ``readuntil()`` directly instead of ``readline()``: unlike ``readline()``,
|
|
98
|
+
``readuntil()`` raises ``LimitOverrunError`` *without* clearing the buffer, so
|
|
99
|
+
we can drain the oversized chunk with ``readexactly()`` and keep looping.
|
|
100
|
+
"""
|
|
101
|
+
chunks: list[bytes] = []
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
chunk = await reader.readuntil(b"\n")
|
|
105
|
+
if chunks:
|
|
106
|
+
chunks.append(chunk)
|
|
107
|
+
return b"".join(chunks)
|
|
108
|
+
return chunk
|
|
109
|
+
except asyncio.IncompleteReadError as exc:
|
|
110
|
+
# EOF reached before newline — return whatever partial data we have
|
|
111
|
+
if chunks:
|
|
112
|
+
chunks.append(exc.partial)
|
|
113
|
+
return b"".join(chunks)
|
|
114
|
+
return exc.partial
|
|
115
|
+
except asyncio.LimitOverrunError as exc:
|
|
116
|
+
# Buffer limit hit but data is still intact; drain consumed bytes and loop
|
|
117
|
+
chunks.append(bytes(await reader.readexactly(exc.consumed)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _drain_stderr(proc: asyncio.subprocess.Process, out: list[str]) -> None:
|
|
121
|
+
if proc.stderr is None:
|
|
122
|
+
return
|
|
123
|
+
while True:
|
|
124
|
+
chunk = await _readline_unlimited(proc.stderr)
|
|
125
|
+
if not chunk:
|
|
126
|
+
break
|
|
127
|
+
out.append(chunk.decode(errors="replace"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _stdout_line_event_iter(
|
|
131
|
+
proc: asyncio.subprocess.Process,
|
|
132
|
+
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
133
|
+
if proc.stdout is None:
|
|
134
|
+
return
|
|
135
|
+
while True:
|
|
136
|
+
line_b = await _readline_unlimited(proc.stdout)
|
|
137
|
+
if not line_b:
|
|
138
|
+
break
|
|
139
|
+
line = line_b.decode(errors="replace")
|
|
140
|
+
yield line, parse_event_line(line)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Substrings that indicate opencode crashed during SQLite WAL initialisation.
|
|
144
|
+
# This happens when multiple instances race to set journal_mode=WAL before
|
|
145
|
+
# busy_timeout is configured (opencode bug: busy_timeout set after WAL pragma).
|
|
146
|
+
_SQLITE_STARTUP_PATTERNS: tuple[str, ...] = (
|
|
147
|
+
"database is locked",
|
|
148
|
+
"sqlite_busy",
|
|
149
|
+
"sqliteerror",
|
|
150
|
+
"journal_mode",
|
|
151
|
+
"disk i/o error",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_sqlite_startup_error(stderr: str) -> bool:
|
|
156
|
+
"""Return True when *stderr* looks like an opencode SQLite initialisation crash."""
|
|
157
|
+
lower = stderr.lower()
|
|
158
|
+
return any(pat in lower for pat in _SQLITE_STARTUP_PATTERNS)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AsyncOpenCodeClient:
|
|
162
|
+
"""
|
|
163
|
+
One-shot async wrapper around the OpenCode CLI.
|
|
164
|
+
|
|
165
|
+
Uses ``opencode run --format json`` with optional ``OPENCODE_CONFIG_CONTENT``.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
startup_concurrency:
|
|
170
|
+
Maximum number of opencode processes that may enter their SQLite
|
|
171
|
+
initialisation window simultaneously. Defaults to ``1`` (serialised
|
|
172
|
+
startup) to avoid the WAL-pragma race that crashes concurrent instances.
|
|
173
|
+
startup_delay_s:
|
|
174
|
+
Seconds to hold the startup semaphore *after* the process is spawned,
|
|
175
|
+
giving SQLite time to finish ``PRAGMA journal_mode = WAL`` before the
|
|
176
|
+
next instance starts. Defaults to ``0.3``.
|
|
177
|
+
isolate_db:
|
|
178
|
+
If ``True`` (default), each run gets a private ``XDG_DATA_HOME`` temp
|
|
179
|
+
directory so opencode stores its SQLite database in isolation. Without
|
|
180
|
+
this, concurrent processes share ``~/.local/share/opencode/opencode.db``
|
|
181
|
+
and SQLite write locks during tool execution serialize otherwise-parallel
|
|
182
|
+
runs (observed 37–46 s delays). Set to ``False`` only if you need runs
|
|
183
|
+
to share session history.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
binary: str = "opencode",
|
|
189
|
+
startup_concurrency: int = 1,
|
|
190
|
+
startup_delay_s: float = 0.3,
|
|
191
|
+
isolate_db: bool = True,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.binary = binary
|
|
194
|
+
self._resolved_binary: str | None = None
|
|
195
|
+
self._startup_sem = asyncio.Semaphore(startup_concurrency)
|
|
196
|
+
self._startup_delay_s = startup_delay_s
|
|
197
|
+
self._isolate_db = isolate_db
|
|
198
|
+
|
|
199
|
+
def resolved_binary(self) -> str:
|
|
200
|
+
if self._resolved_binary is None:
|
|
201
|
+
self._resolved_binary = resolve_binary(self.binary)
|
|
202
|
+
return self._resolved_binary
|
|
203
|
+
|
|
204
|
+
@asynccontextmanager
|
|
205
|
+
async def _managed_process(
|
|
206
|
+
self,
|
|
207
|
+
argv: list[str],
|
|
208
|
+
cwd: str,
|
|
209
|
+
env: dict[str, str],
|
|
210
|
+
) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
|
|
211
|
+
stderr_lines: list[str] = []
|
|
212
|
+
# Give each process its own XDG_DATA_HOME so opencode.db is isolated.
|
|
213
|
+
# Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
|
|
214
|
+
# and SQLite write locks during tool execution serialize the runs (37–46s delays).
|
|
215
|
+
if self._isolate_db:
|
|
216
|
+
xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
|
|
217
|
+
env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
|
|
218
|
+
else:
|
|
219
|
+
xdg_tmpdir = None
|
|
220
|
+
# Serialise process startup to avoid the SQLite WAL-pragma race.
|
|
221
|
+
# The semaphore is released as soon as the startup window has elapsed,
|
|
222
|
+
# so all processes run concurrently after their individual delay.
|
|
223
|
+
async with self._startup_sem:
|
|
224
|
+
proc = await asyncio.create_subprocess_exec(
|
|
225
|
+
*argv,
|
|
226
|
+
cwd=cwd,
|
|
227
|
+
stdout=asyncio.subprocess.PIPE,
|
|
228
|
+
stderr=asyncio.subprocess.PIPE,
|
|
229
|
+
env=env,
|
|
230
|
+
)
|
|
231
|
+
if self._startup_delay_s > 0:
|
|
232
|
+
await asyncio.sleep(self._startup_delay_s)
|
|
233
|
+
stderr_task = asyncio.create_task(_drain_stderr(proc, stderr_lines))
|
|
234
|
+
try:
|
|
235
|
+
yield proc, stderr_lines
|
|
236
|
+
except asyncio.CancelledError:
|
|
237
|
+
if proc.returncode is None:
|
|
238
|
+
try:
|
|
239
|
+
proc.kill()
|
|
240
|
+
except ProcessLookupError:
|
|
241
|
+
pass
|
|
242
|
+
raise
|
|
243
|
+
finally:
|
|
244
|
+
# Natural completion: child usually still has returncode=None until wait().
|
|
245
|
+
await proc.wait()
|
|
246
|
+
try:
|
|
247
|
+
await stderr_task
|
|
248
|
+
except asyncio.CancelledError:
|
|
249
|
+
pass
|
|
250
|
+
if xdg_tmpdir is not None:
|
|
251
|
+
shutil.rmtree(xdg_tmpdir, ignore_errors=True)
|
|
252
|
+
|
|
253
|
+
async def async_stream(
|
|
254
|
+
self,
|
|
255
|
+
prompt: str,
|
|
256
|
+
workspace: str | Path,
|
|
257
|
+
*,
|
|
258
|
+
run_cfg: RunConfig | None = None,
|
|
259
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
260
|
+
"""
|
|
261
|
+
Yield parsed JSON event dicts from stdout.
|
|
262
|
+
|
|
263
|
+
After the stream completes successfully, returns normally.
|
|
264
|
+
On non-zero exit, raises :class:`OpenCodeProcessError` (after all lines are yielded).
|
|
265
|
+
"""
|
|
266
|
+
run_cfg = run_cfg or RunConfig()
|
|
267
|
+
validate_config_for_run(run_cfg)
|
|
268
|
+
bin_path = self.resolved_binary()
|
|
269
|
+
argv = build_argv(bin_path, prompt, run_cfg)
|
|
270
|
+
env = build_env(run_cfg)
|
|
271
|
+
cwd = str(Path(workspace).expanduser().resolve())
|
|
272
|
+
|
|
273
|
+
events_acc: list[dict[str, Any]] = []
|
|
274
|
+
raw_acc: list[str] = []
|
|
275
|
+
|
|
276
|
+
async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
|
|
277
|
+
async for line, ev in _stdout_line_event_iter(proc):
|
|
278
|
+
raw_acc.append(line)
|
|
279
|
+
events_acc.append(ev)
|
|
280
|
+
yield ev
|
|
281
|
+
|
|
282
|
+
code = proc.returncode if proc.returncode is not None else -1
|
|
283
|
+
stderr = "".join(stderr_lines)
|
|
284
|
+
if code != 0:
|
|
285
|
+
raise OpenCodeProcessError(
|
|
286
|
+
exit_code=code,
|
|
287
|
+
stderr=stderr,
|
|
288
|
+
events=events_acc,
|
|
289
|
+
raw_stdout_lines=raw_acc,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
async def async_run(
|
|
293
|
+
self,
|
|
294
|
+
prompt: str,
|
|
295
|
+
workspace: str | Path,
|
|
296
|
+
*,
|
|
297
|
+
run_cfg: RunConfig | None = None,
|
|
298
|
+
timeout_s: float | None = None,
|
|
299
|
+
log_file: str | Path | None = None,
|
|
300
|
+
max_retries: int = 2,
|
|
301
|
+
retry_delay_s: float = 1.0,
|
|
302
|
+
) -> RunResult:
|
|
303
|
+
"""
|
|
304
|
+
Run to completion and return a :class:`RunResult`.
|
|
305
|
+
|
|
306
|
+
If ``log_file`` is given, each event dict is appended as a JSON line
|
|
307
|
+
during execution (flushed immediately), so partial progress survives
|
|
308
|
+
crashes.
|
|
309
|
+
|
|
310
|
+
Raises :class:`OpenCodeTimeoutError` if ``timeout_s`` elapses.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
max_retries:
|
|
315
|
+
Number of additional attempts when opencode crashes during SQLite
|
|
316
|
+
startup (WAL-pragma race). Set to ``0`` to disable retry.
|
|
317
|
+
retry_delay_s:
|
|
318
|
+
Seconds to wait between retry attempts.
|
|
319
|
+
"""
|
|
320
|
+
run_cfg = run_cfg or RunConfig()
|
|
321
|
+
|
|
322
|
+
async def _inner() -> RunResult:
|
|
323
|
+
validate_config_for_run(run_cfg)
|
|
324
|
+
bin_path = self.resolved_binary()
|
|
325
|
+
argv = build_argv(bin_path, prompt, run_cfg)
|
|
326
|
+
env = build_env(run_cfg)
|
|
327
|
+
cwd = str(Path(workspace).expanduser().resolve())
|
|
328
|
+
|
|
329
|
+
events_acc: list[dict[str, Any]] = []
|
|
330
|
+
raw_acc: list[str] = []
|
|
331
|
+
|
|
332
|
+
log_fh = open(log_file, "w") if log_file is not None else None
|
|
333
|
+
try:
|
|
334
|
+
async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
|
|
335
|
+
async for line, ev in _stdout_line_event_iter(proc):
|
|
336
|
+
raw_acc.append(line)
|
|
337
|
+
events_acc.append(ev)
|
|
338
|
+
if log_fh is not None:
|
|
339
|
+
log_fh.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
|
340
|
+
log_fh.flush()
|
|
341
|
+
finally:
|
|
342
|
+
if log_fh is not None:
|
|
343
|
+
log_fh.close()
|
|
344
|
+
|
|
345
|
+
code = proc.returncode if proc.returncode is not None else -1
|
|
346
|
+
stderr = "".join(stderr_lines)
|
|
347
|
+
if code != 0:
|
|
348
|
+
raise OpenCodeProcessError(
|
|
349
|
+
exit_code=code,
|
|
350
|
+
stderr=stderr,
|
|
351
|
+
events=events_acc,
|
|
352
|
+
raw_stdout_lines=raw_acc,
|
|
353
|
+
)
|
|
354
|
+
return aggregate_run_result(
|
|
355
|
+
events=events_acc,
|
|
356
|
+
raw_stdout_lines=raw_acc,
|
|
357
|
+
exit_code=code,
|
|
358
|
+
stderr=stderr,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def _run_with_retries() -> RunResult:
|
|
362
|
+
last_exc: OpenCodeProcessError | None = None
|
|
363
|
+
for attempt in range(1 + max_retries):
|
|
364
|
+
if attempt > 0:
|
|
365
|
+
await asyncio.sleep(retry_delay_s)
|
|
366
|
+
try:
|
|
367
|
+
return await _inner()
|
|
368
|
+
except OpenCodeProcessError as exc:
|
|
369
|
+
if attempt < max_retries and _is_sqlite_startup_error(exc.stderr):
|
|
370
|
+
last_exc = exc
|
|
371
|
+
continue
|
|
372
|
+
raise
|
|
373
|
+
raise last_exc # type: ignore[misc] # unreachable; satisfies type checker
|
|
374
|
+
|
|
375
|
+
if timeout_s is not None:
|
|
376
|
+
try:
|
|
377
|
+
return await asyncio.wait_for(_run_with_retries(), timeout=timeout_s)
|
|
378
|
+
except asyncio.TimeoutError as e:
|
|
379
|
+
raise OpenCodeTimeoutError(
|
|
380
|
+
f"OpenCode run exceeded timeout_s={timeout_s!r}"
|
|
381
|
+
) from e
|
|
382
|
+
return await _run_with_retries()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Runtime config merge for ``OPENCODE_CONFIG_CONTENT`` and CLI flags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
# Permission values accepted by OpenCode
|
|
11
|
+
PermissionAction = str # "allow" | "ask" | "deny"
|
|
12
|
+
|
|
13
|
+
# Nested permission maps: tool name -> action or pattern -> action
|
|
14
|
+
PermissionMap = dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
18
|
+
out = dict(base)
|
|
19
|
+
for k, v in override.items():
|
|
20
|
+
if isinstance(v, Mapping) and not isinstance(v, (str, bytes, bytearray)):
|
|
21
|
+
existing = out.get(k)
|
|
22
|
+
if isinstance(existing, dict):
|
|
23
|
+
out[k] = _deep_merge(existing, dict(v))
|
|
24
|
+
else:
|
|
25
|
+
out[k] = _deep_merge({}, dict(v))
|
|
26
|
+
else:
|
|
27
|
+
out[k] = v
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RunConfig:
|
|
33
|
+
"""Per-invocation settings merged into env and CLI."""
|
|
34
|
+
|
|
35
|
+
agent: str | None = None
|
|
36
|
+
model: str | None = None
|
|
37
|
+
files: tuple[str | Path, ...] = ()
|
|
38
|
+
title: str | None = None
|
|
39
|
+
command: str | None = None
|
|
40
|
+
continue_session: bool = False
|
|
41
|
+
session_id: str | None = None
|
|
42
|
+
fork: bool = False
|
|
43
|
+
share: bool | None = None
|
|
44
|
+
attach: str | None = None
|
|
45
|
+
password: str | None = None
|
|
46
|
+
remote_dir: str | None = None
|
|
47
|
+
port: int | None = None
|
|
48
|
+
variant: str | None = None
|
|
49
|
+
thinking: bool | None = None
|
|
50
|
+
print_logs: bool | None = None
|
|
51
|
+
log_level: str | None = None
|
|
52
|
+
disable_autoupdate: bool = True
|
|
53
|
+
extra_env: Mapping[str, str] | None = None
|
|
54
|
+
# Injected as JSON via OPENCODE_CONFIG_CONTENT (merged with config_overrides)
|
|
55
|
+
permission: PermissionMap | None = None
|
|
56
|
+
mcp: dict[str, Any] | None = None
|
|
57
|
+
tools: dict[str, Any] | None = None
|
|
58
|
+
config_overrides: dict[str, Any] | None = None
|
|
59
|
+
|
|
60
|
+
def build_opencode_config_dict(self) -> dict[str, Any]:
|
|
61
|
+
"""Build the dict serialized to ``OPENCODE_CONFIG_CONTENT``."""
|
|
62
|
+
merged: dict[str, Any] = {}
|
|
63
|
+
if self.config_overrides:
|
|
64
|
+
merged = _deep_merge(merged, self.config_overrides)
|
|
65
|
+
if self.permission is not None:
|
|
66
|
+
merged = _deep_merge(merged, {"permission": dict(self.permission)})
|
|
67
|
+
if self.mcp is not None:
|
|
68
|
+
merged = _deep_merge(merged, {"mcp": dict(self.mcp)})
|
|
69
|
+
if self.tools is not None:
|
|
70
|
+
merged = _deep_merge(merged, {"tools": dict(self.tools)})
|
|
71
|
+
return merged
|
|
72
|
+
|
|
73
|
+
def opencode_config_content_json(self) -> str | None:
|
|
74
|
+
cfg = self.build_opencode_config_dict()
|
|
75
|
+
if not cfg:
|
|
76
|
+
return None
|
|
77
|
+
return json.dumps(cfg, ensure_ascii=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_permission_actions(obj: Any, *, _path: str = "") -> None:
|
|
81
|
+
"""Ensure string leaves are non-interactive OpenCode permission actions.
|
|
82
|
+
|
|
83
|
+
``"ask"`` is rejected because the subprocess has no terminal to prompt —
|
|
84
|
+
it would block forever.
|
|
85
|
+
"""
|
|
86
|
+
allowed = frozenset({"allow", "deny"})
|
|
87
|
+
if isinstance(obj, str):
|
|
88
|
+
if obj == "ask":
|
|
89
|
+
loc = f" at {_path!r}" if _path else ""
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"Permission action 'ask' is not supported in non-interactive "
|
|
92
|
+
f"subprocess mode{loc}; use 'allow' or 'deny' instead"
|
|
93
|
+
)
|
|
94
|
+
if obj not in allowed:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Invalid permission action {obj!r}{' at ' + repr(_path) if _path else ''}; "
|
|
97
|
+
f"expected one of {sorted(allowed)}"
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
if isinstance(obj, dict):
|
|
101
|
+
for k, v in obj.items():
|
|
102
|
+
child_path = f"{_path}.{k}" if _path else k
|
|
103
|
+
validate_permission_actions(v, _path=child_path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_config_for_run(cfg: RunConfig) -> None:
|
|
107
|
+
"""Strict checks before spawning."""
|
|
108
|
+
if cfg.permission is not None:
|
|
109
|
+
validate_permission_actions(cfg.permission)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exceptions raised by the OpenCode async client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenCodeError(Exception):
|
|
10
|
+
"""Base error for OpenCode wrapper operations."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenCodeBinaryNotFoundError(OpenCodeError):
|
|
14
|
+
"""The configured OpenCode executable path does not exist or is not a file."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class OpenCodeProcessError(OpenCodeError):
|
|
19
|
+
"""``opencode`` exited with a non-zero status or was killed."""
|
|
20
|
+
|
|
21
|
+
exit_code: int | None
|
|
22
|
+
stderr: str
|
|
23
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
24
|
+
raw_stdout_lines: list[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
parts = [f"opencode exited with code {self.exit_code!r}"]
|
|
28
|
+
if self.stderr.strip():
|
|
29
|
+
tail = self.stderr.strip()[-2000:]
|
|
30
|
+
parts.append(f"stderr (tail):\n{tail}")
|
|
31
|
+
return "\n".join(parts)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OpenCodeTimeoutError(OpenCodeError):
|
|
35
|
+
"""The OpenCode subprocess did not finish within the configured timeout."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OpenCodeCancelledError(OpenCodeError):
|
|
39
|
+
"""The run was cancelled (e.g. asyncio task cancellation)."""
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Parse ``opencode run --format json`` stdout lines and aggregate ``RunResult``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_event_line(line: str) -> dict[str, Any]:
|
|
11
|
+
"""
|
|
12
|
+
Parse one stdout line into an event dict.
|
|
13
|
+
|
|
14
|
+
Non-JSON lines become a ``diagnostic`` event so the stream never breaks.
|
|
15
|
+
"""
|
|
16
|
+
stripped = line.strip()
|
|
17
|
+
if not stripped:
|
|
18
|
+
return {"type": "diagnostic", "kind": "empty_line", "raw": line}
|
|
19
|
+
try:
|
|
20
|
+
obj = json.loads(stripped)
|
|
21
|
+
if isinstance(obj, dict):
|
|
22
|
+
return obj
|
|
23
|
+
return {"type": "diagnostic", "kind": "non_object_json", "value": obj}
|
|
24
|
+
except json.JSONDecodeError as e:
|
|
25
|
+
return {
|
|
26
|
+
"type": "diagnostic",
|
|
27
|
+
"kind": "json_decode_error",
|
|
28
|
+
"raw": stripped,
|
|
29
|
+
"error": str(e),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_parse_lines(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
|
|
34
|
+
for line in lines:
|
|
35
|
+
yield parse_event_line(line)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text_from_event(ev: dict[str, Any]) -> str | None:
|
|
39
|
+
t = ev.get("type")
|
|
40
|
+
if t == "text":
|
|
41
|
+
# OpenCode nested: {"type":"text","part":{"type":"text","text":"..."}}
|
|
42
|
+
part = ev.get("part")
|
|
43
|
+
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
44
|
+
return part["text"]
|
|
45
|
+
# Flat shapes: {"type":"text","content":"..."} or {"text":"..."}
|
|
46
|
+
if "content" in ev and isinstance(ev["content"], str):
|
|
47
|
+
return ev["content"]
|
|
48
|
+
if "text" in ev and isinstance(ev["text"], str):
|
|
49
|
+
return ev["text"]
|
|
50
|
+
if "delta" in ev and isinstance(ev["delta"], str):
|
|
51
|
+
return ev["delta"]
|
|
52
|
+
if t in ("message", "assistant", "model"):
|
|
53
|
+
content = ev.get("content")
|
|
54
|
+
if isinstance(content, str):
|
|
55
|
+
return content
|
|
56
|
+
# OpenCode / provider streaming: content as list of parts
|
|
57
|
+
content = ev.get("content")
|
|
58
|
+
if isinstance(content, list):
|
|
59
|
+
parts: list[str] = []
|
|
60
|
+
for part in content:
|
|
61
|
+
if isinstance(part, dict):
|
|
62
|
+
if isinstance(part.get("text"), str):
|
|
63
|
+
parts.append(part["text"])
|
|
64
|
+
elif isinstance(part.get("content"), str):
|
|
65
|
+
parts.append(part["content"])
|
|
66
|
+
elif isinstance(part, str):
|
|
67
|
+
parts.append(part)
|
|
68
|
+
if parts:
|
|
69
|
+
return "".join(parts)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_result_fuzzy_text(result: "RunResult") -> str:
|
|
74
|
+
"""
|
|
75
|
+
Best-effort extract human-visible model output across varying ``--format json`` shapes.
|
|
76
|
+
|
|
77
|
+
Uses :attr:`RunResult.final_text` when non-empty; otherwise scans events and raw lines.
|
|
78
|
+
"""
|
|
79
|
+
if (result.final_text or "").strip():
|
|
80
|
+
return result.final_text.strip()
|
|
81
|
+
pieces: list[str] = []
|
|
82
|
+
for ev in result.events:
|
|
83
|
+
if ev.get("type") == "diagnostic":
|
|
84
|
+
continue
|
|
85
|
+
chunk = _text_from_event(ev)
|
|
86
|
+
if chunk and chunk.strip():
|
|
87
|
+
pieces.append(chunk.strip())
|
|
88
|
+
continue
|
|
89
|
+
for key in ("content", "text", "delta", "output", "message", "result", "value"):
|
|
90
|
+
val = ev.get(key)
|
|
91
|
+
if isinstance(val, str) and val.strip():
|
|
92
|
+
pieces.append(val.strip())
|
|
93
|
+
msg = ev.get("message")
|
|
94
|
+
if isinstance(msg, dict):
|
|
95
|
+
for key in ("content", "text"):
|
|
96
|
+
v = msg.get(key)
|
|
97
|
+
if isinstance(v, str) and v.strip():
|
|
98
|
+
pieces.append(v.strip())
|
|
99
|
+
if pieces:
|
|
100
|
+
return "\n".join(pieces).strip()
|
|
101
|
+
raw = "\n".join(x.strip() for x in result.raw_stdout_lines if x.strip())
|
|
102
|
+
return raw.strip()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tool_summary(ev: dict[str, Any]) -> dict[str, Any] | None:
|
|
106
|
+
t = ev.get("type")
|
|
107
|
+
if t in ("tool_use", "tool_call", "tool_result", "tool"):
|
|
108
|
+
return {k: v for k, v in ev.items() if k != "type"} | {"type": t}
|
|
109
|
+
if t == "step_finish" and "tool" in ev:
|
|
110
|
+
return {"type": "step_tool", "payload": ev.get("tool")}
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class TokenUsage:
|
|
116
|
+
"""Aggregated token counts across all steps."""
|
|
117
|
+
|
|
118
|
+
total: int = 0
|
|
119
|
+
input: int = 0
|
|
120
|
+
output: int = 0
|
|
121
|
+
reasoning: int = 0
|
|
122
|
+
cache_read: int = 0
|
|
123
|
+
cache_write: int = 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class RunResult:
|
|
128
|
+
"""Aggregated outcome of a completed ``opencode run``."""
|
|
129
|
+
|
|
130
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
131
|
+
final_text: str = ""
|
|
132
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
133
|
+
exit_code: int | None = None
|
|
134
|
+
stderr: str = ""
|
|
135
|
+
raw_stdout_lines: list[str] = field(default_factory=list)
|
|
136
|
+
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
|
137
|
+
total_cost: float = 0.0
|
|
138
|
+
turns: int = 0
|
|
139
|
+
|
|
140
|
+
def append_event(self, ev: dict[str, Any]) -> None:
|
|
141
|
+
self.events.append(ev)
|
|
142
|
+
chunk = _text_from_event(ev)
|
|
143
|
+
if chunk:
|
|
144
|
+
self.final_text += chunk
|
|
145
|
+
tool = _tool_summary(ev)
|
|
146
|
+
if tool is not None:
|
|
147
|
+
self.tool_calls.append(tool)
|
|
148
|
+
if ev.get("type") == "step_finish":
|
|
149
|
+
self._accumulate_step(ev)
|
|
150
|
+
|
|
151
|
+
def _accumulate_step(self, ev: dict[str, Any]) -> None:
|
|
152
|
+
"""Extract cost/token/turn info from a ``step_finish`` event."""
|
|
153
|
+
part = ev.get("part") or ev
|
|
154
|
+
cost = part.get("cost")
|
|
155
|
+
if isinstance(cost, (int, float)):
|
|
156
|
+
self.total_cost += cost
|
|
157
|
+
tokens = part.get("tokens")
|
|
158
|
+
if isinstance(tokens, dict):
|
|
159
|
+
u = self.token_usage
|
|
160
|
+
for attr, key in (
|
|
161
|
+
("total", "total"),
|
|
162
|
+
("input", "input"),
|
|
163
|
+
("output", "output"),
|
|
164
|
+
("reasoning", "reasoning"),
|
|
165
|
+
):
|
|
166
|
+
val = tokens.get(key)
|
|
167
|
+
if isinstance(val, (int, float)):
|
|
168
|
+
setattr(u, attr, getattr(u, attr) + int(val))
|
|
169
|
+
cache = tokens.get("cache")
|
|
170
|
+
if isinstance(cache, dict):
|
|
171
|
+
for attr, key in (("cache_read", "read"), ("cache_write", "write")):
|
|
172
|
+
val = cache.get(key)
|
|
173
|
+
if isinstance(val, (int, float)):
|
|
174
|
+
setattr(u, attr, getattr(u, attr) + int(val))
|
|
175
|
+
self.turns += 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def aggregate_run_result(
|
|
179
|
+
*,
|
|
180
|
+
events: list[dict[str, Any]],
|
|
181
|
+
raw_stdout_lines: list[str],
|
|
182
|
+
exit_code: int | None,
|
|
183
|
+
stderr: str,
|
|
184
|
+
) -> RunResult:
|
|
185
|
+
r = RunResult(
|
|
186
|
+
raw_stdout_lines=list(raw_stdout_lines),
|
|
187
|
+
exit_code=exit_code,
|
|
188
|
+
stderr=stderr,
|
|
189
|
+
)
|
|
190
|
+
for ev in events:
|
|
191
|
+
r.append_event(ev)
|
|
192
|
+
return r
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-opencode-wrapper
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
|
|
5
|
+
Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
|
|
6
|
+
Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
|
|
7
|
+
Project-URL: Issues, https://github.com/idailylife/oc_py_wrapper/issues
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# oc-py-harness
|
|
15
|
+
|
|
16
|
+
Python **async** wrapper around the [OpenCode](https://opencode.ai/docs/) CLI (`opencode run --format json`). Intended as a subprocess-based executor for **multi-agent workflow** orchestration.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python 3.10+
|
|
21
|
+
- `opencode` on `PATH` (or pass an absolute path to the binary)
|
|
22
|
+
|
|
23
|
+
## Install (local tree)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[dev]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### One-shot run with aggregated result
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from opencode_wrapper import AsyncOpenCodeClient, RunConfig
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
client = AsyncOpenCodeClient("opencode")
|
|
41
|
+
cfg = RunConfig(
|
|
42
|
+
model="anthropic/claude-sonnet-4-5",
|
|
43
|
+
agent="plan",
|
|
44
|
+
permission={"bash": "deny", "edit": "deny"},
|
|
45
|
+
mcp={
|
|
46
|
+
"demo": {
|
|
47
|
+
"type": "local",
|
|
48
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
|
|
49
|
+
"enabled": True,
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
result = await client.async_run(
|
|
54
|
+
"Summarize the README in one sentence.",
|
|
55
|
+
Path("/path/to/repo"),
|
|
56
|
+
run_cfg=cfg,
|
|
57
|
+
timeout_s=600,
|
|
58
|
+
)
|
|
59
|
+
print(result.exit_code, result.final_text)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Stream structured JSON events
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async def stream_example():
|
|
68
|
+
client = AsyncOpenCodeClient()
|
|
69
|
+
cfg = RunConfig(permission={"*": "allow"})
|
|
70
|
+
async for event in client.async_stream("List top-level files.", workspace=".", run_cfg=cfg):
|
|
71
|
+
print(event)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Parallel agents (`asyncio.gather`)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
async def multi():
|
|
78
|
+
# startup_concurrency=1 serialises SQLite initialisation to avoid a known
|
|
79
|
+
# WAL-pragma race in opencode when many instances start simultaneously.
|
|
80
|
+
# startup_delay_s controls how long each slot is held before the next
|
|
81
|
+
# process is allowed to start (default 0.3 s).
|
|
82
|
+
client = AsyncOpenCodeClient(startup_concurrency=1, startup_delay_s=0.3)
|
|
83
|
+
ws = Path("/path/to/monorepo")
|
|
84
|
+
results = await asyncio.gather(*[
|
|
85
|
+
client.async_run(
|
|
86
|
+
f"Explain services/{svc}.",
|
|
87
|
+
ws / "services" / svc,
|
|
88
|
+
run_cfg=RunConfig(agent="explore"),
|
|
89
|
+
timeout_s=600,
|
|
90
|
+
# max_retries=2 (default): retry automatically if opencode crashes
|
|
91
|
+
# during SQLite startup before giving up.
|
|
92
|
+
)
|
|
93
|
+
for svc in ["api", "worker", "gateway"]
|
|
94
|
+
])
|
|
95
|
+
return results
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Configuration injection
|
|
99
|
+
|
|
100
|
+
Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
|
|
101
|
+
|
|
102
|
+
| Field | Purpose |
|
|
103
|
+
|--------|---------|
|
|
104
|
+
| `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
|
|
105
|
+
| `mcp` | MCP server definitions |
|
|
106
|
+
| `tools` | Enable/disable tools (including MCP globs) |
|
|
107
|
+
| `config_overrides` | Any extra top-level config keys to deep-merge |
|
|
108
|
+
|
|
109
|
+
Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
|
110
|
+
|
|
111
|
+
## CLI arguments
|
|
112
|
+
|
|
113
|
+
`RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
|
|
114
|
+
|
|
115
|
+
## Tests
|
|
116
|
+
|
|
117
|
+
Unit tests (no real OpenCode / no API calls):
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pytest -q -m "not integration"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Integration tests (real `opencode run`, needs working provider auth — **slow**, may incur API usage):
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pytest -m integration -q tests/test_integration_opencode.py
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Multi-agent weather workflow** (10 parallel city lookups + 1 summary — **11 API calls**, not run by default):
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
OPENCODE_MULTI_AGENT_WEATHER=1 pytest -m integration -v tests/test_integration_multi_agent_weather.py
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Optional: `OPENCODE_WEATHER_SEQUENTIAL=1` runs the 10 city calls one-by-one (easier on rate limits).
|
|
136
|
+
Per-stage timeouts: `OPENCODE_WEATHER_PER_CITY_TIMEOUT_S`, `OPENCODE_WEATHER_SUMMARY_TIMEOUT_S` (default: same as `OPENCODE_INTEGRATION_TIMEOUT_S`).
|
|
137
|
+
|
|
138
|
+
| Env | Meaning |
|
|
139
|
+
|-----|--------|
|
|
140
|
+
| `OPENCODE_BINARY` | Absolute path to `opencode` if not on `PATH` |
|
|
141
|
+
| `OPENCODE_INTEGRATION=0` | Skip integration tests |
|
|
142
|
+
| `OPENCODE_INTEGRATION_TIMEOUT_S` | Per-test timeout seconds (default `300`) |
|
|
143
|
+
| `OPENCODE_MULTI_AGENT_WEATHER=1` | Enable 11-call weather integration test |
|
|
144
|
+
| `OPENCODE_ENABLE_EXA` | Passed through / defaulted to `1` in that test for web search tools |
|
|
145
|
+
|
|
146
|
+
Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without OpenCode.
|
|
147
|
+
|
|
148
|
+
## Concurrency notes
|
|
149
|
+
|
|
150
|
+
When running many tasks with `asyncio.gather`, two mitigations are active by default:
|
|
151
|
+
|
|
152
|
+
**Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
|
|
153
|
+
|
|
154
|
+
**Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
|
|
155
|
+
|
|
156
|
+
Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
|
|
157
|
+
|
|
158
|
+
## Notes
|
|
159
|
+
|
|
160
|
+
- Event shapes from `--format json` may change between OpenCode versions; unknown fields are preserved in each parsed dict.
|
|
161
|
+
- For fully non-interactive automation, prefer explicit `permission` (`allow`/`deny`) over relying on interactive `ask` prompts.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
|
|
2
|
+
opencode_wrapper/client.py,sha256=YY7K8R6AcNWrOw595KazcLf1Q--CJgHvt6IJNT9cvOQ,13846
|
|
3
|
+
opencode_wrapper/config.py,sha256=5vOlqc0YvPeFjmY9IH4DZYhhU3nKVp54Nt2KSeQaQUQ,3872
|
|
4
|
+
opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
|
|
5
|
+
opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
|
|
6
|
+
py_opencode_wrapper-0.1.2.dist-info/METADATA,sha256=oT5MX1VOFtda9apHKONfEe_scVz3Z0PdBiNhPVMGyNg,6012
|
|
7
|
+
py_opencode_wrapper-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
py_opencode_wrapper-0.1.2.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
|
|
9
|
+
py_opencode_wrapper-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opencode_wrapper
|