feat: add review effort accounting and budget governor
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .budget import Budget, BudgetState, equivalent_cost
|
||||
|
||||
|
||||
_ENV_ALLOW = frozenset({
|
||||
@@ -66,6 +69,7 @@ def warm_opencode(
|
||||
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()
|
||||
@@ -78,19 +82,107 @@ def run_opencode(
|
||||
last_err = ""
|
||||
for _ in range(2):
|
||||
try:
|
||||
proc = runner(
|
||||
cmd, cwd=workdir, env=env, capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=timeout,
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user