Files
planer2/backend/agents.py
2026-09-07 07:44:00 +02:00

230 lines
9.5 KiB
Python

"""Zwei Wege zum Modell, je nach Aufgabe.
`frage` geht über MiniMax im Anthropic-Messages-Format: die Prüfungen sind hundert
Anfragen je Lauf und leben von der Temperaturstreuung — das muss billig und
parallelisierbar sein.
`frage_lokal` geht über die angemeldete Claude-CLI, dasselbe Abo wie die
Agenten-Übergabe. Die Gespräche sind einzelne Aufrufe und leben von Urteilskraft;
dort zählt Qualität, nicht Durchsatz.
Welche Werkzeuge dabei offenstehen, entscheidet die Betriebsart, nicht das Modell:
Planung liest den Projektcode, Recherche sucht im Web, und keine der beiden kann,
was die andere darf. Die Prüfungen bekommen gar nichts — dort ist der Katalog die
einzige zulässige Quelle.
Kein Fallback zwischen beiden: fehlt ein Weg, bricht der Aufruf mit sichtbarer
Meldung ab, statt still auf das schwächere Modell auszuweichen."""
import asyncio
import json
import httpx
import config
ZEITLIMIT = 180.0
MAX_TOKENS = 8000
API_VERSION = "2023-06-01"
GRUNDLAGE = (
"claude", "-p", "--model", "opus", "--output-format", "json",
# Spart ein Fünftel der Grundlast; was hier gilt, steht ohnehin im eigenen Prompt.
"--exclude-dynamic-system-prompt-sections",
# Ohne das erbt der Aufruf, was am Rechner sonst eingerichtet ist: MCP-Server,
# Workflows, Hooks, ein fremder Antwortstil. Ein Gespräch über Feature-Karten hat
# in keinem Postfach etwas zu suchen.
"--safe-mode",
)
# Was in keiner Betriebsart erlaubt ist. Beide Gespräche holen fremden Text in den
# Kontext — das eine Suchtreffer, das andere Quelltext — und fremder Text darf keinen
# Terminplan anlegen, keine Nachricht verschicken und keine Agenten starten. Was
# künftig an Werkzeugen dazukommt, steht hier nicht: beim Nachziehen der CLI gehört
# diese Liste geprüft.
GESPERRT = (
"Write", "Edit", "NotebookEdit", "Bash", "Task", "TodoWrite",
"Workflow", "Skill", "ReportFindings", "ScheduleWakeup", "DesignSync",
"CronCreate", "CronDelete", "CronList", "RemoteTrigger", "PushNotification",
"SendMessage", "Monitor", "EnterWorktree", "ExitWorktree",
"TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate",
)
def _kommando(erlaubt: tuple, gesperrt: tuple, zuege: int) -> tuple:
"""Ein Aufruf mit genau den Werkzeugen einer Betriebsart.
`--allowed-tools` ist nicht bloß die Umkehrung von `--disallowed-tools`: ohne die
Vorabfreigabe fragt die CLI je Suche und je Datei nach Erlaubnis und bekommt im
Stapelbetrieb keine Antwort. Der Versuch endet dann als Absage, nicht als Fehler.
`ToolSearch` steht nirgends: Suche, Abruf und Lesen sind nachladbar, ohne das
Werkzeug käme das Modell gar nicht an sie heran. Was es nachlädt, ist trotzdem
gesperrt — die Sperre wirkt beim Aufruf, nicht beim Suchen.
"""
return (*GRUNDLAGE,
"--allowed-tools", *erlaubt,
"--disallowed-tools", *GESPERRT, *gesperrt,
"--max-turns", str(zuege))
# Planen heißt: an den Karten arbeiten, mit dem Code als Beleg. Das Web bleibt zu —
# was das Projekt können soll, entscheidet die Person, nicht eine fremde Seite.
PLANUNG_KOMMANDO = _kommando(
("Read", "Glob", "Grep"), ("WebSearch", "WebFetch"), 20)
# Ohne eingestelltes Arbeitsverzeichnis gibt es nichts zu lesen. Lesen bliebe hier
# nicht folgenlos: ohne gesetzten Ordner liefe die Suche im Serververzeichnis, und
# das Modell beschriebe planer2 statt des Projekts.
PLANUNG_OHNE_CODE = _kommando(
(), ("Read", "Glob", "Grep", "WebSearch", "WebFetch"), 1)
# Recherche geht nach draußen und nirgendwo sonst: kein Blick in Dateien, damit
# nichts aus dem Projekt in einer Suchanfrage landet.
RECHERCHE_KOMMANDO = _kommando(
("WebSearch", "WebFetch"), ("Read", "Glob", "Grep"), 24)
# Opus denkt länger als MiniMax, der Prozessstart kostet extra, und Suchen wie
# Codelesen legen mehrere Abrufe obendrauf.
CHAT_ZEITLIMIT = 600.0
# Nur fürs Log: welcher Weg gerade lief. Ohne den Namen sagt eine Zeitangabe nichts,
# denn dieselbe Sekundenzahl ist bei einem Zug teuer und bei zwanzig billig.
WEGE = {
PLANUNG_KOMMANDO: "planung+code",
PLANUNG_OHNE_CODE: "planung",
RECHERCHE_KOMMANDO: "recherche",
}
# Der Provider drosselt bei vielen gleichzeitigen Anfragen
WIEDERHOLBAR = (429, 500, 502, 503, 504)
WIEDERHOLUNGEN = 6
PAUSE = 2.0
# Länger als das zu warten hilft nicht mehr; dann ist die Drossel zu weit offen
HOECHSTPAUSE = 60.0
class NichtKonfiguriert(ValueError):
"""Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit
„Prüfung läuft bereits" verwechselt — beides wäre sonst ein ValueError."""
def pruefe_konfiguration():
fehlend = [
name
for name, wert in (
("MINIMAX_API_KEY", config.MINIMAX_API_KEY),
("MINIMAX_MODELL", config.MINIMAX_MODELL),
("MINIMAX_URL", config.MINIMAX_URL),
)
if not wert
]
if fehlend:
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
async def frage_lokal(system: str, inhalt: str,
kommando: tuple = PLANUNG_OHNE_CODE,
ordner: str | None = None) -> str:
"""Eine Runde über die Claude-CLI — der Text der Antwort.
Der Prompt geht über die Standardeingabe: Katalog und Kartenstand sind zu lang,
um sie als Kommandozeilenargument zu übergeben. Keine Temperatur — die CLI kennt
keine; für einen einzelnen Zug braucht es auch keine Streuung.
`ordner` ist das Verzeichnis, in dem der Aufruf läuft. Es entscheidet, was ein
erlaubtes Lesen erreicht; ohne Angabe erbt der Prozess das Serververzeichnis, und
das ist nur zulässig, solange gar nicht gelesen werden darf.
"""
try:
prozess = await asyncio.create_subprocess_exec(
*kommando, "--system-prompt", system,
cwd=ordner,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
raise NichtKonfiguriert(
"Claude-CLI nicht gefunden — „claude“ muss im Pfad liegen und angemeldet sein"
)
try:
aus, err = await asyncio.wait_for(
prozess.communicate(inhalt.encode()), CHAT_ZEITLIMIT)
except asyncio.TimeoutError:
prozess.kill()
raise RuntimeError(f"Claude antwortet nicht innerhalb von {CHAT_ZEITLIMIT:.0f} s")
if prozess.returncode != 0:
raise RuntimeError(f"Claude bricht ab: {err.decode()[-300:].strip() or 'ohne Meldung'}")
try:
daten = json.loads(aus)
except ValueError:
raise RuntimeError("Antwort der Claude-CLI ist kein JSON")
if daten.get("is_error"):
raise RuntimeError(f"Claude meldet einen Fehler: {daten.get('result')}")
_notiere(kommando, daten)
text = str(daten.get("result") or "")
if not text.strip():
raise RuntimeError("Leere Antwort von Claude")
return text
def _notiere(kommando: tuple, daten: dict):
"""Wohin die Wartezeit ging — ins Serverlog, eine Zeile je Aufruf.
Die CLI legt es ohnehin bei; ungelesen bleibt nur die Frage „wieso dauert das so
lange" offen. Ein Zug ist ein Modellaufruf: jede Suche, jeder Abruf, jedes Lesen
zählt einzeln. Sind die Züge hoch, hat das Modell gearbeitet; ist einer teuer, war
der Prompt lang.
"""
zuege = daten.get("num_turns") or 0
api = (daten.get("duration_api_ms") or 0) / 1000
print(f"[claude] {WEGE.get(kommando, 'aufruf')}: {api:.0f}s, {zuege} Züge, "
f"{(api / zuege if zuege else api):.0f}s/Zug, "
f"${daten.get('total_cost_usd') or 0:.3f}", flush=True)
def _pause(res, versuch: int) -> float:
"""Wie lange bis zum nächsten Versuch — der Provider darf es selbst sagen."""
gesagt = res.headers.get("retry-after")
try:
return min(float(gesagt), HOECHSTPAUSE)
except (TypeError, ValueError):
return min(PAUSE * (2 ** versuch), HOECHSTPAUSE)
async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
"""Eine Anfrage, eine Antwort — der Text der Modellantwort.
Kalt geroutet: Temperatur 0, kein Thinking; die Prüfung soll nicht raten.
Für Mehrheitsentscheide wird sie bewusst angehoben, sonst wären die Stimmen gleich."""
pruefe_konfiguration()
daten = {
"model": config.MINIMAX_MODELL,
"max_tokens": MAX_TOKENS,
"system": system,
"messages": [{"role": "user", "content": inhalt}],
"temperature": temperatur,
"thinking": {"type": "disabled"},
}
async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client:
for versuch in range(WIEDERHOLUNGEN):
res = await client.post(
config.MINIMAX_URL,
headers={"x-api-key": config.MINIMAX_API_KEY,
"anthropic-version": API_VERSION},
json=daten,
)
if res.status_code not in WIEDERHOLBAR or versuch == WIEDERHOLUNGEN - 1:
break
# Zu viele Anfragen oder Serverfehler: warten und noch einmal.
# Ohne das fielen bei großen Läufen ganze Stimmen aus — und ein
# fehlendes Urteil sah aus wie „nichts gefunden".
await asyncio.sleep(_pause(res, versuch))
if res.status_code != 200:
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
antwort = res.json()
text = "".join(
b.get("text", "") for b in antwort.get("content", []) if b.get("type") == "text"
)
if not text.strip():
raise RuntimeError(f"Leere Antwort (stop_reason={antwort.get('stop_reason')})")
return text