pi-system/extensions/rule-enforcer.ts

312 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Rule Enforcer Extension
*
* Mechanismen:
*
* 1. before_agent_start injiziert Kern-Regeln in jeden System-Prompt-Turn
* + Selbstkorrektur wenn letzter Turn W06 verletzt hat
* + Subagenten-Lage aus Alert-File (passiver Pfad)
*
* 2. message_end scannt Pi's Ausgabe auf W06-Deliberations-Muster,
* setzt Flag für nächsten before_agent_start
*
* 3. setInterval (30s) erzwingt die Invariante:
* "Solange ein Subagent läuft, darf der Orchestrator nicht idle sein."
* Erkennt laufende Subagenten per PROZESSBAUM (lebt ein `pi`-Kind unter dem
* tmux-Pane?), nicht per Keyword. Ist der Orchestrator idle, obwohl ein
* Subagent läuft, wird er AKTIV via pi.sendUserMessage() geweckt das löst
* garantiert einen Turn aus. Der passive Alert-File-Pfad allein reicht nicht,
* weil before_agent_start bei einem idle Orchestrator nie feuert.
*
* Nur aktiv wenn PI_ORCHESTRATOR=1.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const ORCHESTRATOR_AKTIV = !!process.env.PI_ORCHESTRATOR;
const ALERT_FILE = "/tmp/.pi-subagent-alert";
const CHECK_INTERVAL_MS = 30_000;
// Idle = seit so langer Zeit kein Pi-Event. 45s > 30s watch_subagents-Pollintervall,
// damit aktive Überwachung nicht fälschlich als idle gilt.
const IDLE_SCHWELLE_MS = 45_000;
// Höchstens eine aktive Weckung in diesem Abstand.
const WECK_MIN_ABSTAND_MS = 60_000;
const SUBAGENT_PFAD_MARKER = "/.pi/subagents/";
// ---------------------------------------------------------------------------
// Kern-Regeln — werden in jeden System-Prompt-Turn injiziert
// ---------------------------------------------------------------------------
const KERN_REGELN = `
## PFLICHT-REGELN gelten für JEDE Antwort ohne Ausnahme
**W06** Kein Deliberieren: Niemals "Let me", "Actually", "I should", "Ich sollte", "Das liegt daran", "Weil ich" in Antworten ausgeben. Nur Ergebnis oder Entscheidung.
**W07** Subagenten nicht aufgeben: Bei Problemen Benutzer kurz informieren, auf Anweisung warten. Nie eigenständig auf direkte Ausführung umschwenken.
**W09** sleep verboten: Stattdessen watch_subagents nutzen oder Benutzer informieren.
**W10** Subagent nie doppelt starten: Einmal starten, dann watch_subagents abwarten.
**WATCH** watch_subagents SOFORT nach jeder Antwort erneut aufrufen permanent, ohne Ausnahme. Solange ein Subagent läuft, darfst du NIE idle werden.
`.trim();
// ---------------------------------------------------------------------------
// W06-Deliberations-Muster
// ---------------------------------------------------------------------------
const DELIBERATIONS_MUSTER: RegExp[] = [
/\bLet me\b/,
/\bActually,?\s/,
/\bI should\b/,
/\bI need to\b/,
/\bI'll check\b/,
/\bI'll look\b/,
/\bLet me check\b/,
/\bLet me look\b/,
/\bLet me verify\b/,
/\bLet me first\b/,
/\bHmm,?\s/i,
/\bWait,?\s/,
/\bIch sollte\b/,
/\bLass mich\b/,
/\bDas liegt daran\b/,
/\bWeil ich\b/,
/\bIch muss\b/,
/\bIch werde\b.*\bprüfen\b/,
];
function hatDeliberation(text: string): boolean {
return DELIBERATIONS_MUSTER.some((p) => p.test(text));
}
function textAusNachricht(message: any): string {
const content = message?.content;
if (!Array.isArray(content)) return "";
return content
.filter((c: any) => c.type === "text")
.map((c: any) => String(c.text ?? ""))
.join(" ");
}
// ---------------------------------------------------------------------------
// Alert-File (passiver Pfad)
// ---------------------------------------------------------------------------
function leseAlerts(): string | null {
try {
if (!fs.existsSync(ALERT_FILE)) return null;
const content = fs.readFileSync(ALERT_FILE, "utf-8").trim();
if (!content) return null;
fs.writeFileSync(ALERT_FILE, "");
return content;
} catch {
return null;
}
}
function schreibeAlert(text: string): void {
try {
fs.writeFileSync(ALERT_FILE, text);
} catch {}
}
// ---------------------------------------------------------------------------
// Laufende Subagenten per PROZESSBAUM erkennen
//
// pane_current_command ist unbrauchbar (meldet "bash", obwohl pi als Kind läuft).
// Stattdessen: für jede tmux-Session unter ~/.pi/subagents/ prüfen, ob unter dem
// Pane-Shell-PID ein lebender `pi`-Prozess hängt.
// ---------------------------------------------------------------------------
interface LaufenderSubagent {
name: string;
dialog: string | null; // Pane-Auszug, falls ein Bestätigungs-Dialog erkennbar ist
}
function laeuftPiUnter(panePid: number): boolean {
try {
const kids = execSync(`pgrep -P ${panePid} 2>/dev/null || true`, {
encoding: "utf-8",
timeout: 1500,
}).trim();
const pids = kids.split(/\s+/).filter(Boolean);
for (const k of pids) {
const n = Number(k);
if (!Number.isInteger(n)) continue;
const comm = execSync(`ps -o comm= -p ${n} 2>/dev/null || true`, {
encoding: "utf-8",
timeout: 1000,
}).trim();
if (comm === "pi") return true;
// pi könnte eine Ebene tiefer laufen (z.B. via node-Wrapper)
if (laeuftPiUnter(n)) return true;
}
return false;
} catch {
return false;
}
}
function laufendeSubagenten(): LaufenderSubagent[] {
try {
const raw = execSync(
"tmux list-panes -a -F '#{session_name}::#{pane_pid}::#{pane_current_path}' 2>/dev/null || true",
{ encoding: "utf-8", timeout: 2500 },
).trim();
if (!raw) return [];
const ergebnis: LaufenderSubagent[] = [];
const gesehen = new Set<string>();
for (const line of raw.split("\n")) {
const teile = line.split("::");
if (teile.length < 3) continue;
const name = teile[0].trim();
const panePid = Number(teile[1].trim());
const path = teile[2].trim();
if (!name || gesehen.has(name)) continue;
if (!path.includes(SUBAGENT_PFAD_MARKER)) continue;
if (!Number.isInteger(panePid)) continue;
if (!laeuftPiUnter(panePid)) continue; // Pi nicht (mehr) aktiv ⇒ kein laufender Subagent
gesehen.add(name);
// Optional: Dialog-Inhalt zur Anreicherung der Weckmeldung
let dialog: string | null = null;
try {
const pane = execSync(
`tmux capture-pane -t '${name}' -p 2>/dev/null | tail -8`,
{ encoding: "utf-8", timeout: 1000 },
).trim();
if (
/Erlauben\?|→ Yes|Überschreiben\?|ACHTUNG:|Bestätigung/.test(pane)
) {
dialog = pane;
}
} catch {}
ergebnis.push({ name, dialog });
}
return ergebnis;
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
if (!ORCHESTRATOR_AKTIV) return;
let letzterVerstoss: string | null = null;
let letzterCtx: any = null;
let letzteAktivitaet = Date.now();
let letzteWeckung = 0;
function markAktiv(ctx?: any): void {
letzteAktivitaet = Date.now();
if (ctx) letzterCtx = ctx;
}
// ── 30s Background-Check: Invariante erzwingen ───────────────────────────
const interval = setInterval(() => {
const subagenten = laufendeSubagenten();
if (subagenten.length === 0) return; // kein laufender Subagent ⇒ idle ist erlaubt
const dialoge = subagenten.filter((s) => s.dialog);
const beschreibung = subagenten
.map(
(s) =>
`${s.name}${s.dialog ? " — WARTET AUF BESTÄTIGUNG" : " — läuft"}`,
)
.join("\n");
// Passiver Pfad: greift, falls der Orchestrator gerade in einem Turn ist
schreibeAlert(
`[30s-Check] Laufende Subagenten — Orchestrator muss überwachen:\n${beschreibung}`,
);
// Idle-Erkennung: primär zeitbasiert (ctx-unabhängig)
const idleMs = Date.now() - letzteAktivitaet;
if (idleMs < IDLE_SCHWELLE_MS) return; // Orchestrator aktiv ⇒ nichts tun
// Zusätzliches Veto, falls ctx zuverlässig "beschäftigt" meldet
try {
if (letzterCtx && typeof letzterCtx.isIdle === "function") {
if (letzterCtx.isIdle() === false) return;
}
} catch {}
const jetzt = Date.now();
if (jetzt - letzteWeckung < WECK_MIN_ABSTAND_MS) return;
letzteWeckung = jetzt;
const dringlich = dialoge.length > 0;
const dialogText = dialoge
.map((d) => `--- ${d.name} ---\n${d.dialog}`)
.join("\n");
try {
letzterCtx?.ui?.notify?.(
dringlich
? "🚨 Subagent wartet auf Bestätigung — Orchestrator wird geweckt."
: "⏱️ Subagent läuft, Orchestrator war idle — wird geweckt.",
"warning",
);
} catch {}
try {
pi.sendUserMessage(
`🚨 [Auto-Weckung] Es läuft mindestens ein Subagent, aber du warst idle. ` +
`Das ist nicht erlaubt, solange Subagenten laufen.\n${beschreibung}\n` +
(dringlich
? `\nEin Subagent wartet auf eine Bestätigung:\n${dialogText}\n→ Sofort watch_subagents aufrufen, den Dialog prüfen und nach Regel bestätigen ODER den Benutzer informieren.\n`
: ``) +
`→ Nimm die watch_subagents-Überwachung sofort wieder auf und halte sie, bis ALLE Subagenten fertig sind.`,
);
} catch {}
}, CHECK_INTERVAL_MS);
pi.on("session_shutdown", async () => {
clearInterval(interval);
});
// ── Aktivitäts-Tracking für die zeitbasierte Idle-Erkennung ──────────────
pi.on("turn_start", async (_e, ctx) => markAktiv(ctx));
pi.on("turn_end", async (_e, ctx) => markAktiv(ctx));
pi.on("message_start", async (_e, ctx) => markAktiv(ctx));
pi.on("message_update", async (_e, ctx) => markAktiv(ctx));
pi.on("tool_execution_start", async (_e, ctx) => markAktiv(ctx));
pi.on("tool_execution_end", async (_e, ctx) => markAktiv(ctx));
// ── message_end: W06-Scan + Aktivität ────────────────────────────────────
pi.on("message_end", async (event, ctx) => {
markAktiv(ctx);
const text = textAusNachricht(event.message);
if (hatDeliberation(text)) {
letzterVerstoss = "W06 (Internes Deliberieren)";
}
});
// ── before_agent_start: System-Prompt-Injection ──────────────────────────
pi.on("before_agent_start", async (event, ctx) => {
markAktiv(ctx);
let zusatz = `\n\n---\n${KERN_REGELN}`;
if (letzterVerstoss) {
zusatz += `\n\n⚠ REGELVERSTOSS erkannt (${letzterVerstoss}): Diese Antwort OHNE Gedankenkommentar. Nur Ergebnis oder Entscheidung.`;
letzterVerstoss = null;
}
const alerts = leseAlerts();
if (alerts) {
zusatz += `\n\n🚨 SUBAGENT-LAGE:\n${alerts}\n→ watch_subagents aufrufen und die Überwachung halten, bis alle Subagenten fertig sind.`;
}
return { systemPrompt: event.systemPrompt + zusatz };
});
}