"""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 /.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