Files
a0_software_orchestrator/helpers/compact_protocol.py
T
Software Orchestrator 5769c1cd22 Initial commit: a0_software_orchestrator v1.0
- Auto-Registration-Bug behoben (register_project/get_project_id/resolve_project Trennung)
- 25 Tests gruen (Pytest)
- block_compactor-Tool refactored (Option B: Soft-Check statt Hard-Block)
- 4 Restbaustellen gefixt
- DB-Schema: plugin_settings-Tabelle hinzugefuegt
- 3 Schattenprojekte aus DB geloescht
- Plan v3 + Refactor-Plan + Worklog dokumentiert
2026-06-16 22:13:06 +00:00

313 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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