"""Staged authoring backend for evidence-backed skill evolution.""" from __future__ import annotations import json import uuid from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any from openspace.prompts import SkillEnginePrompts from openspace.skill_engine.evidence import EvidenceEvent, EvidencePacket, ResourceRef from openspace.skill_engine.evolution.audit_tools import ( PacketAuditReadError, PacketAuditReader, build_packet_audit_tools, ) from openspace.skill_engine.evolver import EvolutionContext, EvolutionTrigger from openspace.skill_engine.patch import ( PatchType, SkillEditResult, SKILL_FILENAME, stage_create_skill, stage_derive_skill, stage_fix_skill, ) from openspace.skill_engine.skill_utils import ( get_frontmatter_field, set_frontmatter_field, truncate, validate_skill_dir, ) from openspace.skill_engine.types import ( EvolutionSuggestion, EvolutionType, SkillCategory, SkillLineage, SkillOrigin, SkillRecord, ) from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) _SKILL_CONTENT_MAX_CHARS = 12_000 @dataclass(frozen=True) class StagedSkillEdit: staging_id: str decision_id: str action_type: str staging_dir: str target_dir: str target_skill_ids: list[str] parent_skill_ids: list[str] proposed_skill_id: str | None proposed_name: str proposed_description: str changed_files: list[str] content_diff: str content_snapshot: dict[str, str] tool_dependencies: list[str] critical_tools: list[str] overlay_fields: dict[str, Any] overlay_metadata: dict[str, Any] evidence_refs: list[str] apply_metadata: dict[str, Any] created_at: str def to_dict(self) -> dict[str, Any]: return asdict(self) @dataclass(frozen=True) class AuthoringResult: authoring_id: str decision_id: str packet_id: str status: str staged_edit: StagedSkillEdit | None failure_reason: str | None model: str created_at: str def to_dict(self) -> dict[str, Any]: data = asdict(self) if self.staged_edit is not None: data["staged_edit"] = self.staged_edit.to_dict() return data @dataclass(frozen=True) class _SourceSkill: skill_id: str record: SkillRecord content: str skill_dir: Path file_ref: ResourceRef class SkillEvolverAuthoringBackend: """Adapt ``SkillEvolver`` into a staging-only authoring backend.""" def __init__( self, evolver: Any, staging_root: Path, evidence_store: Any, ) -> None: self.evolver = evolver self.staging_root = Path(staging_root).expanduser().resolve() self.evidence_store = evidence_store async def author_from_action_packet(self, packet: EvidencePacket) -> AuthoringResult: authoring_id = f"auth_{uuid.uuid4().hex}" staging_id = f"stage_{uuid.uuid4().hex}" staging_dir = self.staging_root / staging_id staging_dir.mkdir(parents=True, exist_ok=True) created_at = _utc_now() model = _model_name(self.evolver) def failed(reason: str, *, status: str = "failed") -> AuthoringResult: result = AuthoringResult( authoring_id=authoring_id, decision_id=_decision_id(packet), packet_id=packet.packet_id, status=status, staged_edit=None, failure_reason=reason, model=model, created_at=created_at, ) self._persist_result(result, staging_dir, packet) return result if getattr(packet, "packet_type", "") != "action": return failed("authoring requires an action EvidencePacket") decision_ref = _single_ref(packet, "decision_rationale_ref") if decision_ref is None: return failed("missing decision_rationale_ref") admission_ref = _single_ref(packet, "admission_result_ref") if admission_ref is None: return failed("missing admission_result_ref") admission_outcome = str( admission_ref.metadata.get("outcome") or "" ).strip().lower() if admission_outcome not in {"direct", "accepted"}: return failed( f"admission outcome is not accepted: {admission_outcome or '(missing)'}", status="declined", ) action_type = _action_type(decision_ref) if action_type not in {"FIX", "DERIVED", "CAPTURED"}: return failed(f"unsupported action type: {action_type or '(missing)'}") target_skill_ids = _target_skill_ids(decision_ref) try: sources = _source_skills(packet, target_skill_ids, self.evolver) except Exception as exc: return failed(str(exc)) if action_type in {"FIX", "DERIVED"} and not sources: return failed(f"{action_type} requires target skill_file refs") if action_type == "FIX" and len(sources) != 1: return failed("FIX requires exactly one target skill") direction = str( decision_ref.metadata.get("reason_summary") or decision_ref.preview or "Apply the admitted skill evolution." ) prompt = self._build_prompt(action_type, sources, packet, direction) category = _category(decision_ref) or ( sources[0].record.category if sources else SkillCategory.WORKFLOW ) ctx = EvolutionContext( trigger=EvolutionTrigger.ANALYSIS, suggestion=EvolutionSuggestion( evolution_type=EvolutionType(action_type.lower()), target_skill_ids=list(target_skill_ids), category=category, direction=direction, ), skill_records=[source.record for source in sources], skill_contents=[source.content for source in sources], skill_dirs=[source.skill_dir for source in sources], source_task_id=packet.scope.task_id, recent_analyses=[], available_tools=build_packet_audit_tools(packet), capture_dir=_capture_root(packet), ) evolution_output = await self.evolver._run_evolution_loop(prompt, ctx) if evolution_output is None: return failed("evolution authoring produced no usable finalization") edit_content = str(getattr(evolution_output, "edit_content", "") or "") if not edit_content.strip(): return failed("evolution authoring produced empty edit content") try: edit_result, proposed_name, target_dir = await self._stage_edit( action_type=action_type, sources=sources, packet=packet, edit_content=edit_content, staging_dir=staging_dir, prompt=prompt, ctx=ctx, ) except Exception as exc: return failed(str(exc)) if edit_result is None or not edit_result.ok: return failed( getattr(edit_result, "error", None) or "staging apply failed" ) skill_md = edit_result.content_snapshot.get(SKILL_FILENAME, "") proposed_name = ( get_frontmatter_field(skill_md, "name") or proposed_name or (sources[0].record.name if sources else "captured-skill") ) proposed_description = ( get_frontmatter_field(skill_md, "description") or (sources[0].record.description if sources else proposed_name) ) parent_skill_ids = [source.record.skill_id for source in sources] tool_dependencies = sorted( {tool for source in sources for tool in source.record.tool_dependencies} ) critical_tools = sorted( {tool for source in sources for tool in source.record.critical_tools} ) proposed_skill_id = _proposed_skill_id( action_type, proposed_name, sources[0].record if sources else None, ) staged_edit = StagedSkillEdit( staging_id=staging_id, decision_id=str(decision_ref.metadata.get("decision_id") or ""), action_type=action_type, staging_dir=str(staging_dir), target_dir=str(target_dir), target_skill_ids=list(target_skill_ids), parent_skill_ids=parent_skill_ids, proposed_skill_id=proposed_skill_id, proposed_name=proposed_name, proposed_description=proposed_description, changed_files=_changed_files(edit_result), content_diff=edit_result.content_diff, content_snapshot=dict(edit_result.content_snapshot), tool_dependencies=tool_dependencies, critical_tools=critical_tools, overlay_fields=dict(getattr(evolution_output, "overlay_fields", {}) or {}), overlay_metadata=dict(getattr(evolution_output, "overlay_metadata", {}) or {}), evidence_refs=_evidence_refs(packet, decision_ref, admission_ref), apply_metadata={ "change_summary": getattr(evolution_output, "change_summary", None), "source_packet_id": _source_packet_id(packet), "action_packet_id": packet.packet_id, "admission_id": admission_ref.metadata.get("admission_id"), "patch_type": PatchType.AUTO.value, }, created_at=created_at, ) result = AuthoringResult( authoring_id=authoring_id, decision_id=staged_edit.decision_id, packet_id=packet.packet_id, status="staged", staged_edit=staged_edit, failure_reason=None, model=model, created_at=created_at, ) self._persist_result(result, staging_dir, packet) logger.info("Evolution authoring staged %s at %s", action_type, staging_dir) return result def _build_prompt( self, action_type: str, sources: list[_SourceSkill], packet: EvidencePacket, direction: str, ) -> str: packet_context = _packet_context(packet) if action_type == "FIX": current = sources[0].content if sources else "" return SkillEnginePrompts.evolution_fix( current_content=truncate(current, _SKILL_CONTENT_MAX_CHARS), direction=direction, failure_context=packet_context, ) if action_type == "DERIVED": if len(sources) > 1: parent_content = "\n\n---\n\n".join( f"## Parent {index + 1}: {source.record.name}\n" f"{truncate(source.content, _SKILL_CONTENT_MAX_CHARS)}" for index, source in enumerate(sources) ) else: parent_content = truncate( sources[0].content if sources else "", _SKILL_CONTENT_MAX_CHARS, ) return SkillEnginePrompts.evolution_derived( parent_content=parent_content, direction=direction, execution_insights=packet_context, ) return SkillEnginePrompts.evolution_captured( direction=direction, category=SkillCategory.WORKFLOW.value, execution_highlights=packet_context, ) async def _stage_edit( self, *, action_type: str, sources: list[_SourceSkill], packet: EvidencePacket, edit_content: str, staging_dir: Path, prompt: str, ctx: EvolutionContext, ) -> tuple[SkillEditResult | None, str, Path]: if action_type == "FIX": source = sources[0] proposed_name = source.record.name proposed_dir = staging_dir / "proposed" / source.skill_dir.name apply_fn = lambda content: stage_fix_skill( source.skill_dir, staging_dir, content, PatchType.AUTO, ) target_dir = source.skill_dir elif action_type == "DERIVED": proposed_name, edit_content = _derived_name(edit_content, sources) proposed_dir = staging_dir / "proposed" / proposed_name apply_fn = lambda content: stage_derive_skill( [source.skill_dir for source in sources], staging_dir, proposed_name, content, PatchType.AUTO, ) target_dir = sources[0].skill_dir.parent / proposed_name else: proposed_name = get_frontmatter_field(edit_content, "name") or "" if not proposed_name: raise ValueError("CAPTURED authoring output missing skill name") proposed_name = _sanitize_skill_name(proposed_name) edit_content = set_frontmatter_field(edit_content, "name", proposed_name) capture_root = _capture_root(packet) if capture_root is None: raise ValueError("CAPTURED action packet missing capture destination root") proposed_dir = staging_dir / "proposed" / proposed_name apply_fn = lambda content: stage_create_skill( staging_dir, proposed_name, content, PatchType.AUTO, ) target_dir = capture_root / proposed_name retry = getattr(self.evolver, "_apply_with_retry", None) if callable(retry): result = await retry( apply_fn=apply_fn, initial_content=edit_content, skill_dir=proposed_dir, ctx=ctx, prompt=prompt, cleanup_on_retry=staging_dir, ) return result, proposed_name, target_dir result = apply_fn(edit_content) if result.ok: validation_error = validate_skill_dir(proposed_dir) if validation_error: return SkillEditResult(error=f"Validation failed: {validation_error}"), proposed_name, target_dir return result, proposed_name, target_dir def _persist_result( self, result: AuthoringResult, staging_dir: Path, packet: EvidencePacket, ) -> None: staging_dir.mkdir(parents=True, exist_ok=True) (staging_dir / "authoring.json").write_text( json.dumps(result.to_dict(), indent=2, sort_keys=True, default=str), encoding="utf-8", ) (staging_dir / "prompt_refs.json").write_text( json.dumps( { "packet_id": packet.packet_id, "refs": [ ref.ref_id for refs in packet.selected_refs.values() for ref in refs if ref.ref_id ], }, indent=2, sort_keys=True, ), encoding="utf-8", ) if self.evidence_store is None: return ref = ResourceRef( ref_id=f"authoring:{result.authoring_id}", ref_type="authoring_result_ref", uri=str(staging_dir), session_id=packet.scope.session_id, task_id=packet.scope.task_id, producer="authoring_backend", created_at=result.created_at, reliability="derived", role="derived", preview=( f"authoring {result.status}" + (f": {result.failure_reason}" if result.failure_reason else "") )[:500], metadata={ "authoring_id": result.authoring_id, "decision_id": result.decision_id, "packet_id": result.packet_id, "status": result.status, "failure_reason": result.failure_reason, "staging_dir": str(staging_dir), "staged_edit": ( result.staged_edit.to_dict() if result.staged_edit is not None else None ), }, raw_backrefs=_authoring_backrefs(packet, result), ) event = EvidenceEvent.create( event_id=f"evt_authoring_{_digest(result.authoring_id)}", event_type="authoring_result_persisted", producer="authoring_backend", created_at=result.created_at, session_id=packet.scope.session_id, task_id=packet.scope.task_id, idempotency_key=f"authoring_result:{result.authoring_id}", derived_refs=[ref], metadata={ "authoring_id": result.authoring_id, "packet_id": result.packet_id, "status": result.status, }, ) self.evidence_store.ingest_event(event) def _source_skills( packet: EvidencePacket, target_skill_ids: list[str], evolver: Any, ) -> list[_SourceSkill]: if not target_skill_ids: return [] reader = PacketAuditReader(packet) skill_refs = [ ref for ref in _all_refs(packet) if ref.ref_type == "skill_file" and str(ref.metadata.get("skill_id") or "") in set(target_skill_ids) ] by_skill_id = { str(ref.metadata.get("skill_id") or ""): ref for ref in skill_refs } sources: list[_SourceSkill] = [] for skill_id in target_skill_ids: ref = by_skill_id.get(skill_id) if ref is None: raise ValueError(f"missing skill_file ref for target skill: {skill_id}") try: content = reader.read_skill_file(ref.ref_id) except PacketAuditReadError as exc: raise ValueError(f"unreadable skill_file ref {ref.ref_id}: {exc}") from exc path_text = str(ref.metadata.get("path") or ref.uri or "") if not path_text: raise ValueError(f"skill_file ref missing path: {ref.ref_id}") skill_file = Path(path_text).expanduser().resolve() skill_dir = skill_file.parent if skill_file.name == SKILL_FILENAME else skill_file record = _load_record(evolver, skill_id) if record is None: record = _record_from_ref(skill_id, ref, content, skill_file) sources.append( _SourceSkill( skill_id=skill_id, record=record, content=content, skill_dir=skill_dir, file_ref=ref, ) ) return sources def _load_record(evolver: Any, skill_id: str) -> SkillRecord | None: store = getattr(evolver, "_store", None) load_record = getattr(store, "load_record", None) if callable(load_record): try: record = load_record(skill_id) if isinstance(record, SkillRecord): return record except Exception: logger.debug("Authoring could not load SkillRecord %s", skill_id, exc_info=True) return None def _record_from_ref( skill_id: str, ref: ResourceRef, content: str, skill_file: Path, ) -> SkillRecord: name = get_frontmatter_field(content, "name") or skill_file.parent.name description = get_frontmatter_field(content, "description") or ref.preview or name return SkillRecord( skill_id=skill_id, name=name, description=description, path=str(skill_file), lineage=SkillLineage(origin=SkillOrigin.IMPORTED), ) def _single_ref(packet: EvidencePacket, ref_type: str) -> ResourceRef | None: refs = packet.selected_refs.get(ref_type) or [] return refs[0] if refs else None def _all_refs(packet: EvidencePacket) -> list[ResourceRef]: return [ ref for refs in packet.selected_refs.values() for ref in refs ] def _action_type(decision_ref: ResourceRef) -> str: return str(decision_ref.metadata.get("proposed_action") or "").strip().upper() def _decision_id(packet: EvidencePacket) -> str: ref = _single_ref(packet, "decision_rationale_ref") if ref is None: return "" return str(ref.metadata.get("decision_id") or "").strip() def _target_skill_ids(decision_ref: ResourceRef) -> list[str]: return _str_list(decision_ref.metadata.get("target_skill_ids")) def _category(decision_ref: ResourceRef) -> SkillCategory | None: value = decision_ref.metadata.get("category") if not value: return None try: return SkillCategory(str(value)) except ValueError: return None def _capture_root(packet: EvidencePacket) -> Path | None: for key in ("capture_destination_root", "capture_root", "capture_skill_dir"): value = packet.instructions.get(key) if value: return Path(value).expanduser().resolve() for ref in _all_refs(packet): for key in ("capture_destination_root", "capture_root", "capture_skill_dir"): value = ref.metadata.get(key) if value: return Path(str(value)).expanduser().resolve() return None def _packet_context(packet: EvidencePacket) -> str: snippets = [ snippet.text for snippet in packet.expanded_snippets if str(snippet.text or "").strip() ] if snippets: return "\n\n".join(snippets) previews = [ f"[{ref.ref_id}] {ref.preview}" for ref in _all_refs(packet) if ref.preview ] return "\n".join(previews) or f"Evidence packet {packet.packet_id}" def _source_packet_id(packet: EvidencePacket) -> str | None: packet_ref = _single_ref(packet, "evidence_packet_ref") if packet_ref is None: return None return str(packet_ref.metadata.get("packet_id") or packet_ref.ref_id).removeprefix("packet:") def _derived_name( edit_content: str, sources: list[_SourceSkill], ) -> tuple[str, str]: first_parent_name = sources[0].record.name if sources else "derived-skill" is_merge = len(sources) > 1 new_name = get_frontmatter_field(edit_content, "name") if not new_name or new_name == first_parent_name: suffix = "-merged" if is_merge else "-enhanced" new_name = f"{first_parent_name}{suffix}" new_name = _sanitize_skill_name(new_name) return new_name, set_frontmatter_field(edit_content, "name", new_name) def _sanitize_skill_name(name: str) -> str: import re clean = re.sub(r"[^a-z0-9\-]", "-", name.lower().strip()) clean = re.sub(r"-{2,}", "-", clean).strip("-") return clean[:50].strip("-") or "skill" def _proposed_skill_id( action_type: str, proposed_name: str, parent: SkillRecord | None, ) -> str | None: if not proposed_name: return None if action_type == "FIX" and parent is not None: generation = parent.lineage.generation + 1 return f"{proposed_name}__v{generation}_{uuid.uuid4().hex[:8]}" return f"{proposed_name}__v0_{uuid.uuid4().hex[:8]}" def _changed_files(edit_result: SkillEditResult) -> list[str]: files: set[str] = set() for line in edit_result.content_diff.splitlines(): if line.startswith("+++ b/"): name = line.removeprefix("+++ b/") if name and name != "/dev/null": files.add(name) elif line.startswith("--- a/"): name = line.removeprefix("--- a/") if name and name != "/dev/null": files.add(name) if files: return sorted(files) return sorted(edit_result.content_snapshot) def _evidence_refs( packet: EvidencePacket, decision_ref: ResourceRef, admission_ref: ResourceRef, ) -> list[str]: refs: list[str] = [] refs.extend(decision_ref.raw_backrefs) refs.extend(admission_ref.raw_backrefs) refs.extend( ref.ref_id for ref in _all_refs(packet) if ref.ref_type not in { "decision_rationale_ref", "admission_result_ref", "evidence_packet_ref", } ) return [item for item in dict.fromkeys(refs) if item] def _authoring_backrefs( packet: EvidencePacket, result: AuthoringResult, ) -> list[str]: refs = [f"packet:{packet.packet_id}"] refs.extend( ref.ref_id for ref in _all_refs(packet) if ref.ref_id ) if result.staged_edit is not None: refs.extend(result.staged_edit.evidence_refs) return [item for item in dict.fromkeys(refs) if item] def _str_list(value: Any) -> list[str]: if value is None: return [] if isinstance(value, str): return [value] if value else [] if isinstance(value, (list, tuple, set)): return [str(item) for item in value if str(item)] return [] def _model_name(evolver: Any) -> str: explicit = getattr(evolver, "_model", None) if explicit: return str(explicit) llm = getattr(evolver, "_llm_client", None) return str(getattr(llm, "model", "") or "") def _digest(value: Any) -> str: payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str) import hashlib return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24] def _utc_now() -> str: return datetime.now(timezone.utc).isoformat()