Compare commits
2 Commits
8e735df32d
...
798de0be8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 798de0be8f | |||
| c13cfd2bd0 |
@@ -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.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 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 <msgs> [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.
|
||||||
|
|
||||||
|
### Sprach-Signal an Lucy (Phase 2)
|
||||||
|
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
|
||||||
|
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
|
||||||
|
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
|
||||||
|
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
|
||||||
|
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
|
||||||
|
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
|
||||||
|
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
|
||||||
|
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
|
||||||
|
|
||||||
|
## 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 (Hart-Deckel default AN = Soft+5000):
|
||||||
|
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
|
||||||
|
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
|
||||||
|
|
||||||
|
# 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. **Der Hart-
|
||||||
|
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
|
||||||
|
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
|
||||||
|
|
||||||
|
### 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. Der Hart-Deckel (jetzt Default) begrenzt
|
||||||
|
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
|
||||||
|
- **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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
|
||||||
|
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
|
||||||
|
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
|
||||||
|
set -u
|
||||||
|
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||||
|
|
||||||
|
python3 - <<'PY' > /tmp/gov_fire.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
|
||||||
|
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
|
||||||
|
|
||||||
|
sleep 1.5 # Announce-Thread durchlassen
|
||||||
|
echo "=== Governor-Log (letzte 4 Zeilen) ==="
|
||||||
|
tail -4 ~/governor-p0/governor.log
|
||||||
|
|
||||||
|
echo "=== Announce-Queue: Governor-Eintraege ==="
|
||||||
|
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
|
||||||
|
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
|
||||||
@@ -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 <messages-file>
|
||||||
|
"""
|
||||||
|
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 <messages-file>", 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())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/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}"
|
||||||
|
cd "$DIR"
|
||||||
|
: > "$LOG"
|
||||||
|
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
|
||||||
|
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
|
||||||
|
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
|
||||||
|
local hardenv=()
|
||||||
|
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
|
||||||
|
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
|
||||||
|
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
|
||||||
|
GOV_LOG="$LOG" GOV_PORT=8100 \
|
||||||
|
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
|
||||||
|
echo $! > "$PIDF"
|
||||||
|
sleep 1.2
|
||||||
|
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
|
||||||
|
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
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
#!/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: Soft+5000, AN)
|
||||||
|
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)
|
||||||
|
GOV_ANNOUNCE_URL Lucy-Sprach-Signal-Endpunkt; "" = aus (Default :9001/api/voice/announce)
|
||||||
|
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
|
||||||
|
GOV_ANNOUNCE_TEXT Text des Sprach-Signals (sonst Default)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"))
|
||||||
|
# Hart-Deckel: Default AN (Soft + 5000 = ein Finalisier-Zug Luft), weil der weiche
|
||||||
|
# Schnitt allein bei Weiterarbeit ueber die Grenze eine Fassade erzeugt (Befund P0).
|
||||||
|
# Explizit setzbar; GOV_HARD_CEILING=0 schaltet ihn aus.
|
||||||
|
_hard_env = os.environ.get("GOV_HARD_CEILING")
|
||||||
|
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Sprach-Signal an Lucy (Phase 2): beim Feuern POSTet der Governor eine Meldung an die
|
||||||
|
# vorhandene MC2-Announce-Pipeline (:9001). Lucy pollt sie ohnehin, dedupliziert und
|
||||||
|
# spricht sie (gated durch ihren "Box-Meldungen laut"-Schalter). Best-effort, gedrosselt
|
||||||
|
# gegen die Pro-Runde-Feuerung. GOV_ANNOUNCE_URL="" schaltet das Signal ab.
|
||||||
|
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
|
||||||
|
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300")) # Sekunden
|
||||||
|
DEFAULT_ANNOUNCE = (
|
||||||
|
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
|
||||||
|
"Stand im Savepoint; am besten fangen wir gleich frisch an."
|
||||||
|
)
|
||||||
|
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
|
||||||
|
|
||||||
|
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+)')
|
||||||
|
_announce_lock = threading.Lock()
|
||||||
|
_last_announce = 0.0 # Zeitstempel der letzten Meldung (Drossel)
|
||||||
|
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_announce(est) -> None:
|
||||||
|
"""POSTet die Meldung an die MC2-Announce-Pipeline. Laeuft im Hintergrund-Thread."""
|
||||||
|
try:
|
||||||
|
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
||||||
|
.replace("{threshold}", str(THRESHOLD)))
|
||||||
|
body = json.dumps({"text": text, "subject": "[Governor]",
|
||||||
|
"source": "governor", "priority": "normal"}).encode("utf-8")
|
||||||
|
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
|
||||||
|
_an.port or 80, timeout=4)
|
||||||
|
conn.request("POST", _an.path or "/api/voice/announce", body=body,
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Content-Length": str(len(body))})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
resp.read()
|
||||||
|
conn.close()
|
||||||
|
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_announce(est) -> None:
|
||||||
|
"""Sprach-Signal an Lucy ausloesen — gedrosselt, damit die Pro-Runde-Feuerung
|
||||||
|
nicht spammt (eine Aeusserung je Ueberschreitungs-Episode). Best-effort."""
|
||||||
|
if not _an:
|
||||||
|
return
|
||||||
|
global _last_announce
|
||||||
|
now = time.time()
|
||||||
|
with _announce_lock:
|
||||||
|
if now - _last_announce < ANNOUNCE_THROTTLE:
|
||||||
|
return
|
||||||
|
_last_announce = now
|
||||||
|
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if action in ("soft", "hard"):
|
||||||
|
maybe_announce(est) # Sprach-Signal an Lucy (gedrosselt)
|
||||||
|
|
||||||
|
# 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())
|
||||||
@@ -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}'
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-aider-msg.sh <repo> <message> — 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"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-aider.sh <messages-file> — 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"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-driver.sh <messages-file> — 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"
|
||||||
Reference in New Issue
Block a user