"""LLM-backed draft synthesis for skill learning.""" from __future__ import annotations import json from typing import Any from beaver.engine.providers.base import LLMProvider from beaver.skills.authoring import canonical_skill_format_instructions, ensure_canonical_skill_body, normalize_skill_frontmatter from beaver.skills.learning.evidence import EvidencePacket from beaver.memory.skills.models import SkillLearningCandidate class SkillDraftSynthesizer: async def synthesize_revision( self, candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, provider: LLMProvider, model: str, base_skill: dict[str, Any] | None = None, ) -> dict[str, Any]: return await self._synthesize(candidate, evidence_packet, provider, model, "revise", base_skill=base_skill) async def synthesize_new_skill( self, candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, provider: LLMProvider, model: str, ) -> dict[str, Any]: return await self._synthesize(candidate, evidence_packet, provider, model, "new", base_skill=None) async def synthesize_merge( self, candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, provider: LLMProvider, model: str, base_skill: dict[str, Any] | None = None, ) -> dict[str, Any]: return await self._synthesize(candidate, evidence_packet, provider, model, "merge", base_skill=base_skill) async def synthesize_plugin_update( self, candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, provider: LLMProvider, model: str, *, old_upstream: dict[str, Any], current_local: dict[str, Any], new_upstream: dict[str, Any], ) -> dict[str, Any]: prompt = self._build_plugin_update_prompt( candidate, evidence_packet, old_upstream=old_upstream, current_local=current_local, new_upstream=new_upstream, ) response = await provider.chat( messages=[ { "role": "system", "content": ( "You merge Beaver plugin skill updates. Return JSON only with keys: " "frontmatter, content, change_reason, preserved_local_sections, " "adopted_upstream_sections, resolved_conflicts, dropped_sections. " "Preserve valid local learning, adopt upstream fixes and safety changes, " "do not concatenate duplicate sections, and list every intentional drop." ), }, {"role": "user", "content": prompt}, ], tools=None, model=model, max_tokens=4096, temperature=0, ) payload = self._parse_plugin_update_payload(response.content or "") if payload: return payload fallback = self._fallback_payload(candidate, evidence_packet, "plugin_update") return { **fallback, "preserved_local_sections": [], "adopted_upstream_sections": [], "resolved_conflicts": [], "dropped_sections": [], } async def _synthesize( self, candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, provider: LLMProvider, model: str, action: str, *, base_skill: dict[str, Any] | None, ) -> dict[str, Any]: prompt = self._build_prompt(candidate, evidence_packet, action, base_skill=base_skill) response = await provider.chat( messages=[ { "role": "system", "content": ( "You synthesize Beaver skill drafts from execution evidence. " "Return only JSON with keys: frontmatter, content, change_reason, " "preserved_sections, changed_sections, dropped_sections. " "The content must follow the Canonical Beaver SKILL.md format." ), }, {"role": "user", "content": prompt}, ], tools=None, model=model, max_tokens=4096, temperature=0, ) payload = self._parse_payload(response.content or "") if payload: return self._normalize_payload(payload, evidence_packet) return self._fallback_payload(candidate, evidence_packet, action) @staticmethod def _build_prompt( candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str, base_skill: dict[str, Any] | None = None, ) -> str: tool_names = _coerce_string_list(evidence_packet.metadata.get("tool_names")) tool_section = ", ".join(tool_names) if tool_names else "none observed" selected_tool_names = _coerce_string_list(evidence_packet.metadata.get("selected_tool_names")) selected_tool_section = ", ".join(selected_tool_names) if selected_tool_names else "none recorded" base_section = "" if base_skill: base_section = ( "\n\nBase skill snapshot:\n" f"- skill_name: {base_skill.get('skill_name')}\n" f"- version: {base_skill.get('version')}\n" f"- frontmatter: {json.dumps(base_skill.get('frontmatter') or {}, ensure_ascii=False, sort_keys=True)}\n" f"- tool_hints: {base_skill.get('tool_hints') or []}\n" f"- summary: {base_skill.get('summary') or ''}\n" "Base skill content:\n" f"{base_skill.get('content') or ''}\n" "Preserve existing instructions unless the evidence requires a change. " "If any section is changed or dropped, explain it in changed_sections or dropped_sections." ) return ( f"Action: {action}\n" f"Candidate kind: {candidate.kind}\n" f"Reason: {candidate.reason}\n" f"Related skills: {candidate.related_skill_names}\n" f"Called tool names: {tool_section}\n" f"Run-selected tool names: {selected_tool_section}\n" f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries) + "\n\nSession excerpts:\n" + "\n\n".join(evidence_packet.session_excerpts) + base_section + "\n\nReturn JSON only. The frontmatter object must include:" + "\n- description: a concise skill description" + "\n- tools: an explicit JSON array of exact tool names this skill needs. " + "Prefer called tool names when the workflow depends on them; use run-selected tool names only when clearly required. " + "Use [] only when no tool is required." + "\n\n" + canonical_skill_format_instructions() + "\nThe JSON may include preserved_sections, changed_sections, and dropped_sections arrays." ) @staticmethod def _build_plugin_update_prompt( candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, *, old_upstream: dict[str, Any], current_local: dict[str, Any], new_upstream: dict[str, Any], ) -> str: return ( f"Candidate kind: {candidate.kind}\n" f"Reason: {candidate.reason}\n" f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries or ["No historical run evidence."]) + "\n\nOLD UPSTREAM (merge base B):\n" + str(old_upstream.get("content") or "") + "\n\nCURRENT LOCAL (Beaver learned version L):\n" + str(current_local.get("content") or "") + "\n\nNEW UPSTREAM (plugin update U):\n" + str(new_upstream.get("content") or "") + "\n\nReturn JSON only. Preserve useful CURRENT LOCAL learning and adopt important NEW UPSTREAM changes." ) @staticmethod def _parse_payload(content: str) -> dict[str, Any]: cleaned = content.strip() if cleaned.startswith("```"): lines = cleaned.splitlines() if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"): cleaned = "\n".join(lines[1:-1]).strip() try: payload = json.loads(cleaned) except json.JSONDecodeError: return {} if not isinstance(payload, dict): return {} frontmatter = payload.get("frontmatter") content_value = payload.get("content") if not isinstance(frontmatter, dict) or not isinstance(content_value, str): return {} return { "frontmatter": frontmatter, "content": content_value.strip(), "change_reason": str(payload.get("change_reason") or ""), "preserved_sections": _coerce_string_list(payload.get("preserved_sections")), "changed_sections": _coerce_string_list(payload.get("changed_sections")), "dropped_sections": _coerce_string_list(payload.get("dropped_sections")), } @staticmethod def _parse_plugin_update_payload(content: str) -> dict[str, Any]: cleaned = content.strip() if cleaned.startswith("```"): lines = cleaned.splitlines() if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"): cleaned = "\n".join(lines[1:-1]).strip() try: payload = json.loads(cleaned) except json.JSONDecodeError: return {} if not isinstance(payload, dict): return {} frontmatter = payload.get("frontmatter") content_value = payload.get("content") if not isinstance(frontmatter, dict) or not isinstance(content_value, str): return {} return { "frontmatter": frontmatter, "content": content_value.strip(), "change_reason": str(payload.get("change_reason") or ""), "preserved_local_sections": _coerce_string_list(payload.get("preserved_local_sections")), "adopted_upstream_sections": _coerce_string_list(payload.get("adopted_upstream_sections")), "resolved_conflicts": _coerce_string_list(payload.get("resolved_conflicts")), "dropped_sections": _coerce_string_list(payload.get("dropped_sections")), } @staticmethod def _normalize_payload(payload: dict[str, Any], evidence_packet: EvidencePacket) -> dict[str, Any]: frontmatter = normalize_skill_frontmatter( dict(payload.get("frontmatter") or {}), skill_name=str((payload.get("frontmatter") or {}).get("name") or "generated-skill"), ) tool_hints = _coerce_string_list(frontmatter.get("tools")) if not tool_hints: tool_hints = _coerce_string_list(evidence_packet.metadata.get("tool_names")) frontmatter["tools"] = tool_hints content = ensure_canonical_skill_body( str(payload.get("content") or "").strip(), title=str(frontmatter.get("name") or "generated-skill"), description=str(frontmatter.get("description") or ""), tools=tool_hints, ) return { "frontmatter": frontmatter, "content": content, "change_reason": str(payload.get("change_reason") or ""), "preserved_sections": _coerce_string_list(payload.get("preserved_sections")), "changed_sections": _coerce_string_list(payload.get("changed_sections")), "dropped_sections": _coerce_string_list(payload.get("dropped_sections")), } @staticmethod def _fallback_payload(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> dict[str, Any]: related = candidate.related_skill_names[0] if candidate.related_skill_names else "generated-skill" title = related.replace("_", "-") tools = _coerce_string_list(evidence_packet.metadata.get("tool_names")) content = ensure_canonical_skill_body( "\n".join(f"- {item}" for item in evidence_packet.task_summaries[:5]) or "- No evidence captured.", title=title, description=candidate.reason or f"Auto-generated {action} draft for {title}.", tools=tools, ) return { "frontmatter": { "name": title, "description": candidate.reason or f"Auto-generated {action} draft for {title}.", "tools": tools, }, "content": content, "change_reason": candidate.reason or f"Fallback {action} synthesis.", "preserved_sections": [], "changed_sections": [], "dropped_sections": [], } def _coerce_string_list(value: Any) -> list[str]: raw_items: list[Any] if isinstance(value, list): raw_items = value elif isinstance(value, str): raw_items = value.split(",") else: raw_items = [] result: list[str] = [] for item in raw_items: cleaned = str(item).strip() if cleaned and cleaned not in result: result.append(cleaned) return result