Files
planer/backend/agents.py
2026-07-22 16:12:23 +02:00

565 lines
23 KiB
Python

"""Provider-Layer: LLM-Calls über Claude-CLI, OpenCode-CLI oder direkte MiniMax-API.
Port aus creator/backend/agents.py mit zwei bewussten Abweichungen:
- KEIN Provider-Fallback: resolve_role(role) kommt vollständig aus der .env; ist eine
Rolle unkonfiguriert oder der Provider nicht nutzbar, wird der Lauf pausiert
(AgentInfraError) — nie still gewechselt (Leitprinzip 2).
- Leere-Ausgabe-Triage (Phase-0-Erkenntnis 13): leerer Text bei rc=0 ist ein Fehler.
OpenCode-Pfad: keine Session in der OpenCode-DB → Netz/Provider → AgentInfraError
(Pause); Session vorhanden → Retry-Fehler (Thinking-Overrun/Stall).
Der Event-Sink `on_event` wird von außen injiziert (CLI setzt database.add_event) —
dieses Modul bleibt DB-frei.
"""
import asyncio
import heapq
import logging
import os
import re
import shutil
import signal
import sqlite3
import tempfile
import time
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
RAM_MIN_FREE_PCT, resolve_role)
log = logging.getLogger("planer.agents")
class AgentInfraError(Exception):
"""Infrastruktur nicht nutzbar (Netz/Provider down, Rolle unkonfiguriert). KEIN
inhaltliches Urteil — der Aufrufer pausiert den Lauf, nie fail-open."""
_active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_api: set[str] = set() # laufende direkte API-Calls (kein Prozess zu killen)
_active_started: dict[str, float] = {} # agent_key → Wandzeit-Start (Live-Anzeige)
_active_labels: dict[str, str] = {}
def active_agents(scope_prefix: str | None = None) -> list[dict]:
"""Laufende Agenten mit Laufzeit, längste zuerst. → [{key, label, runtime}]"""
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"])
# --- Abbruch (Prefix-basiert) --------------------------------------------------------
_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)
class _PrioritySemaphore:
"""Semaphore-Variante: bei knappen Slots gewinnt die NIEDRIGSTE Prioritätszahl
(FIFO innerhalb gleicher Priorität) — frühe Board-Spalten vor späten."""
def __init__(self, value: int):
self._value = value
self._waiters: list = [] # Heap aus [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() reicht den Slot direkt weiter (kein value-Increment)
except BaseException:
entry[2] = None # Tombstone: release() überspringt tote Waiter
if fut.done() and not fut.cancelled():
self.release() # Slot kam exakt vor dem Cancel → weitergeben
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)
return
self._value += 1
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # Prozess-Tier (~310 MB RSS)
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # API-Tier (~0 RAM)
_topic_sems: dict[str, _PrioritySemaphore] = {}
# Kleinerer Index = höhere Priorität: Karten fertig machen statt neues WIP öffnen.
_STAGE_PRIORITY = ("sichten", "judge", "nachfass", "schneiden")
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)
@asynccontextmanager
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
"""Erst der Per-Topic-Slot (fair), dann der GLOBALE Slot nach Priorität. Ein Waiter
hält beim globalen Anstehen nur seinen Topic-Slot. Topic-Cap gilt über beide Tiers."""
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()
# Stagger für OpenCode-Starts: gleichzeitige Kaltstarts kollidieren auf der internen
# Session-DB (Phase-0-Erkenntnis 13a: alle hängen, keine Session entsteht). Token-Bucket:
# der Lock vergibt nur den Start-Slot, geschlafen wird außerhalb.
_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-Gate: unter RAM_MIN_FREE_PCT freiem Speicher warten neue CLI-Prozesse ---------
_RAM_GATE_FLOOR = 2 # unter so vielen Läufen: immer zulassen (Deadlock-Schutz)
_RAM_PER_PROC_KB = 350 * 1024
_RAM_COMMIT_WINDOW_S = 10.0 # frische Zulassungen zählen als schon verbrauchtes RAM
_RAM_POLL_S = 2.0
_cli_running = 0
_cli_recent_starts: list[tuple[float, int]] = []
def _meminfo() -> tuple[int, int] | None:
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 zugelassen (Commit registriert), False = Scope während des Wartens
abgebrochen. Check und Commit-Append im selben synchronen Block (kein await dazwischen)."""
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
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 wartet (%.0f%% frei)", 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
# Capability → Claude --allowedTools / OpenCode-Agent
_CLAUDE_TOOLS = {"full": "Write,Bash,Read,WebSearch,WebFetch", "files": "Read,Bash,Write",
"read": "Read", "none": None}
_OPENCODE_AGENTS = {"full": "full", "files": "files", "read": "readonly", "none": "text"}
def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
"""Direkter API-Pfad nur für tool-lose, nicht-streamende MiniMax-Calls — spart den
OpenCode-Prozess (und dessen Stall-/Kaltstart-Probleme). PLANER_TEXT_API=0 = aus."""
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("PLANER_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
return True
def _kill(process) -> None:
"""Agent + Kindprozesse über die Prozessgruppe killen (Kinder halten sonst die
Pipes offen und blockieren 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:
for key, process in list(_active_processes.items()):
if process.returncode is not None:
_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 (CLI injiziert database.add_event). Fire-and-forget je fertigem Agenten.
on_event = None
async def run_agent(
agent_key: str,
prompt: str,
timeout: int,
role: str,
capabilities: str = "none",
scope: str | None = None,
on_line=None,
label: str = "",
) -> tuple[int, str, str]:
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
if os.getenv("PLANER_FAKE_AGENTS"): # Sekunden-Smoke: deterministisch statt LLM
import fake_agents
start = time.monotonic()
res = await fake_agents.respond(agent_key, prompt, capabilities)
if on_event is not None and scope is not None: # Telemetrie auch im Fake-Pfad testbar
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
status="ok" if res[0] == 0 else "error",
dur_ms=int((time.monotonic() - start) * 1000), wait_ms=0,
meta={"provider": "fake", "model": "fake", "role": role, "rc": res[0]})
return res
if _scope_cancelled(agent_key):
return 1, "", "cancelled"
try:
provider, model = resolve_role(role) # NUR aus .env — kein Default, kein Fallback
except RuntimeError as e:
raise AgentInfraError(str(e)) from e
use_api = _use_text_api(provider, model, capabilities, on_line)
if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
raise AgentInfraError(f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider {provider})")
queued = time.monotonic()
async with _batch_gate(scope, _agent_priority(agent_key), api=use_api):
if _scope_cancelled(agent_key):
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 (Rolle %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, model, capabilities,
on_line=on_line, label=label)
res = await _leere_ausgabe_triage(agent_key, res, on_line)
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:]
return res
except asyncio.TimeoutError:
status = "timeout"
raise
except asyncio.CancelledError:
status = "cancelled"
raise
except AgentInfraError:
status = "infra"
raise
finally:
if on_event is not None and scope is not None: # nie fatal
try:
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
if err_tail:
meta["stderr"] = err_tail
if api_tokens:
meta["tokens"] = api_tokens
elif PROVIDERS[provider]["cli"] == "opencode":
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 _leere_ausgabe_triage(agent_key: str, res: tuple[int, str, str], on_line) -> tuple[int, str, str]:
"""Erkenntnis 13: leerer Text bei rc=0 ist nie ok. Keine OpenCode-Session zum Key →
der Call kam nie beim Provider an (Netz) → AgentInfraError (Lauf pausiert).
Session vorhanden → Thinking-Overrun/Stall → normaler Fehler (Engine-Retry)."""
rc, out, err = res
if rc != 0 or out.strip() or on_line is not None:
return res
tok = await asyncio.to_thread(_session_tokens, agent_key)
if tok is None:
raise AgentInfraError(f"agent {agent_key}: leere Ausgabe ohne OpenCode-Session — Provider/Netz nicht erreichbar")
return 1, "", f"leere Ausgabe (Session vorhanden, output-tokens={tok.get('output')}) — Overrun/Stall"
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, # eigene Prozessgruppe → killpg trifft auch Kinder
env=env,
)
global _cli_running
if stagger:
if not await _ram_gate(agent_key, est_kb):
return 1, "", "cancelled"
await _opencode_slot() # Gate VOR dem Start-Slot: Zulassungswelle wird trotzdem gespaced
process = await spawn()
if stagger:
_cli_running += 1
# Kollisionssicheres Tracking: identische Keys bekommen ~n-Suffix (Prefix-Kill matcht weiter)
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:
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
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]:
cmd = ["claude", "-p", "--model", model]
tools = _CLAUDE_TOOLS.get(capabilities)
if tools:
cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"]
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-Zähler der neuesten OpenCode-Session mit title=agent_key (via run --title).
Best-effort, read-only, nie fatal — None wenn DB/Zeile fehlt. Auch Timeouts haben
Tokens verbraucht; die Verschwendung soll sichtbar bleiben."""
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, model: str, capabilities: str,
on_line=None, label: str = "") -> tuple[int, str, str]:
# Prompt per Temp-Datei statt argv (ARG_MAX-Schutz bei großen Chunks)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
f.write(prompt)
prompt_path = Path(f.name)
# Die positionale Message MUSS vor -f stehen (-f ist ein Array-Flag).
cmd = [
"opencode", "run",
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
"-m", model,
"--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
"--title", agent_key, # Token-Accounting: verbindet die OpenCode-Session mit unserem Event
"-f", str(prompt_path),
]
if on_line is not None:
cmd += ["--format", "json"]
env = {**os.environ, "OPENCODE_CONFIG": str(OPENCODE_CONFIG)}
try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True,
on_line=on_line, label=label, env=env)
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally:
prompt_path.unlink(missing_ok=True)
# --- Direkter API-Pfad (MiniMax, Anthropic-Messages-Format) ---------------------------
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
_API_VERSION = "2023-06-01"
_API_MAX_TOKENS = 32_000
def _api_model_opts(model: str) -> dict:
"""kalt-Route = niedrige Temperatur ohne Thinking; nativ = Endpoint-Defaults.
Bewusst prefix-basiert statt Modell-Tabelle — Modell-IDs gehören nicht in den Code."""
if model.startswith("minimax-kalt/"):
return {"temperature": 0.3, "thinking": {"type": "disabled"}}
return {}
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 direkt aus der Response-usage, auch bei rc!=0 —
Verschwendung bleibt sichtbar. Leere Antwort und max_tokens-Abbruch sind Fehler."""
body = {
"model": model.split("/", 1)[1], # ohne OpenCode-Provider-Prefix
"max_tokens": _API_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}],
**_api_model_opts(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(
client.post(_API_URL, json=body, headers=headers), timeout=timeout)
except httpx.TimeoutException:
raise asyncio.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()
# Nur Text-Blöcke zählen — Thinking-Blöcke (native Route) werden übersprungen.
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"leere Antwort (stop_reason={data.get('stop_reason')})", tokens
err = "stop_reason=max_tokens (abgeschnitten)" 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:
"""ANSI-Codes und den führenden Banner ("> agent · model") entfernen."""
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()