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
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""Handover protocol helpers for specialist subagents (Phase 1 - read-only spike).
|
||||
|
||||
Implements the 3-stage context transfer pattern documented in
|
||||
/usr/workdir/handover-spike.md (sections 4 and 5).
|
||||
|
||||
This module is a HELPER (not a Tool). It exposes pure functions that the
|
||||
HandoverToSpecialist tool and future code can call. It is intentionally
|
||||
LLM-free in Phase 1: the orchestrator is expected to pre-fill the LLM
|
||||
summary before invoking the tool. The actual LLM summarisation belongs in
|
||||
a later phase.
|
||||
|
||||
Protection rules (enforced here):
|
||||
- never compact mid-subagent
|
||||
- never skip snapshot (callers MUST call snapshot_state first)
|
||||
- never overwrite resume/conversation_summary with empty content
|
||||
- never use chat history as project memory (briefing files are
|
||||
curated LLM summaries, not raw chat dumps)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.context_budget import (
|
||||
estimate_tokens,
|
||||
should_warn,
|
||||
)
|
||||
|
||||
|
||||
# Known specialist subagent profile directories. The orchestrator will only
|
||||
# TODO: Centralize this allow-list so call_subordinate enforces it too.
|
||||
# Currently call_subordinate can invoke ANY profile, bypassing this check.
|
||||
# accept handovers to profiles in this allow-list. Update this list when new
|
||||
# profiles are added to /a0/usr/plugins/a0_software_orchestrator/agents/.
|
||||
KNOWN_SPECIALISTS = {
|
||||
"a0_software_orchestrator",
|
||||
"codebase_explorer",
|
||||
"deploy_agent",
|
||||
"implementation_engineer",
|
||||
"quality_reviewer",
|
||||
"release_auditor",
|
||||
"requirements_analyst",
|
||||
"runtime_devops_engineer",
|
||||
"security_data_engineer",
|
||||
"solution_architect",
|
||||
"test_debug_engineer",
|
||||
"hacker",
|
||||
}
|
||||
|
||||
# Hard caps (token budget). All numbers are conservative defaults that match
|
||||
# section 4 of /usr/workdir/handover-spike.md.
|
||||
DEFAULT_MAX_CONTEXT_TOKENS = 3000
|
||||
DEFAULT_RECENT_TURNS = 5
|
||||
HARD_CAP_HISTORY_TOKENS = 50_000
|
||||
MAX_HANDOVER_DEPTH = 3
|
||||
|
||||
|
||||
# Filename safety: keep this strict so we never escape the project root via a
|
||||
# malicious topic name. We allow letters, digits, dashes, underscores and dots.
|
||||
_SAFE_SLUG = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||
|
||||
|
||||
def _safe_slug(value: str) -> str:
|
||||
"""Sanitise a free-form string into a filename-safe slug."""
|
||||
cleaned = _SAFE_SLUG.sub("-", value.strip())
|
||||
cleaned = cleaned.strip("-")
|
||||
return cleaned or "handover"
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _timestamp_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def list_known_specialists() -> list[str]:
|
||||
"""Return the sorted list of accepted specialist profile names."""
|
||||
return sorted(KNOWN_SPECIALISTS)
|
||||
|
||||
|
||||
def is_known_specialist(profile: str) -> bool:
|
||||
"""True if `profile` is in the handover allow-list."""
|
||||
return profile in KNOWN_SPECIALISTS
|
||||
|
||||
|
||||
def build_briefing_path(project_root: str, specialist: str, topic: str) -> str:
|
||||
"""Return the absolute path where a briefing file should be written.
|
||||
|
||||
Layout: <project_root>/.a0proj/handover/<specialist>-<topic>-<date>.md
|
||||
The directory is created on demand by append_briefing().
|
||||
"""
|
||||
if not is_known_specialist(specialist):
|
||||
raise ValueError(f"Unknown specialist profile: {specialist!r}")
|
||||
slug = _safe_slug(topic)
|
||||
filename = f"{_safe_slug(specialist)}-{slug}-{_today_iso()}.md"
|
||||
return os.path.join(project_root, ".a0proj", "handover", filename)
|
||||
|
||||
|
||||
def render_briefing_section(
|
||||
*,
|
||||
specialist: str,
|
||||
topic: str,
|
||||
summary: str,
|
||||
decisions: list[str],
|
||||
artifacts: list[str],
|
||||
recent_turns: list[str],
|
||||
return_condition: str,
|
||||
handover_reason: str,
|
||||
) -> str:
|
||||
"""Render the markdown content for a new briefing-file section.
|
||||
|
||||
This is the canonical layout. The orchestrator (or the tool) appends the
|
||||
result to a per-specialist briefing file. Sections are append-only and
|
||||
timestamped, in line with the 'never overwrite' protection rule.
|
||||
"""
|
||||
lines = [
|
||||
f"## {specialist} – {topic} ({_timestamp_iso()})",
|
||||
"",
|
||||
f"**Handover reason:** {handover_reason.strip() or 'n/a'}",
|
||||
"",
|
||||
"**Summary (LLM-curated, max 300 tokens):**",
|
||||
"",
|
||||
summary.strip() or "_(no summary provided)_",
|
||||
"",
|
||||
"**Recent turns (verbatim, last N):**",
|
||||
"",
|
||||
]
|
||||
if recent_turns:
|
||||
for turn in recent_turns:
|
||||
lines.append(f"> {turn.strip()}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("_(no recent turns provided)_")
|
||||
lines.append("")
|
||||
lines.append("**Decisions so far:**")
|
||||
lines.append("")
|
||||
if decisions:
|
||||
for d in decisions:
|
||||
lines.append(f"- {d}")
|
||||
else:
|
||||
lines.append("- _(none yet)_")
|
||||
lines.append("")
|
||||
lines.append("**Artifacts (paths/references):**")
|
||||
lines.append("")
|
||||
if artifacts:
|
||||
for a in artifacts:
|
||||
lines.append(f"- `{a}`")
|
||||
else:
|
||||
lines.append("- _(none yet)_")
|
||||
lines.append("")
|
||||
lines.append(f"**Return condition:** {return_condition.strip() or 'user_request'}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def append_briefing(path: str, section_markdown: str) -> int:
|
||||
"""Append `section_markdown` to the briefing file at `path`.
|
||||
|
||||
Creates the parent directory if it does not exist. Returns the new file
|
||||
size in bytes. Raises OSError on filesystem errors.
|
||||
"""
|
||||
if not section_markdown or not section_markdown.strip():
|
||||
# Protection rule: never overwrite/append with empty content.
|
||||
raise ValueError("refusing to append empty briefing content")
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
# Append-only: open in append mode, never in write/truncate mode.
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(section_markdown)
|
||||
if not section_markdown.endswith("\n"):
|
||||
fh.write("\n")
|
||||
return os.path.getsize(path)
|
||||
|
||||
|
||||
def estimate_handover_tokens(
|
||||
*,
|
||||
summary: str,
|
||||
recent_turns: list[str],
|
||||
decisions: list[str],
|
||||
artifacts: list[str],
|
||||
) -> int:
|
||||
"""Rough token estimate of the stage-1 handover package (no system prompt)."""
|
||||
text_parts = [summary or ""]
|
||||
text_parts.extend(recent_turns or [])
|
||||
text_parts.extend(decisions or [])
|
||||
text_parts.extend(artifacts or [])
|
||||
blob = "\n".join(text_parts)
|
||||
return estimate_tokens(blob)
|
||||
|
||||
|
||||
def validate_history_request(max_tokens: int) -> int:
|
||||
"""Validate a stage-2 history request against the hard cap.
|
||||
|
||||
Returns the effective token budget (clamped to HARD_CAP_HISTORY_TOKENS).
|
||||
Raises ValueError if the caller asks for zero or negative tokens.
|
||||
"""
|
||||
if max_tokens <= 0:
|
||||
raise ValueError("max_tokens must be positive")
|
||||
return min(max_tokens, HARD_CAP_HISTORY_TOKENS)
|
||||
|
||||
|
||||
def build_handover_package(
|
||||
*,
|
||||
specialist: str,
|
||||
topic: str,
|
||||
summary: str,
|
||||
recent_turns: list[str],
|
||||
decisions: list[str],
|
||||
artifacts: list[str],
|
||||
return_condition: str,
|
||||
handover_reason: str,
|
||||
project_root: str,
|
||||
max_context_tokens: int = DEFAULT_MAX_CONTEXT_TOKENS,
|
||||
) -> dict:
|
||||
"""Build a stage-1 handover package as a plain dict.
|
||||
|
||||
The dict contains both the metadata the subagent will receive AND the
|
||||
resolved briefing-file path. The caller (tool) is responsible for writing
|
||||
the briefing file via append_briefing() before invoking the subagent.
|
||||
|
||||
Raises ValueError for unknown specialists, oversized payloads, or
|
||||
empty summary / topic (the latter is a soft warning, not a hard error,
|
||||
so the caller can decide).
|
||||
"""
|
||||
if not is_known_specialist(specialist):
|
||||
raise ValueError(
|
||||
f"Unknown specialist profile: {specialist!r}. "
|
||||
f"Known specialists: {', '.join(list_known_specialists())}"
|
||||
)
|
||||
if not topic or not topic.strip():
|
||||
raise ValueError("topic must be a non-empty string")
|
||||
|
||||
tokens = estimate_handover_tokens(
|
||||
summary=summary,
|
||||
recent_turns=recent_turns,
|
||||
decisions=decisions,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
warn_oversize = False
|
||||
if tokens > max_context_tokens:
|
||||
# We do NOT raise here: the orchestrator may decide to truncate or
|
||||
# accept the cost. We do flag it.
|
||||
warn_oversize = True
|
||||
|
||||
briefing_path = build_briefing_path(project_root, specialist, topic)
|
||||
package = {
|
||||
"specialist": specialist,
|
||||
"topic": topic,
|
||||
"summary": summary.strip(),
|
||||
"recent_turns": list(recent_turns or []),
|
||||
"decisions": list(decisions or []),
|
||||
"artifacts": list(artifacts or []),
|
||||
"return_condition": (return_condition or "user_request").strip(),
|
||||
"handover_reason": (handover_reason or "").strip(),
|
||||
"briefing_file": briefing_path,
|
||||
"estimated_tokens": tokens,
|
||||
"max_context_tokens": max_context_tokens,
|
||||
"oversize_warning": warn_oversize,
|
||||
"_meta": {
|
||||
"created_at": _timestamp_iso(),
|
||||
"phase": "phase-1-readonly",
|
||||
},
|
||||
}
|
||||
return package
|
||||
|
||||
|
||||
def persona_marker(specialist: str) -> str:
|
||||
"""Return a short, UI-friendly persona marker for `specialist`.
|
||||
|
||||
The marker is intentionally compact so it can be prefixed to chat
|
||||
messages without inflating the visible output. Phase 3 may add a richer
|
||||
UI representation (icon, color, etc.).
|
||||
"""
|
||||
safe = specialist.replace("_", " ").strip()
|
||||
return f"[{safe}]"
|
||||
Reference in New Issue
Block a user