Files
pragent/pilot/review/opencode_runtime.py
T

189 lines
6.9 KiB
Python

"""Isolated opencode process runtime."""
import os
import subprocess
import time
from .budget import Budget, BudgetState, equivalent_cost
_ENV_ALLOW = frozenset({
"PATH", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TERM",
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
"NO_PROXY", "no_proxy",
})
def shared_home(work_root):
home = os.path.join(work_root, ".opencode-home")
os.makedirs(home, exist_ok=True)
return home
def ensure_global_config(home, factory_dir, install_config):
dst_dir = os.path.join(home, ".config", "opencode")
os.makedirs(dst_dir, exist_ok=True)
dst = os.path.join(dst_dir, "opencode.json")
src = os.path.join(factory_dir, "opencode.json")
if not os.path.isfile(src):
return
if not os.path.isfile(dst) or os.path.getmtime(src) > os.path.getmtime(dst):
install_config(src, dst)
def build_env(home, rtk_dir, source_env=None):
source = os.environ if source_env is None else source_env
env = {key: value for key, value in source.items() if key in _ENV_ALLOW}
env["HOME"] = home
path = env.get("PATH", "/usr/local/bin:/usr/bin:/bin")
env["PATH"] = (rtk_dir + os.pathsep + path) if rtk_dir else path
env["OPENCODE_EXPERIMENTAL_LSP_TOOL"] = source.get(
"OPENCODE_EXPERIMENTAL_LSP_TOOL", "true"
)
return env
def warm_opencode(
home, model, *, opencode_bin, ensure_config, build_environment,
runner=subprocess.run,
):
marker = os.path.join(home, ".pragent.warmed")
if os.path.exists(marker):
return
ensure_config(home)
env = build_environment(home)
try:
runner(
[opencode_bin, "run", "--pure", "--model", model, "ok"],
cwd=home, env=env, capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=240,
)
except (subprocess.TimeoutExpired, Exception):
pass
try:
open(marker, "w").close()
except OSError:
pass
def run_opencode(
workdir, model, *, opencode_bin, shared_home_fn, warm_fn,
build_environment, parse_events, prompt, timeout,
budget: Budget | None = None, budget_state: BudgetState | None = None,
runner=subprocess.run,
):
home = shared_home_fn()
warm_fn(home, model)
env = build_environment(home)
cmd = [
opencode_bin, "run", "--pure", "--format", "json",
"--agent", "pragent", "--dir", workdir, "--model", model, prompt,
]
last_err = ""
for _ in range(2):
try:
proc = _run_process(
cmd, cwd=workdir, env=env, timeout=timeout,
parse_events=parse_events, budget=budget,
budget_state=budget_state, model=model, runner=runner,
)
except subprocess.TimeoutExpired as exc:
last_err = f"opencode timed out after {exc.timeout}s"
continue
text, usage = parse_events(proc.stdout or "")
if usage and budget_state:
usage.update({f"budget_{k}": v for k, v in budget_state.snapshot().items()})
if text.strip():
return text, usage
if budget_state and budget_state.cap_reason:
reason = budget_state.cap_reason
empty_usage = usage or {
"input": 0, "output": 0, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 0,
"cost": 0.0, "steps": 0, "tool_calls": 0, "iterations": [],
}
empty_usage.update({
f"budget_{k}": v for k, v in budget_state.snapshot().items()
})
return (
"Review stopped before a complete response was produced "
f"because the budget reached {reason}.\n\n"
"```json\n{\"summary\": \"Review budget reached\", "
"\"findings\": []}\n```\n",
empty_usage,
)
if usage and usage.get("budget_cap_hit"):
return text, usage
last_err = (
f"opencode empty text (rc={proc.returncode}); "
f"stderr: {(proc.stderr or '')[-1500:]}"
)
raise RuntimeError(last_err or "opencode produced no output")
def _run_process(
cmd, *, cwd, env, timeout, parse_events, budget, budget_state, model, runner,
):
"""Run a process, terminating it after a completed event exceeds budget."""
if budget is None or budget_state is None:
return runner(
cmd, cwd=cwd, env=env, capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=timeout,
)
existing_reason = budget_state.reason()
if existing_reason:
budget_state.cap_reason = existing_reason
return subprocess.CompletedProcess(cmd, 0, "", "")
proc = subprocess.Popen(
cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL, text=True,
)
output: list[str] = []
previous = {"steps": 0, "total": 0, "output": 0, "cost": 0.0}
cap_reason = ""
started = time.monotonic()
try:
assert proc.stdout is not None
for line in proc.stdout:
output.append(line)
_, usage = parse_events("".join(output))
if usage:
delta = {
"steps": usage.get("steps", 0) - previous["steps"],
"input": usage.get("input", 0) - previous.get("input", 0),
"cache_read": usage.get("cache_read", 0) - previous.get("cache_read", 0),
"cache_write": usage.get("cache_write", 0) - previous.get("cache_write", 0),
"total": usage.get("total", 0) - previous["total"],
"output": usage.get("output", 0) - previous["output"],
}
cost = float(usage.get("cost", 0.0)) - previous["cost"]
previous.update({
"steps": usage.get("steps", 0),
"input": usage.get("input", 0),
"cache_read": usage.get("cache_read", 0),
"cache_write": usage.get("cache_write", 0),
"total": usage.get("total", 0),
"output": usage.get("output", 0),
"cost": float(usage.get("cost", 0.0)),
})
cap_reason = budget_state.record(
delta, equivalent_cost(delta, model, budget.price_target),
)
if cap_reason or time.monotonic() - started >= budget.max_duration_seconds:
cap_reason = cap_reason or "max_duration_seconds"
budget_state.cap_reason = cap_reason
proc.terminate()
break
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
finally:
if proc.stdout:
proc.stdout.close()
stderr = proc.stderr.read() if proc.stderr else ""
return subprocess.CompletedProcess(
cmd, proc.returncode, "".join(output), stderr,
)