From c13cfd2bd0406179cc199f3a030954c9786f3db7 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 24 Jul 2026 19:17:36 +0200 Subject: [PATCH] Governor Phase 0: Token-Waechter-Proxy + Aider Session-Hygiene (bewiesen) Duenner, zustandsloser Proxy (nur stdlib) zwischen Aider und llama-swap :8080. Schiebt an einer Token-Schwelle "SAVEPOINT.md finalisieren + stoppen" ein (weich) bzw. antwortet oberhalb eines optionalen Hart-Deckels selbst. Beweist Session- Hygiene per hartem Schnitt statt Auto-Compaction -- ohne eine Zeile Lucy-Code. Alle 4 Akzeptanzkriterien gruen: feuert im Log; SAVEPOINT.md gepflegt; ehrlicher Grenz-Savepoint am ersten Feuern; frische Sitzung liest Savepoint -> baut Tests, 9 unittest gruen, keine Fassade. CPT 3.5 kalibriert (est ~= echte prompt_tokens). Hart-Deckel nachgeruestet (weicher Schnitt allein erzeugt Fassade bei Weiterarbeit ueber die Grenze). Streaming-read1-Fix + 4 kleinere aus adversarialer Review. Laeuft deployt auf der Box unter ~/governor-p0/ (gov-ctl.sh start ). Co-Authored-By: Claude Opus 4.8 --- deploy/governor/CONVENTIONS.md | 33 +++ deploy/governor/README.md | 124 +++++++++ deploy/governor/SAVEPOINT.template.md | 23 ++ deploy/governor/driver.py | 67 +++++ deploy/governor/gov-ctl.sh | 52 ++++ deploy/governor/governor.py | 362 ++++++++++++++++++++++++++ deploy/governor/hardstop-test.sh | 29 +++ deploy/governor/msgs-todo.txt | 13 + deploy/governor/run-aider-msg.sh | 19 ++ deploy/governor/run-aider.sh | 20 ++ deploy/governor/run-driver.sh | 8 + 11 files changed, 750 insertions(+) create mode 100644 deploy/governor/CONVENTIONS.md create mode 100644 deploy/governor/README.md create mode 100644 deploy/governor/SAVEPOINT.template.md create mode 100644 deploy/governor/driver.py create mode 100644 deploy/governor/gov-ctl.sh create mode 100644 deploy/governor/governor.py create mode 100644 deploy/governor/hardstop-test.sh create mode 100644 deploy/governor/msgs-todo.txt create mode 100644 deploy/governor/run-aider-msg.sh create mode 100644 deploy/governor/run-aider.sh create mode 100644 deploy/governor/run-driver.sh diff --git a/deploy/governor/CONVENTIONS.md b/deploy/governor/CONVENTIONS.md new file mode 100644 index 0000000..e16a0bf --- /dev/null +++ b/deploy/governor/CONVENTIONS.md @@ -0,0 +1,33 @@ +# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext) + +Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene: +Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf. + +## 1. Zu Beginn: erst SAVEPOINT.md lesen +Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über +den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext, +der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar +ist, sag es, statt es zu erfinden. + +## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren +Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein +Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur: + +- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze). +- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade). +- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist. +- **Offene Fragen** — Entscheidungen, die noch anstehen. +- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde. +- **Dateien** — die wichtigsten Dateien und was sie enthalten. + +Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md` +plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat. + +## 3. Kleine, überprüfbare Schritte +Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein +Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten. + +## 4. Wenn der Governor das Sitzungs-Limit meldet +Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE +neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster +Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts. diff --git a/deploy/governor/README.md b/deploy/governor/README.md new file mode 100644 index 0000000..2880925 --- /dev/null +++ b/deploy/governor/README.md @@ -0,0 +1,124 @@ +# Governor — Phase 0 + +Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten +(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt +**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze +Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine +Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in +`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext. + +Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen, +**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch. + +## Bausteine (dieses Verzeichnis) +| Datei | Zweck | +|---|---| +| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. | +| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. | +| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. | +| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). | +| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). | +| `run-driver.sh` | `run-driver.sh [repo]` — Aider-Sitzung durch den Governor. | +| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. | +| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). | +| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. | + +## Verhalten des Governors +Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`, +kalibriert auf CPT **3.5** → `est ≈ echte prompt_tokens` auf ~1–3 % bei echten Sessions): + +- **est < Soft** → unverändert durchreichen. +- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als + letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN + ehrlichen Abschluss-Savepoint. +- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit + einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert, + dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten). + +Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird +byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung +mitgeloggt. + +### Umgebungsvariablen +`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) · +`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) · +`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG`. + +## Auf der Box laufen lassen (wie in P0 aufgesetzt) +```bash +# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins): +pipx install uv && uv tool install --python 3.12 aider-chat + +# Dateien liegen in ~/governor-p0/. Governor starten (Soft 25k, Hart aus): +~/governor-p0/gov-ctl.sh start 25000 0 # oder: start 25000 30000 (Hart an) + +# Aider-Sitzung durch den Governor: +~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt +``` +Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell +`openai/Qwen3-Coder-Next` auf den Governor. + +## Wichtig: Aiders eigene Zusammenfassung MUSS aus +Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie +selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der +Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die +der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein. + +## Ergebnisse & Befunde (24.07.2026) + +### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next) +1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6 + `ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000 + → `HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log. +2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten + (Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`. +3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das + Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster + Schritt) und verweigerte neuen Code. +4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den + Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest- + Tests laufen grün** gegen die echte API. Kein Erfinden. + +### Kalibrierung +`CHARS_PER_TOKEN = 3.5` → `est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %. +(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.) +Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k. + +### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet +Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze +respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest), +verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt" +in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT- +Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es +nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet +der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Empfehlung: +Hart-Deckel aktiv** (z. B. `Soft 25000, Hart 30000` — ein Finalisier-Zug Luft, dann harter Riegel). + +### Weitere Befunde / Fallen +- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet + Aider selbst und der Governor greift nie. Siehe oben. +- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code + ist die Wahrheit; git-Nachrichten sind es hier nicht. → Für Phase 1: Auto-Commit nahe/nach dem + Feuern zügeln oder Savepoint mit ehrlicher fixer Nachricht committen. +- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem + Python 3.12 installiert. +- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten + nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py` + taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber. + +### Bekannte Grenzen des Governors (aus adversarialer Review, für später) +- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer + `Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche). +- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten- + reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner + Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit. +- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den + lokalen `:8080`-Endpoint irrelevant. +Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher +puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String** +umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header. + +## Nächste Schritte (Phase 1+) +Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das +Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway. +Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default. diff --git a/deploy/governor/SAVEPOINT.template.md b/deploy/governor/SAVEPOINT.template.md new file mode 100644 index 0000000..686616e --- /dev/null +++ b/deploy/governor/SAVEPOINT.template.md @@ -0,0 +1,23 @@ +# SAVEPOINT + +> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine +> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter. +> (Anfangszustand — von der ersten Sitzung zu ersetzen.) + +## Ziel +_(noch nichts — von der ersten Sitzung zu füllen)_ + +## Erledigt +- _(noch nichts)_ + +## Nächster Schritt +- Auftrag des Nutzers entgegennehmen und beginnen. + +## Offene Fragen +- _(keine)_ + +## Stolpersteine +- _(keine bekannt)_ + +## Dateien +- `SAVEPOINT.md` — diese Übergabe-Datei. diff --git a/deploy/governor/driver.py b/deploy/governor/driver.py new file mode 100644 index 0000000..7330cb6 --- /dev/null +++ b/deploy/governor/driver.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor. + +Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden +bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was +der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit +der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst). + +Aufruf (cwd muss das Test-Repo sein, mit venv-python): + .../aider-chat/bin/python driver.py +""" +import os +import sys + +os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1") +os.environ.setdefault("OPENAI_API_KEY", "dummy") + +from aider.coders import Coder # noqa: E402 +from aider.models import Model # noqa: E402 +from aider.io import InputOutput # noqa: E402 + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: driver.py ", file=sys.stderr) + return 2 + with open(sys.argv[1], encoding="utf-8") as fh: + messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")] + + model = Model("openai/Qwen3-Coder-Next") + io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen + coder = Coder.create( + main_model=model, + io=io, + fnames=["SAVEPOINT.md"], # editierbar + read_only_fnames=["CONVENTIONS.md"], # nur-lesbar + auto_commits=True, # jede Etappe -> git-Commit + stream=False, # deterministische Logs fuer P0 + map_tokens=512, + use_git=True, + ) + + # Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr. + try: + coder.summarizer.max_tokens = 10 ** 9 + print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True) + except Exception as exc: # noqa: BLE001 + print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True) + + for i, msg in enumerate(messages, 1): + print(f"\n===== TURN {i}/{len(messages)} =====", flush=True) + print(f">> {msg[:140]}", flush=True) + try: + coder.run(with_message=msg) + except Exception as exc: # noqa: BLE001 + print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True) + break + sent = getattr(coder, "total_tokens_sent", "?") + recv = getattr(coder, "total_tokens_received", "?") + print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True) + + print("\n===== SESSION ENDE =====", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/governor/gov-ctl.sh b/deploy/governor/gov-ctl.sh new file mode 100644 index 0000000..4c9c819 --- /dev/null +++ b/deploy/governor/gov-ctl.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer). +# Nutzung: +# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000 +# gov-ctl.sh stop +# gov-ctl.sh status +set -u +DIR="$HOME/governor-p0" +PIDF="$DIR/governor.pid" +LOG="$DIR/governor.log" +OUT="$DIR/governor.stdout" + +start() { + stop + local thr="${1:-25000}" + local hard="${2:-${GOV_HARD_CEILING:-0}}" + cd "$DIR" + : > "$LOG" + # Voll detachen: eigene Session, alle FDs weg vom Aufrufer. + # CPT (Zeichen/Token) aus der Umgebung durchreichen, Default 3.5 (kalibriert 24.07.). + setsid env GOV_THRESHOLD="$thr" GOV_HARD_CEILING="$hard" \ + GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \ + GOV_LOG="$LOG" GOV_PORT=8100 \ + python3 "$DIR/governor.py" >"$OUT" 2>&1 & + echo $! > "$PIDF" + sleep 1.2 + echo "started pid=$(cat "$PIDF") threshold=$thr hard=$hard" + ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening" +} + +stop() { + pkill -f "$DIR/governor.py" 2>/dev/null || true + [ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true + sleep 0.6 + rm -f "$PIDF" +} + +status() { + if pgrep -f "$DIR/governor.py" >/dev/null; then + echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')" + ss -tlnp 2>/dev/null | grep ":8100" || true + else + echo "not running" + fi +} + +case "${1:-status}" in + start) shift; start "${1:-25000}" "${2:-}" ;; + stop) stop; echo stopped ;; + status) status ;; + *) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;; +esac diff --git a/deploy/governor/governor.py b/deploy/governor/governor.py new file mode 100644 index 0000000..49890e8 --- /dev/null +++ b/deploy/governor/governor.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Governor — duenner, zustandsloser Token-Waechter-Proxy (Phase 0). + +Sitzt zwischen einem Coding-Agenten (Aider) und dem Modell-Endpoint (llama-swap +:8080). Reicht ALLES unveraendert durch — mit einer Ausnahme bei +/v1/chat/completions: er schaetzt die Token-Groesse der Anfrage (= Sessiongroesse, +weil die ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen: + + est >= SCHWELLE (soft): haengt eine Stopp-Anweisung als letzte User-Nachricht an + ("SAVEPOINT.md finalisieren + stoppen") und leitet weiter. Das Modell schreibt + EINEN ehrlichen Abschluss-Savepoint. Loggt FIRED. + est >= HART-DECKEL (optional): antwortet SELBST mit einer kurzen Stopp-Nachricht, + OHNE das Modell zu fragen. Verhindert, dass ueber die Grenze hinaus + weitergearbeitet wird — genau das erzeugte in Tests eine Fassade. Loggt HARDSTOP. + +Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3. +Bewusst zustandslos: jede Anfrage wird fuer sich bewertet; keine Sitzungs-DB. + +Konfiguration per Umgebungsvariablen (alle optional): + GOV_PORT Listen-Port (Default 8100) + GOV_HOST Listen-Adresse (Default 0.0.0.0) + GOV_UPSTREAM Modell-Endpoint (Default http://127.0.0.1:8080) + GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 25000) + GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default 0) + GOV_CHARS_PER_TOKEN Heuristik Zeichen->Token (Default 3.5, kalibriert) + GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log) + GOV_DIRECTIVE Text des Soft-Einschubs (sonst Default unten) + GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort (sonst Default unten) +""" + +import http.client +import json +import os +import re +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +# ---- Konfiguration --------------------------------------------------------- + +PORT = int(os.environ.get("GOV_PORT", "8100")) +HOST = os.environ.get("GOV_HOST", "0.0.0.0") +UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:8080") +THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "25000")) +HARD_CEILING = int(os.environ.get("GOV_HARD_CEILING", "0")) # 0 = deaktiviert +CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.5")) +LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log")) + +DEFAULT_DIRECTIVE = ( + "[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} " + "Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren " + "Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n" + "1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: " + "was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-" + "Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle " + "Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus " + "SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n" + "2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung " + "starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus." +) +DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE) + +DEFAULT_HARDSTOP = ( + "[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint " + "sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. " + "Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und " + "macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)" +) +HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP) + +up = urlparse(UPSTREAM) +UP_HOST = up.hostname or "127.0.0.1" +UP_PORT = up.port or 80 + +HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", +} + +_log_lock = threading.Lock() +_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)') + + +def log(line: str) -> None: + """Eine Zeile nach stdout UND in die Logdatei (thread-sicher).""" + stamp = time.strftime("%Y-%m-%dT%H:%M:%S") + msg = f"{stamp} {line}" + with _log_lock: + print(msg, flush=True) + try: + with open(LOG_PATH, "a", encoding="utf-8") as fh: + fh.write(msg + "\n") + except OSError: + pass + + +def estimate_tokens(messages) -> int: + """Grobe, aber stabile Heuristik: Zeichen aller Nachrichteninhalte / CPT. + + Gegen die echten prompt_tokens aus der Antwort kalibriert (CPT=3.5 traf am + 24.07. auf ~1-3 % genau). Zaehlt Text in String- und Multimodal-Listen-Inhalten; + kleiner Aufschlag je Nachricht fuer Rollen-/Template-Overhead. + """ + chars = 0 + for m in messages or []: + chars += 4 + content = m.get("content") if isinstance(m, dict) else None + if isinstance(content, str): + chars += len(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and isinstance(part.get("text"), str): + chars += len(part["text"]) + return int(chars / CHARS_PER_TOKEN) + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "Governor/0.2" + + def log_message(self, *args): + pass + + def do_GET(self): + self._proxy() + + def do_POST(self): + self._proxy() + + def do_PUT(self): + self._proxy() + + def do_DELETE(self): + self._proxy() + + def do_OPTIONS(self): + self._proxy() + + # -- Kern --------------------------------------------------------------- + def _read_body(self) -> bytes: + length = self.headers.get("Content-Length") + if length is None: + return b"" + try: + return self.rfile.read(int(length)) + except (ValueError, OSError): + return b"" + + def _proxy(self) -> None: + body = self._read_body() + path = self.path + # Query-String vor der Endpunkt-Erkennung abschneiden (sonst umgeht + # z. B. ?api-version=... den Governor). + clean_path = path.split("?", 1)[0] + is_chat = clean_path.rstrip("/").endswith("/chat/completions") + + action = "passthrough" + est = None + streaming = False + model = "" + if is_chat and body: + action, body, est, streaming, model = self._decide(body) + + # HARTER STOPP: selbst antworten, Upstream nie fragen. + if action == "hard": + self._send_canned_stop(model, streaming, est) + log(f"chat est={est} thr={THRESHOLD} hard={HARD_CEILING} HARDSTOP " + f"stream={streaming} status=200") + return + + # Header fuer Upstream aufbereiten. + out_headers = {} + for k, v in self.headers.items(): + kl = k.lower() + if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"): + continue + out_headers[k] = v + out_headers["Host"] = f"{UP_HOST}:{UP_PORT}" + out_headers["Accept-Encoding"] = "identity" + if body: + out_headers["Content-Length"] = str(len(body)) + out_headers["Connection"] = "close" + + conn = None + try: + conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=600) + conn.request(self.command, path, body=body or None, headers=out_headers) + resp = conn.getresponse() + except (OSError, http.client.HTTPException) as exc: + log(f"ERROR upstream {self.command} {path}: {exc!r}") + if conn is not None: + conn.close() + self._safe_error(502, f"governor upstream: {exc}") + return + + self.send_response(resp.status) + for k, v in resp.getheaders(): + kl = k.lower() + # hop-by-hop + Laenge raus; Date/Server setzt send_response schon selbst. + if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"): + continue + self.send_header(k, v) + self.send_header("Connection", "close") + self.end_headers() + + tail = bytearray() + try: + while True: + # read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE- + # Durchreichen). read() wuerde bis 64 KB oder Stream-Ende puffern + # und streamendes Aider die ganze Generierung haengen lassen. + chunk = resp.read1(65536) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + tail.extend(chunk) + if len(tail) > 16384: + del tail[:-16384] + except OSError: + pass + finally: + conn.close() + + exact = self._scan_prompt_tokens(tail) + if is_chat: + exact_s = str(exact) if exact is not None else "-" + flag = "FIRED" if action == "soft" else "ok" + log(f"chat est={est} exact={exact_s} thr={THRESHOLD} {flag} " + f"stream={streaming} status={resp.status}") + + def _decide(self, body: bytes): + """Aktion bestimmen: passthrough | soft (Einschub) | hard (Selbstantwort). + + Rueckgabe: (action, body, est, streaming, model). + """ + try: + data = json.loads(body) + except (ValueError, UnicodeDecodeError): + return "passthrough", body, None, False, "" + if not isinstance(data, dict): + return "passthrough", body, None, False, "" + + messages = data.get("messages") + streaming = bool(data.get("stream")) + model = data.get("model", "") or "" + est = estimate_tokens(messages if isinstance(messages, list) else []) + + if HARD_CEILING > 0 and est >= HARD_CEILING: + return "hard", body, est, streaming, model + + if est >= THRESHOLD and isinstance(messages, list): + # Sichere Substitution statt str.format: ein operator-gesetzter + # GOV_DIRECTIVE mit { } (JSON/Code-Beispiel) darf nicht crashen. + directive = (DIRECTIVE.replace("{est}", str(est)) + .replace("{threshold}", str(THRESHOLD))) + messages.append({"role": "user", "content": directive}) + data["messages"] = messages + return "soft", json.dumps(data).encode("utf-8"), est, streaming, model + + return "passthrough", body, est, streaming, model + + def _send_canned_stop(self, model: str, streaming: bool, est) -> None: + """OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call).""" + created = int(time.time()) + usage = {"prompt_tokens": est or 0, "completion_tokens": 0, + "total_tokens": est or 0} + try: + if streaming: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + + def sse(obj): + self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n") + self.wfile.flush() + + base = {"id": "governor-hardstop", "object": "chat.completion.chunk", + "created": created, "model": model} + sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"}, + "finish_reason": None}]}) + sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG}, + "finish_reason": None}]}) + sse({**base, "choices": [{"index": 0, "delta": {}, + "finish_reason": "stop"}], "usage": usage}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + else: + payload = { + "id": "governor-hardstop", "object": "chat.completion", + "created": created, "model": model, + "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", "content": HARDSTOP_MSG}}], + "usage": usage, + } + data = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) + self.wfile.flush() + except OSError: + pass + + def _safe_error(self, status: int, msg: str) -> None: + try: + data = json.dumps({"error": msg}).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) + except OSError: + pass + + @staticmethod + def _scan_prompt_tokens(tail: bytearray): + if not tail: + return None + try: + text = tail.decode("utf-8", errors="ignore") + except Exception: # noqa: BLE001 + return None + matches = _PROMPT_TOKENS_RE.findall(text) + if not matches: + return None + try: + return int(matches[-1]) + except ValueError: + return None + + +def main() -> int: + log_dir = os.path.dirname(LOG_PATH) + if log_dir and not os.path.isdir(log_dir): + try: + os.makedirs(log_dir, exist_ok=True) + except OSError: + pass + + server = ThreadingHTTPServer((HOST, PORT), Handler) + server.daemon_threads = True + hard = HARD_CEILING if HARD_CEILING > 0 else "aus" + log(f"Governor startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} " + f"Hart={hard} | CPT={CHARS_PER_TOKEN} | Log={LOG_PATH}") + try: + server.serve_forever() + except KeyboardInterrupt: + log("Governor beendet (SIGINT).") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/governor/hardstop-test.sh b/deploy/governor/hardstop-test.sh new file mode 100644 index 0000000..d75d7a2 --- /dev/null +++ b/deploy/governor/hardstop-test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls: +# klein -> passthrough (Modell antwortet normal) +# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet) +# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call) +set -u +GOV="http://127.0.0.1:8100/v1/chat/completions" + +python3 - <<'PY' > /tmp/gov_small.json +import json +print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False})) +PY +python3 - <<'PY' > /tmp/gov_mid.json +import json +print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False})) +PY +python3 - <<'PY' > /tmp/gov_big.json +import json +print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False})) +PY + +echo "=== TEST A: klein (passthrough) ===" +curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content' + +echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ===" +curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}' + +echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ===" +curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}' diff --git a/deploy/governor/msgs-todo.txt b/deploy/governor/msgs-todo.txt new file mode 100644 index 0000000..c09e00f --- /dev/null +++ b/deploy/governor/msgs-todo.txt @@ -0,0 +1,13 @@ +# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde. +Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions. +Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md. +Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md. +Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md. +Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md. +Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md. +Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md. +Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md. +Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md. +Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md. +Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md. +Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md. diff --git a/deploy/governor/run-aider-msg.sh b/deploy/governor/run-aider-msg.sh new file mode 100644 index 0000000..66051a0 --- /dev/null +++ b/deploy/governor/run-aider-msg.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# run-aider-msg.sh — EINE Aider-Runde via --message (sauberer, +# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor. +# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden) +# und keine "Datei hinzufuegen?"-Reflexion ausloest. +set -u +REPO="${1:?repo dir}" +MSG="${2:?message}" +export PATH="$HOME/.local/bin:$PATH" +export OPENAI_API_BASE="http://127.0.0.1:8100/v1" +export OPENAI_API_KEY="dummy" +cd "$REPO" +aider \ + --model openai/Qwen3-Coder-Next \ + --no-check-update --no-show-model-warnings --no-analytics --yes-always \ + --map-tokens 512 \ + --read CONVENTIONS.md --read todo.py --read cli.py \ + SAVEPOINT.md \ + --message "$MSG" diff --git a/deploy/governor/run-aider.sh b/deploy/governor/run-aider.sh new file mode 100644 index 0000000..2121019 --- /dev/null +++ b/deploy/governor/run-aider.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# run-aider.sh — treibt EINE Aider-Sitzung durch den Governor. +# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert, +# sodass die Anfrage jede Runde wächst (genau das prüft der Governor). +set -u +MSGS="${1:?Nachrichtendatei angeben}" +export PATH="$HOME/.local/bin:$PATH" +export OPENAI_API_BASE="http://127.0.0.1:8100/v1" +export OPENAI_API_KEY="dummy" +cd "$HOME/governor-p0/testrepo" +aider \ + --model openai/Qwen3-Coder-Next \ + --no-check-update \ + --no-show-model-warnings \ + --no-analytics \ + --yes-always \ + --map-tokens 512 \ + --read CONVENTIONS.md \ + SAVEPOINT.md \ + < "$MSGS" diff --git a/deploy/governor/run-driver.sh b/deploy/governor/run-driver.sh new file mode 100644 index 0000000..fcf2c58 --- /dev/null +++ b/deploy/governor/run-driver.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# run-driver.sh — Aider-Scripting-Sitzung durch den Governor. +set -u +MSGS="${1:?Nachrichtendatei angeben}" +REPO="${2:-$HOME/governor-p0/testrepo}" +VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python" +cd "$REPO" +exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"