614 lines
27 KiB
Python
614 lines
27 KiB
Python
"""Provider layer: runs agent calls via the Claude CLI or OpenCode (MiniMax).
|
|
|
|
Both runners are independent. If a binary/key is missing, only the
|
|
respective provider fails — the other keeps running unchanged.
|
|
|
|
Role routing: config.resolve_role maps (run_provider, role) → (provider, model)
|
|
ACROSS stacks, so one run can generate on MiniMax and judge on Claude. If the
|
|
routed provider is unavailable, the call falls back to the run's provider.
|
|
"""
|
|
|
|
import asyncio
|
|
import heapq
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import sqlite3
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
|
|
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_API_AGENTS,
|
|
MAX_CONCURRENT_INTERACTIVE, RAM_MIN_FREE_PCT, resolve_role)
|
|
|
|
log = logging.getLogger("creator.agents")
|
|
|
|
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
|
_active_api: set[str] = set() # agent_keys of running direct-API calls (no process to kill)
|
|
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
|
|
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
|
|
|
|
|
|
def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
|
"""Currently running agents and how long they've been running. Filter by key prefix
|
|
(e.g. f"blocks-{topic}-") for one topic. → [{key, label, runtime}] sorted longest-first."""
|
|
now = time.time()
|
|
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
|
|
for k, t in list(_active_started.items())
|
|
if (k in _active_processes or k in _active_api)
|
|
and (not scope_prefix or k.startswith(scope_prefix))]
|
|
return sorted(out, key=lambda a: -a["runtime"])
|
|
|
|
|
|
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
|
|
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
|
|
"-outline")
|
|
|
|
|
|
def agent_ebene(key: str) -> str:
|
|
"""Generierungs-Ebene eines Blocks-Agenten (für Board-Anzeige/Accounting): inventory|artefacts."""
|
|
return "artefacts" if any(m in key for m in _ARTEFAKT_MARKER) else "inventory"
|
|
|
|
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
|
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
|
|
# in the semaphore queue are also stopped immediately on abort instead of still starting.
|
|
_cancelled_prefixes: set[str] = set()
|
|
|
|
|
|
def cancel_scope(prefix: str) -> None:
|
|
_cancelled_prefixes.add(prefix)
|
|
|
|
|
|
def clear_scope(prefix: str) -> None:
|
|
_cancelled_prefixes.discard(prefix)
|
|
|
|
|
|
def _scope_cancelled(agent_key: str) -> bool:
|
|
return any(agent_key.startswith(p) for p in _cancelled_prefixes)
|
|
|
|
# Caps the real CLI processes — independent of the pipeline semaphore in
|
|
# generator.py. The acquire happens BEFORE the spawn so that queue wait time
|
|
# does not count against the agent timeout.
|
|
class _PrioritySemaphore:
|
|
"""asyncio.Semaphore variant: when slots are scarce, the LOWEST priority number is served first
|
|
(FIFO within the same priority). Lets earlier pipeline columns grab agents before later ones."""
|
|
def __init__(self, value: int):
|
|
self._value = value
|
|
self._waiters: list = [] # heap of [priority, seq, future]
|
|
self._seq = 0
|
|
|
|
async def acquire(self, priority: int = 100):
|
|
if self._value > 0:
|
|
self._value -= 1
|
|
return
|
|
fut = asyncio.get_event_loop().create_future()
|
|
entry = [priority, self._seq, fut]
|
|
self._seq += 1
|
|
heapq.heappush(self._waiters, entry)
|
|
try:
|
|
await fut # release() hands us the slot directly (no value change)
|
|
except BaseException:
|
|
entry[2] = None # tombstone so release() skips this dead waiter
|
|
if fut.done() and not fut.cancelled():
|
|
self.release() # granted just before we were cancelled → pass it on
|
|
raise
|
|
|
|
def release(self):
|
|
while self._waiters:
|
|
entry = heapq.heappop(self._waiters)
|
|
if entry[2] is not None and not entry[2].done():
|
|
entry[2].set_result(None) # hand the slot straight to the highest-priority waiter
|
|
return
|
|
self._value += 1
|
|
|
|
|
|
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # process tier (~310 MB RSS each)
|
|
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # direct-API tier (~0 RAM)
|
|
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
|
|
|
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
|
|
# MAX_CONCURRENT_AGENTS_PER_TOPIC, nested INSIDE the global _batch_sem. Priority-based too, so the
|
|
# per-topic queue can't undo the global priority when one topic is the only load.
|
|
_topic_sems: dict[str, _PrioritySemaphore] = {}
|
|
|
|
# Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
|
|
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
|
|
# instead of opening new WIP, so the makespan tail block gets slots before fresh work.
|
|
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
|
|
"dedup", "grouping", "gruppierung", "supplement", "outline", "artifact",
|
|
"question", "relevance", "level", "facts", "subblock")
|
|
|
|
|
|
def _agent_priority(key: str) -> int:
|
|
for i, tag in enumerate(_STAGE_PRIORITY):
|
|
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
|
|
return i
|
|
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
|
|
"""Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when
|
|
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally.
|
|
The per-topic cap is shared across both tiers; only the global cap is tiered (process vs API)."""
|
|
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
|
|
global_sem = _batch_sem_api if api else _batch_sem
|
|
if topic_sem is not None:
|
|
await topic_sem.acquire(priority)
|
|
await global_sem.acquire(priority)
|
|
try:
|
|
yield
|
|
finally:
|
|
global_sem.release()
|
|
if topic_sem is not None:
|
|
topic_sem.release()
|
|
|
|
# Space OpenCode starts: processes starting simultaneously collide on the internal
|
|
# session DB ("database is locked", exit after <1s). Token bucket instead of a lock
|
|
# held through spawn+sleep: the lock only assigns a start slot, the sleep happens
|
|
# outside — a wave of starts is spaced by OPENCODE_START_DELAY without a global convoy.
|
|
_opencode_start_lock = asyncio.Lock()
|
|
_OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5"))
|
|
_opencode_next_start = 0.0
|
|
|
|
|
|
async def _opencode_slot() -> None:
|
|
global _opencode_next_start
|
|
loop = asyncio.get_running_loop()
|
|
async with _opencode_start_lock:
|
|
now = loop.time()
|
|
start_at = max(now, _opencode_next_start)
|
|
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
|
await asyncio.sleep(max(0.0, start_at - now))
|
|
|
|
# RAM-adaptive admission for opencode spawns (~310 MB RSS each): below RAM_MIN_FREE_PCT
|
|
# free memory new processes wait instead of starting. Gates admission only — running
|
|
# processes are never touched.
|
|
_RAM_GATE_FLOOR = 2 # below this many running: always admit (deadlock guard)
|
|
_RAM_PER_PROC_KB = 350 * 1024 # commit estimate for a batch agent (opencode/claude, no MCP)
|
|
_RAM_PER_PROC_FULL_KB = 1250 * 1024 # `full` agent: opencode + 3 MCP servers (~300 MB each)
|
|
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
|
|
_RAM_POLL_S = 2.0
|
|
_cli_running = 0 # CLI spawns (opencode AND claude) — the deadlock-floor counter
|
|
_cli_recent_starts: list[tuple[float, int]] = [] # (monotonic ts, est_kb) of recent admissions
|
|
|
|
|
|
def _meminfo() -> tuple[int, int] | None:
|
|
"""(MemAvailable_kB, MemTotal_kB) from /proc/meminfo; None → gate fails open (non-Linux)."""
|
|
try:
|
|
text = Path("/proc/meminfo").read_text()
|
|
except OSError:
|
|
return None
|
|
m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)}
|
|
if "MemTotal" not in m or "MemAvailable" not in m:
|
|
return None
|
|
return int(m["MemAvailable"]), int(m["MemTotal"])
|
|
|
|
|
|
async def _ram_gate(agent_key: str, est_kb: int) -> bool:
|
|
"""True = start admitted (commit registered), False = scope cancelled while waiting.
|
|
Gates ALL CLI spawns (opencode + claude). `est_kb` = this spawn's RAM estimate (a `full`
|
|
agent drags 3 MCP servers). Check and commit-append happen in the same synchronous block
|
|
(no await between) — concurrent waiters cannot double-admit on the same free RAM."""
|
|
if RAM_MIN_FREE_PCT <= 0:
|
|
return True
|
|
waited = False
|
|
while True:
|
|
if _scope_cancelled(agent_key):
|
|
return False
|
|
mem = _meminfo()
|
|
if mem is None or _cli_running < _RAM_GATE_FLOOR:
|
|
break # fail open / floor
|
|
avail_kb, total_kb = mem
|
|
now = time.monotonic()
|
|
_cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
|
|
if now - t < _RAM_COMMIT_WINDOW_S]
|
|
committed_kb = sum(kb for _, kb in _cli_recent_starts)
|
|
if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
|
|
break
|
|
if not waited:
|
|
log.info("agent %s: RAM gate waiting (%.0f%% free)", agent_key, avail_kb * 100 / total_kb)
|
|
waited = True
|
|
await asyncio.sleep(_RAM_POLL_S)
|
|
_cli_recent_starts.append((time.monotonic(), est_kb))
|
|
return True
|
|
|
|
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
|
|
|
# Capability → Claude --allowedTools
|
|
_CLAUDE_TOOLS = {
|
|
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
|
"files": "Read,Bash,Write",
|
|
"read": "Read",
|
|
"none": None,
|
|
}
|
|
|
|
# Capability → OpenCode agent (tool permissions defined in dev-ops/opencode.json)
|
|
_OPENCODE_AGENTS = {
|
|
"full": "full",
|
|
"files": "files",
|
|
"read": "readonly",
|
|
"none": "text",
|
|
}
|
|
|
|
|
|
def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
|
|
"""Direct API path only for tool-less, non-streaming MiniMax calls. Model-prefix check
|
|
instead of cli check: "lokal" (ollama) also runs via opencode. Missing key or
|
|
CREATOR_TEXT_API=0 (kill switch) falls back to the opencode process path."""
|
|
return (capabilities == "none" and on_line is None
|
|
and PROVIDERS[provider]["cli"] == "opencode"
|
|
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
|
|
and bool(os.environ.get("MINIMAX_API_KEY"))
|
|
and os.getenv("CREATOR_TEXT_API", "1") != "0")
|
|
|
|
|
|
def provider_available(provider: str) -> bool:
|
|
cfg = PROVIDERS.get(provider)
|
|
if not cfg:
|
|
return False
|
|
if shutil.which(cfg["cli"]) is None:
|
|
return False
|
|
env_key = cfg.get("env_key")
|
|
if env_key and not os.environ.get(env_key):
|
|
return False
|
|
check_url = cfg.get("check_url")
|
|
if check_url:
|
|
try:
|
|
urllib.request.urlopen(check_url, timeout=1)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
# Availability cache for role routing: the routed target is probed at most once per
|
|
# TTL (check_url providers would otherwise block the loop on every call).
|
|
_avail_cache: dict[str, tuple[float, bool]] = {}
|
|
_AVAIL_TTL = 60.0
|
|
|
|
|
|
def _available_cached(provider: str) -> bool:
|
|
now = time.monotonic()
|
|
hit = _avail_cache.get(provider)
|
|
if hit and now - hit[0] < _AVAIL_TTL:
|
|
return hit[1]
|
|
ok = provider_available(provider)
|
|
_avail_cache[provider] = (now, ok)
|
|
return ok
|
|
|
|
|
|
def _kill(process) -> None:
|
|
"""Kill the agent and its child processes via the process group (otherwise the
|
|
children spawned by the CLI survive, keep the pipes open and block communicate())."""
|
|
try:
|
|
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
try:
|
|
process.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
def kill_process(agent_key_prefix: str) -> None:
|
|
"""Kill all active processes whose key starts with the prefix (covers -plan/-w1…)."""
|
|
for key, process in list(_active_processes.items()):
|
|
if process.returncode is not None: # clean up dead entries while iterating
|
|
_active_processes.pop(key, None)
|
|
_active_started.pop(key, None)
|
|
continue
|
|
if key.startswith(agent_key_prefix):
|
|
log.debug("kill agent %s", key)
|
|
_kill(process)
|
|
|
|
|
|
# Event sink for the pipeline history (injected by main.py lifespan as database.add_event —
|
|
# agents.py stays DB-free). Called fire-and-forget for every finished BATCH agent.
|
|
on_event = None
|
|
|
|
|
|
async def run_agent(
|
|
agent_key: str,
|
|
prompt: str,
|
|
timeout: int,
|
|
provider: str = DEFAULT_PROVIDER,
|
|
role: str = "fast",
|
|
capabilities: str = "none",
|
|
lane: str = "batch",
|
|
scope: str | None = None,
|
|
on_line=None,
|
|
label: str = "",
|
|
) -> tuple[int, str, str]:
|
|
if os.getenv("CREATOR_FAKE_AGENTS"): # Sekunden-Smoke: deterministische Antworten statt LLM
|
|
import fake_agents
|
|
return await fake_agents.respond(agent_key, prompt, capabilities)
|
|
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
|
|
return 1, "", "cancelled"
|
|
if provider not in PROVIDERS:
|
|
return 1, "", f"Unknown provider: {provider}"
|
|
run_provider = provider
|
|
provider, model = resolve_role(run_provider, role)
|
|
if provider != run_provider and not _available_cached(provider):
|
|
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
|
|
if not model:
|
|
return 1, "", f"No model for role '{role}' (provider: {provider})"
|
|
use_api = _use_text_api(provider, model, capabilities, on_line)
|
|
if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
|
|
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
|
queued = time.monotonic()
|
|
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key), api=use_api)
|
|
async with gate:
|
|
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
|
return 1, "", "cancelled"
|
|
wait_ms = int((time.monotonic() - queued) * 1000)
|
|
start = time.monotonic()
|
|
status = "error"
|
|
rc = None
|
|
err_tail = ""
|
|
api_tokens = None
|
|
try:
|
|
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
|
if use_api:
|
|
rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label)
|
|
res = (rc_, out_, err_)
|
|
elif PROVIDERS[provider]["cli"] == "opencode":
|
|
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
|
|
else:
|
|
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
|
|
rc = res[0]
|
|
status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
|
|
if rc not in (0, None) and rc >= 0:
|
|
err_tail = (res[2] or res[1] or "").strip()[-300:] # diagnosis: rc=1 without stderr is opaque
|
|
return res
|
|
except asyncio.TimeoutError:
|
|
status = "timeout"
|
|
raise
|
|
except asyncio.CancelledError:
|
|
status = "cancelled"
|
|
raise
|
|
finally:
|
|
if on_event is not None and scope is not None: # batch pipeline only, never fatal
|
|
try:
|
|
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
|
if agent_key.startswith("blocks-"): # Ebene fürs Per-Board-Accounting
|
|
meta["board"] = agent_ebene(agent_key)
|
|
if err_tail:
|
|
meta["stderr"] = err_tail
|
|
if api_tokens: # direct-API path: usage from the response, even on rc!=0
|
|
meta["tokens"] = api_tokens
|
|
elif PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
|
|
if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
|
|
meta["tokens"] = tok
|
|
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
|
|
status=status, dur_ms=int((time.monotonic() - start) * 1000),
|
|
wait_ms=wait_ms, meta=meta)
|
|
except Exception:
|
|
log.debug("on_event failed", exc_info=True)
|
|
|
|
|
|
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
|
|
start = time.monotonic()
|
|
|
|
async def spawn():
|
|
return await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
start_new_session=True, # own process group → killpg also kills child processes
|
|
env=env,
|
|
)
|
|
|
|
global _cli_running
|
|
if stagger:
|
|
if not await _ram_gate(agent_key, est_kb):
|
|
return 1, "", "cancelled" # like the cancelled path in run_agent
|
|
await _opencode_slot() # gate BEFORE the start slot: an admission wave still gets spaced
|
|
process = await spawn()
|
|
if stagger:
|
|
_cli_running += 1
|
|
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
|
|
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
|
|
track_key = agent_key
|
|
n = 2
|
|
while track_key in _active_processes:
|
|
track_key = f"{agent_key}~{n}"
|
|
n += 1
|
|
_active_processes[track_key] = process
|
|
_active_started[track_key] = time.time()
|
|
_active_labels[track_key] = label
|
|
try:
|
|
try:
|
|
if on_line is not None:
|
|
# Streaming path: read stdout line by line, hand each raw line to on_line LIVE.
|
|
out_chunks: list[str] = []
|
|
|
|
async def _pump():
|
|
async for raw in process.stdout:
|
|
s = raw.decode("utf-8", errors="replace")
|
|
out_chunks.append(s)
|
|
try:
|
|
on_line(s)
|
|
except Exception:
|
|
log.debug("on_line callback failed", exc_info=True)
|
|
await asyncio.wait_for(_pump(), timeout=timeout)
|
|
await process.wait()
|
|
stderr_b = await process.stderr.read()
|
|
stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
|
|
else:
|
|
stdout, stderr = await asyncio.wait_for(
|
|
process.communicate(input=stdin_data),
|
|
timeout=timeout,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
_kill(process)
|
|
try:
|
|
await asyncio.wait_for(process.wait(), timeout=5)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
log.info("agent %s: timeout after %ds", agent_key, timeout)
|
|
raise
|
|
log.info(
|
|
"agent %s: exit %s after %.1fs (%d bytes stdout)",
|
|
agent_key, process.returncode, time.monotonic() - start, len(stdout),
|
|
)
|
|
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
|
finally:
|
|
if stagger:
|
|
_cli_running -= 1
|
|
# Pop only on identity: a slot restart under the same key must not evict
|
|
# the NEW process from tracking.
|
|
if _active_processes.get(track_key) is process:
|
|
del _active_processes[track_key]
|
|
_active_started.pop(track_key, None)
|
|
_active_labels.pop(track_key, None)
|
|
|
|
|
|
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str, label: str = "") -> tuple[int, str, str]:
|
|
cfg = PROVIDERS["claude"]
|
|
cmd = [cfg["cli"], "-p", "--model", model]
|
|
tools = _CLAUDE_TOOLS.get(capabilities)
|
|
if tools:
|
|
cmd += ["--allowedTools", tools]
|
|
cmd += ["--dangerously-skip-permissions"]
|
|
# stagger=True: claude-CLI (~310 MB) also passes the RAM gate — previously ungated.
|
|
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)
|
|
|
|
|
|
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
|
|
|
|
|
def _session_tokens(agent_key: str) -> dict | None:
|
|
"""Token counters of the newest OpenCode session titled `agent_key` (set via run --title).
|
|
Best-effort read-only lookup — None when DB/row is missing; never fails the agent.
|
|
Timeouts count too: their sessions consumed tokens, that waste should be visible."""
|
|
try:
|
|
con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
|
|
try:
|
|
row = con.execute(
|
|
"SELECT tokens_input, tokens_output, tokens_reasoning,"
|
|
" tokens_cache_read, tokens_cache_write"
|
|
" FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1",
|
|
(agent_key,)).fetchone()
|
|
finally:
|
|
con.close()
|
|
except Exception:
|
|
return None
|
|
if row is None:
|
|
return None
|
|
keys = ("input", "output", "reasoning", "cache_read", "cache_write")
|
|
return {k: int(v or 0) for k, v in zip(keys, row)}
|
|
|
|
|
|
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]:
|
|
cfg = PROVIDERS[provider]
|
|
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
|
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
|
f.write(prompt)
|
|
prompt_path = Path(f.name)
|
|
# The positional message MUST come before -f: -f is an array flag and
|
|
# would otherwise eat the text as a second file name ("File not found").
|
|
cmd = [
|
|
cfg["cli"], "run",
|
|
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
|
"-m", model,
|
|
"--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
|
|
"--dangerously-skip-permissions",
|
|
"--title", agent_key, # token accounting: joins the OpenCode session to our event
|
|
"-f", str(prompt_path),
|
|
]
|
|
if on_line is not None:
|
|
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
|
|
env = None
|
|
if capabilities != "full":
|
|
# Batch agents (files/readonly/text) never use the web MCPs, but opencode starts
|
|
# every configured MCP server PER PROCESS (~3 procs / ~300 MB each). Point them
|
|
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
|
|
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
|
|
try:
|
|
# `full` keeps the 3 MCP servers (~300 MB each) → charge the gate accordingly.
|
|
est_kb = _RAM_PER_PROC_FULL_KB if capabilities == "full" else _RAM_PER_PROC_KB
|
|
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env, est_kb=est_kb)
|
|
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
|
finally:
|
|
prompt_path.unlink(missing_ok=True)
|
|
|
|
|
|
# Direct text-API path (MiniMax, Anthropic Messages format): saves the ~310 MB RSS opencode
|
|
# process for tool-less calls. The native "minimax" and "minimax-kalt" opencode providers both
|
|
# resolve to this base URL — only the per-model options differ. Options source of truth:
|
|
# dev-ops/opencode.json (+ -slim); keep in sync on changes there.
|
|
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
|
|
_API_VERSION = "2023-06-01"
|
|
_API_MAX_TOKENS = 32_000 # required Messages field; generate calls are long
|
|
_API_MODEL_OPTS = { # prefix "minimax" (native) → endpoint defaults (no entry)
|
|
"minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
|
|
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3},
|
|
}
|
|
|
|
|
|
async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
|
|
label: str = "") -> tuple[int, str, str, dict | None]:
|
|
"""→ (rc, text, err, tokens). Tokens come straight from the response usage (same key set
|
|
as _session_tokens) and are returned even on rc!=0 — waste stays visible."""
|
|
body = {
|
|
"model": model.split("/", 1)[1], # without the opencode provider prefix
|
|
"max_tokens": _API_MAX_TOKENS,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
**_API_MODEL_OPTS.get(model, {}),
|
|
}
|
|
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
|
|
track_key = agent_key
|
|
n = 2
|
|
while track_key in _active_api:
|
|
track_key = f"{agent_key}~{n}"
|
|
n += 1
|
|
_active_api.add(track_key)
|
|
_active_started[track_key] = time.time()
|
|
_active_labels[track_key] = label
|
|
start = time.monotonic()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
|
|
resp = await asyncio.wait_for( # belt: hard wall-clock cap like the process path
|
|
client.post(_API_URL, json=body, headers=headers), timeout=timeout)
|
|
except httpx.TimeoutException:
|
|
raise asyncio.TimeoutError # contract: run_agent/_race handle TimeoutError
|
|
except httpx.HTTPError as e:
|
|
return 1, "", f"{type(e).__name__}: {e}", None
|
|
finally:
|
|
_active_api.discard(track_key)
|
|
_active_started.pop(track_key, None)
|
|
_active_labels.pop(track_key, None)
|
|
log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start)
|
|
if resp.status_code != 200:
|
|
return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None
|
|
data = resp.json()
|
|
# Only text blocks count — thinking blocks (native route) are skipped.
|
|
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
|
u = data.get("usage") or {}
|
|
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
|
|
"reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0),
|
|
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
|
|
if not text.strip():
|
|
return 1, "", f"empty response (stop_reason={data.get('stop_reason')})", tokens
|
|
err = "stop_reason=max_tokens (truncated)" if data.get("stop_reason") == "max_tokens" else ""
|
|
return 0, text, err, tokens
|
|
|
|
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
|
|
def _clean_opencode_output(text: str) -> str:
|
|
"""Strip ANSI codes and the leading banner ("> agent · model")."""
|
|
text = _ANSI_RE.sub("", text)
|
|
lines = text.splitlines()
|
|
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
|
|
lines.pop(0)
|
|
return "\n".join(lines).strip()
|