313 lines
11 KiB
Python
313 lines
11 KiB
Python
|
|
"""Block Compact Protocol – Schwellen, Pflicht-Artefakte und sichere Trigger.
|
|||
|
|
|
|||
|
|
NEU (additiv, DB-backed): Definiert das Block-Compact-Protokoll für den
|
|||
|
|
A0 Software Orchestrator. Verwendet die DB-Tabellen orch_* (Migration v3).
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import yaml
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Any, Dict, List, Optional
|
|||
|
|
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
|||
|
|
get_project_id,
|
|||
|
|
list_next_steps,
|
|||
|
|
list_worklog,
|
|||
|
|
list_todos,
|
|||
|
|
list_kv_keys,
|
|||
|
|
get_kv,
|
|||
|
|
get_status,
|
|||
|
|
_db,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Plugin-Root für Config-Lookup (default_config.yaml)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Schwellen (werden zur Laufzeit aus config.json block_compact überschrieben)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
DEFAULT_THRESHOLDS: Dict[str, Any] = {
|
|||
|
|
"enabled": True,
|
|||
|
|
"threshold_warn": 0.8,
|
|||
|
|
"threshold_hard": 0.9,
|
|||
|
|
"min_lines_user_status": 30,
|
|||
|
|
"max_conversation_summary_lines": 200,
|
|||
|
|
"require_user_approval": False,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Pflicht-Artefakte – werden in der DB geprüft (nicht mehr als Files!)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
MANDATORY_ARTIFACTS: List[str] = [
|
|||
|
|
"worklog", # orch_worklog: mind. 1 Eintrag
|
|||
|
|
"todo", # orch_todos: mind. 1 open todo
|
|||
|
|
"project_state", # orch_kv key='project_state' oder 'phase'
|
|||
|
|
"current_status", # orch_status: gesetzt
|
|||
|
|
"next_steps", # orch_next_steps: mind. 1 pending
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Zusätzliche Artefakte, die das Block-Compact-Protokoll schreibt
|
|||
|
|
BLOCK_COMPACT_EXTRA_ARTIFACTS: List[str] = [
|
|||
|
|
"block_compact_marker", # orch_block_compact (DB)
|
|||
|
|
"snapshot", # orch_snapshots (DB)
|
|||
|
|
"worklog_entry", # orch_worklog Eintrag (DB)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Trigger-Bedingungen – wann ein Compact SICHER ist
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def get_block_compact_config(project_root: str = "") -> Dict[str, Any]:
|
|||
|
|
"""Liest die block_compact-Konfiguration aus Plugin- und Projekt-Config.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
project_root: Optional. Wird hier nicht mehr benötigt (DB-basiert),
|
|||
|
|
aber als Parameter behalten für Rückwärtskompatibilität.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Konfig-Dict mit aktivierten Schwellen etc.
|
|||
|
|
"""
|
|||
|
|
config = dict(DEFAULT_THRESHOLDS)
|
|||
|
|
|
|||
|
|
# Suche zuerst nach <project_root>/.a0/config.json
|
|||
|
|
candidate_paths = []
|
|||
|
|
if project_root:
|
|||
|
|
candidate_paths.append(
|
|||
|
|
(os.path.join(project_root, ".a0", "config.json"), json.load)
|
|||
|
|
)
|
|||
|
|
# Fallback: Plugin-Default
|
|||
|
|
candidate_paths.append(
|
|||
|
|
(os.path.join(_PLUGIN_ROOT, "default_config.yaml"), yaml.safe_load)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for path, loader in candidate_paths:
|
|||
|
|
if not os.path.exists(path):
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
with open(path, "r", encoding="utf-8") as f:
|
|||
|
|
data = loader(f)
|
|||
|
|
except (json.JSONDecodeError, yaml.YAMLError, OSError, UnicodeDecodeError):
|
|||
|
|
continue
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
continue
|
|||
|
|
block_cfg = data.get("block_compact", {})
|
|||
|
|
if isinstance(block_cfg, dict) and block_cfg:
|
|||
|
|
config.update(block_cfg)
|
|||
|
|
break
|
|||
|
|
return config
|
|||
|
|
|
|||
|
|
|
|||
|
|
def should_block_compact(
|
|||
|
|
project_name: str = "",
|
|||
|
|
project_root: str = "",
|
|||
|
|
estimated_ratio: float = 0.0,
|
|||
|
|
open_subagent_calls: int = 0,
|
|||
|
|
quality_gate_passed: bool = True,
|
|||
|
|
user_gave_new_instruction: bool = False,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""Entscheidet, ob ein Block-Compact jetzt SICHER ausgelöst werden darf.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
project_name: Projektname (DB-Lookup, bevorzugt).
|
|||
|
|
project_root: Legacy – wird zu basename() aufgelöst.
|
|||
|
|
estimated_ratio: Token-Verbrauch (0.0–1.0).
|
|||
|
|
open_subagent_calls: Anzahl offener Subagent-Calls.
|
|||
|
|
quality_gate_passed: True, wenn Quality-Gate passed.
|
|||
|
|
user_gave_new_instruction: True, wenn User in dieser Runde neue Anweisung gab.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
dict mit allowed, forced, reason, config.
|
|||
|
|
"""
|
|||
|
|
# Bug-Fix: basename-Fallback entfernt – würde ungültige/Phantom-Namen
|
|||
|
|
# erzeugen. project_name ist Pflicht.
|
|||
|
|
if not project_name or not project_name.strip():
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": "project_name is required (basename-fallback removed)",
|
|||
|
|
"config": get_block_compact_config(project_root=project_root),
|
|||
|
|
}
|
|||
|
|
name = project_name.strip()
|
|||
|
|
config = get_block_compact_config(project_root=project_root)
|
|||
|
|
|
|||
|
|
# 1. Disabled?
|
|||
|
|
if not config.get("enabled", True):
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": "block_compact disabled in config", "config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 2. Quality-Gate failed?
|
|||
|
|
if not quality_gate_passed:
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": "quality_gate failed – fix first, compact after",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 3. Offene Subagent-Calls?
|
|||
|
|
if open_subagent_calls > 0:
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": f"{open_subagent_calls} subagent call(s) still open",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 4. User hat neue Anweisung in dieser Runde gegeben?
|
|||
|
|
if user_gave_new_instruction:
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": "user gave new instruction this turn – process first",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 5. Pflicht-Artefakte vorhanden?
|
|||
|
|
# (Refactor Option B / Soft-Check 2026-06-16: HARD-Block entfernt.
|
|||
|
|
# Stattdessen prüft block_compactor.execute die Artefakte als
|
|||
|
|
# WARN und schreibt trotzdem. Diese Funktion behält Token-Ratio,
|
|||
|
|
# Subagent, Quality-Gate, User-Instruction, next_steps als BLOCK.)
|
|||
|
|
# artifacts = check_mandatory_artifacts(name) # Soft-Check-Pfad: block_compactor
|
|||
|
|
# for artifact_key, ok in artifacts.items():
|
|||
|
|
# if not ok:
|
|||
|
|
# return {
|
|||
|
|
# "allowed": False, "forced": False,
|
|||
|
|
# "reason": f"mandatory artifact missing: {artifact_key}",
|
|||
|
|
# "config": config,
|
|||
|
|
# }
|
|||
|
|
|
|||
|
|
# 6. next_steps nicht leer
|
|||
|
|
try:
|
|||
|
|
pid = get_project_id(name)
|
|||
|
|
if pid is None:
|
|||
|
|
raise LookupError(f"project '{name}' not found in DB")
|
|||
|
|
pending_steps = list_next_steps(pid, status="pending")
|
|||
|
|
if not pending_steps:
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": "orch_next_steps has no pending entries – next block not defined",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
except (ValueError, LookupError):
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": f"project {name!r} not found in DB",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 7. Token-Schwelle
|
|||
|
|
threshold_warn = float(config.get("threshold_warn", 0.8))
|
|||
|
|
threshold_hard = float(config.get("threshold_hard", 0.9))
|
|||
|
|
forced = estimated_ratio >= threshold_hard
|
|||
|
|
if estimated_ratio < threshold_warn and not forced:
|
|||
|
|
return {
|
|||
|
|
"allowed": False, "forced": False,
|
|||
|
|
"reason": f"context ratio {estimated_ratio:.2f} below warn-threshold {threshold_warn:.2f}",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"allowed": True,
|
|||
|
|
"forced": forced,
|
|||
|
|
"reason": "all preconditions met",
|
|||
|
|
"config": config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _has_working_todo_table(pid: int) -> bool:
|
|||
|
|
"""Prüft, ob die Todo-Tabelle vorhanden und das Schema nutzbar ist.
|
|||
|
|
|
|||
|
|
Hintergrund (Bugfix): Vorher wurde 'mind. 1 open todo' als Pflicht
|
|||
|
|
verlangt. Das blockierte das Block-Compact-Protokoll fälschlich,
|
|||
|
|
wenn die Session beendet ist und keine offenen Todos anstehen
|
|||
|
|
(gültiger Zustand). Korrekt ist: Todo-Mechanismus ist verfügbar =
|
|||
|
|
Tabelle existiert + die für die Listen-Abfrage nötigen Spalten sind da.
|
|||
|
|
"""
|
|||
|
|
db = _db()
|
|||
|
|
# 1. Tabelle existiert?
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='orch_todos'"
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return False
|
|||
|
|
# 2. Erforderliche Spalten vorhanden?
|
|||
|
|
required = {"id", "project_id", "status"}
|
|||
|
|
cols = {r[1] for r in db.conn.execute("PRAGMA table_info(orch_todos)").fetchall()}
|
|||
|
|
return required.issubset(cols)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_mandatory_artifacts(project_name: str) -> Dict[str, bool]:
|
|||
|
|
"""Prüft, ob alle 5 Pflicht-Artefakte (DB) vorhanden sind.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
dict artifact_key -> exists (bool)
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
pid = get_project_id(project_name)
|
|||
|
|
if pid is None:
|
|||
|
|
raise LookupError(f"project {project_name!r} not in DB")
|
|||
|
|
except (ValueError, LookupError):
|
|||
|
|
# Projekt nicht in DB → alle Artefakte fehlen
|
|||
|
|
return {k: False for k in MANDATORY_ARTIFACTS}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"worklog": len(list_worklog(pid, limit=1)) > 0,
|
|||
|
|
"todo": _has_working_todo_table(pid),
|
|||
|
|
"project_state": (
|
|||
|
|
get_kv(pid, "project_state") is not None
|
|||
|
|
or get_kv(pid, "phase") is not None
|
|||
|
|
),
|
|||
|
|
"current_status": get_status(pid) is not None,
|
|||
|
|
"next_steps": len(list_next_steps(pid, status="pending", limit=1)) > 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def now_iso() -> str:
|
|||
|
|
"""ISO-8601 UTC timestamp."""
|
|||
|
|
return datetime.utcnow().isoformat() + "Z"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_resume_payload(project_name: str) -> Dict[str, Any]:
|
|||
|
|
"""Liest alle Resume-relevanten Daten aus der DB für `block_resume.py`."""
|
|||
|
|
try:
|
|||
|
|
pid = get_project_id(project_name)
|
|||
|
|
except (ValueError, LookupError):
|
|||
|
|
pid = None
|
|||
|
|
|
|||
|
|
if pid is None:
|
|||
|
|
return {
|
|||
|
|
"project_name": project_name,
|
|||
|
|
"resume_exists": False,
|
|||
|
|
"conversation_summary_exists": False,
|
|||
|
|
"project_state": {},
|
|||
|
|
"next_steps": "",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
from .db_state_store import get_block_compact
|
|||
|
|
bc = get_block_compact(pid) or {}
|
|||
|
|
|
|||
|
|
payload: Dict[str, Any] = {
|
|||
|
|
"project_name": project_name,
|
|||
|
|
"resume_exists": bool(bc.get("resume_md")),
|
|||
|
|
"conversation_summary_exists": bool(bc.get("conversation_summary_md")),
|
|||
|
|
"project_state": {
|
|||
|
|
"project_id": pid,
|
|||
|
|
"block_compact": bc,
|
|||
|
|
},
|
|||
|
|
"next_steps": list_next_steps(pid, status="pending"),
|
|||
|
|
}
|
|||
|
|
if bc.get("resume_md"):
|
|||
|
|
payload["resume"] = bc["resume_md"]
|
|||
|
|
if bc.get("conversation_summary_md"):
|
|||
|
|
payload["conversation_summary"] = bc["conversation_summary_md"]
|
|||
|
|
return payload
|