"""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, fake_agents_aktiv, 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 fake_agents_aktiv(): # 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), } api_key = os.environ.get("MINIMAX_API_KEY") if not api_key: raise AgentInfraError("MINIMAX_API_KEY fehlt — in .env setzen") headers = {"x-api-key": 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()