Files
a0_software_orchestrator/helpers/compact_protocol.py
T

313 lines
11 KiB
Python
Raw Normal View History

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