feat(skill-learning): merge plugin skill updates
This commit is contained in:
@ -94,6 +94,34 @@ class DraftService:
|
||||
self.store.write_draft(draft)
|
||||
return draft
|
||||
|
||||
def create_plugin_update_draft(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
base_version: str,
|
||||
proposed_content: str,
|
||||
proposed_frontmatter: dict,
|
||||
created_by: str,
|
||||
reason: str,
|
||||
provenance: dict,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillDraft:
|
||||
draft = SkillDraft(
|
||||
draft_id=uuid4().hex,
|
||||
skill_name=skill_name,
|
||||
base_version=base_version,
|
||||
proposed_content=proposed_content,
|
||||
proposed_frontmatter=dict(proposed_frontmatter),
|
||||
created_at=_utc_now(),
|
||||
created_by=created_by,
|
||||
reason=reason,
|
||||
evidence_refs=list(evidence_refs or []),
|
||||
proposal_kind="plugin_skill_update",
|
||||
provenance=dict(provenance),
|
||||
)
|
||||
self.store.write_draft(draft)
|
||||
return draft
|
||||
|
||||
def create_retire_proposal(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -35,6 +35,7 @@ class SkillLearningPipelineService:
|
||||
publisher: SkillPublisher,
|
||||
safety_checker: SkillDraftSafetyChecker | None = None,
|
||||
evaluator: SkillDraftEvaluator | None = None,
|
||||
publish_observer: Callable[[SkillDraft, SkillVersion | SkillSpec], None] | None = None,
|
||||
) -> None:
|
||||
self.learning_store = learning_store
|
||||
self.learning_service = learning_service
|
||||
@ -43,6 +44,7 @@ class SkillLearningPipelineService:
|
||||
self.publisher = publisher
|
||||
self.safety_checker = safety_checker or SkillDraftSafetyChecker()
|
||||
self.evaluator = evaluator
|
||||
self.publish_observer = publish_observer
|
||||
|
||||
def list_candidates(self, status: str | None = None) -> list[SkillLearningCandidate]:
|
||||
return self.learning_store.list_learning_candidates(status=status)
|
||||
@ -238,6 +240,16 @@ class SkillLearningPipelineService:
|
||||
else:
|
||||
result = self.publisher.publish(skill_name, draft_id, publisher=publisher, notes=notes)
|
||||
self._mark_candidate_by_draft(skill_name, draft_id, "published", "published")
|
||||
if self.publish_observer is not None:
|
||||
try:
|
||||
self.publish_observer(draft, result)
|
||||
except Exception as exc: # noqa: BLE001 - observer is best effort after successful publish.
|
||||
candidate = self._candidate_by_draft(skill_name, draft_id)
|
||||
self.learning_store.append_audit_event(
|
||||
candidate.candidate_id if candidate is not None else f"draft:{draft_id}",
|
||||
"plugin_publish_ack_failed",
|
||||
{"error": str(exc), "skill_name": skill_name, "draft_id": draft_id},
|
||||
)
|
||||
return result
|
||||
|
||||
def rollback(
|
||||
@ -391,6 +403,14 @@ class SkillLearningPipelineService:
|
||||
preservation = eval_report.preservation_report or {}
|
||||
if preservation.get("passed") is False:
|
||||
raise ValueError("Draft preservation check did not pass")
|
||||
if draft.proposal_kind == "plugin_skill_update":
|
||||
if draft.provenance.get("merge_mode") == "three_way" and preservation.get("mode") != "plugin_three_way":
|
||||
raise ValueError("Plugin update requires a three-way preservation report")
|
||||
if preservation.get("unresolved_conflicts"):
|
||||
raise ValueError("Plugin update has unresolved merge conflicts")
|
||||
supporting_plan = draft.provenance.get("supporting_file_plan")
|
||||
if isinstance(supporting_plan, dict) and supporting_plan.get("conflicts"):
|
||||
raise ValueError("Plugin update has unresolved supporting-file conflicts")
|
||||
|
||||
def _mark_candidate_by_draft(
|
||||
self,
|
||||
|
||||
@ -32,6 +32,30 @@ def check_preservation(*, base_content: str, draft_content: str) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
def check_plugin_merge_preservation(
|
||||
*,
|
||||
local_content: str,
|
||||
upstream_content: str,
|
||||
draft_content: str,
|
||||
merge_decisions: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
local = check_preservation(base_content=local_content, draft_content=draft_content)
|
||||
upstream = check_preservation(base_content=upstream_content, draft_content=draft_content)
|
||||
unresolved = [str(item) for item in merge_decisions.get("unresolved_conflicts") or []]
|
||||
safety_sections_missing = _important_sections_missing(upstream, local)
|
||||
passed = bool(local.get("passed")) and bool(upstream.get("passed")) and not unresolved and not safety_sections_missing
|
||||
return {
|
||||
"mode": "plugin_three_way",
|
||||
"passed": passed,
|
||||
"risk_level": "high" if not passed else "low",
|
||||
"local": local,
|
||||
"upstream": upstream,
|
||||
"unresolved_conflicts": unresolved,
|
||||
"safety_sections_missing": safety_sections_missing,
|
||||
"resolved_conflicts": [str(item) for item in merge_decisions.get("resolved_conflicts") or []],
|
||||
}
|
||||
|
||||
|
||||
def _sections(content: str) -> dict[str, str]:
|
||||
current = "body"
|
||||
sections: dict[str, list[str]] = {current: []}
|
||||
@ -51,3 +75,13 @@ def _sections(content: str) -> dict[str, str]:
|
||||
|
||||
def _normalize(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value or "").strip().lower()
|
||||
|
||||
|
||||
def _important_sections_missing(*reports: dict[str, Any]) -> list[str]:
|
||||
important = {"safety", "required tools", "required tool", "tools"}
|
||||
missing: list[str] = []
|
||||
for report in reports:
|
||||
for section in report.get("dropped_sections") or []:
|
||||
if str(section).strip().lower() in important and str(section) not in missing:
|
||||
missing.append(str(section))
|
||||
return missing
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import combinations
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
@ -14,9 +15,12 @@ from beaver.memory.runs.models import RunRecord, SkillEffectRecord
|
||||
from beaver.memory.runs.store import RunMemoryStore
|
||||
from beaver.memory.skills.models import SkillLearningCandidate, SkillPerformanceSnapshot
|
||||
from beaver.memory.skills.store import SkillLearningStore
|
||||
from beaver.plugins.hashing import hash_plugin_skill_tree
|
||||
from beaver.plugins.tree_merge import merge_supporting_file_trees
|
||||
from beaver.skills.drafts.service import DraftService
|
||||
from beaver.skills.learning.evidence import EvidencePacket, EvidenceSelector
|
||||
from beaver.skills.learning.synthesizer import SkillDraftSynthesizer
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
from beaver.skills.specs import SkillActivationReceipt
|
||||
|
||||
|
||||
@ -179,6 +183,8 @@ class SkillLearningService:
|
||||
candidate = candidates.get(candidate_id)
|
||||
if candidate is None:
|
||||
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
||||
if candidate.kind == "plugin_skill_update":
|
||||
return await self._synthesize_plugin_update(candidate, provider_bundle)
|
||||
if candidate.kind == "retire_skill":
|
||||
target_skill = candidate.related_skill_names[0]
|
||||
return self.draft_service.create_retire_proposal(
|
||||
@ -242,6 +248,85 @@ class SkillLearningService:
|
||||
evidence_refs=[{"run_id": item} for item in candidate.source_run_ids],
|
||||
)
|
||||
|
||||
async def _synthesize_plugin_update(self, candidate: SkillLearningCandidate, provider_bundle: ProviderBundle) -> Any:
|
||||
evidence = dict(candidate.evidence)
|
||||
skill_name = str(evidence.get("skill_name") or (candidate.related_skill_names[0] if candidate.related_skill_names else ""))
|
||||
plugin_id = str(evidence.get("plugin_id") or "")
|
||||
new_upstream_tree_hash = str(evidence.get("new_upstream_tree_hash") or "")
|
||||
local_version = str(evidence.get("local_version") or "")
|
||||
merge_mode = str(evidence.get("merge_mode") or "")
|
||||
if not skill_name or not plugin_id or not new_upstream_tree_hash or not local_version:
|
||||
raise ValueError("Plugin update candidate is missing required evidence references")
|
||||
new_upstream = self.draft_service.store.read_upstream_snapshot(
|
||||
skill_name,
|
||||
plugin_id,
|
||||
new_upstream_tree_hash,
|
||||
)
|
||||
if new_upstream is None:
|
||||
raise ValueError("Plugin update references a missing upstream snapshot")
|
||||
frontmatter, body = parse_frontmatter(new_upstream.content)
|
||||
if merge_mode == "fast_forward":
|
||||
return self.draft_service.create_plugin_update_draft(
|
||||
skill_name=skill_name,
|
||||
base_version=local_version,
|
||||
proposed_content=body.strip(),
|
||||
proposed_frontmatter=frontmatter,
|
||||
created_by="learning-loop",
|
||||
reason=candidate.reason,
|
||||
provenance={
|
||||
**evidence,
|
||||
"proposal_kind": "plugin_skill_update",
|
||||
},
|
||||
evidence_refs=[],
|
||||
)
|
||||
base_upstream_tree_hash = str(evidence.get("base_upstream_tree_hash") or "")
|
||||
old_upstream = self.draft_service.store.read_upstream_snapshot(skill_name, plugin_id, base_upstream_tree_hash)
|
||||
current_local = self.draft_service.store.read_published_skill(skill_name, local_version)
|
||||
if old_upstream is None:
|
||||
raise ValueError("Plugin update references a missing base upstream snapshot")
|
||||
if current_local is None:
|
||||
raise ValueError("Plugin update references a missing local skill version")
|
||||
packet = self.evidence_selector.build_evidence_packet(candidate.source_run_ids, candidate.source_session_ids)
|
||||
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
|
||||
model = (
|
||||
provider_bundle.auxiliary_runtime.model
|
||||
if provider_bundle.auxiliary_runtime is not None
|
||||
else provider_bundle.main_runtime.model
|
||||
)
|
||||
local_root = self.draft_service.store.root / skill_name / "versions" / local_version
|
||||
file_plan = merge_supporting_file_trees(
|
||||
base=_digest_map(old_upstream.root),
|
||||
local=_digest_map(local_root),
|
||||
upstream=_digest_map(new_upstream.root),
|
||||
)
|
||||
payload = await self.synthesizer.synthesize_plugin_update(
|
||||
candidate,
|
||||
packet,
|
||||
provider,
|
||||
model,
|
||||
old_upstream={"content": old_upstream.content, "frontmatter": old_upstream.snapshot.frontmatter},
|
||||
current_local={"content": current_local.content, "frontmatter": current_local.version.frontmatter},
|
||||
new_upstream={"content": new_upstream.content, "frontmatter": frontmatter},
|
||||
)
|
||||
return self.draft_service.create_plugin_update_draft(
|
||||
skill_name=skill_name,
|
||||
base_version=local_version,
|
||||
proposed_content=payload["content"],
|
||||
proposed_frontmatter=payload["frontmatter"],
|
||||
created_by="learning-loop",
|
||||
reason=payload["change_reason"] or candidate.reason,
|
||||
provenance={
|
||||
**evidence,
|
||||
"proposal_kind": "plugin_skill_update",
|
||||
"preserved_local_sections": payload.get("preserved_local_sections", []),
|
||||
"adopted_upstream_sections": payload.get("adopted_upstream_sections", []),
|
||||
"resolved_conflicts": payload.get("resolved_conflicts", []),
|
||||
"dropped_sections": payload.get("dropped_sections", []),
|
||||
"supporting_file_plan": file_plan.to_dict(),
|
||||
},
|
||||
evidence_refs=[],
|
||||
)
|
||||
|
||||
def _base_skill_snapshot(self, skill_name: str, version: str | None) -> dict[str, Any] | None:
|
||||
loaded = self.draft_service.store.read_published_skill(skill_name, version)
|
||||
if loaded is None:
|
||||
@ -515,3 +600,16 @@ class SkillLearningService:
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _digest_map(root: Path) -> dict[str, dict[str, Any]]:
|
||||
digest = hash_plugin_skill_tree(root)
|
||||
return {
|
||||
item.path: {
|
||||
"content_hash": item.content_hash,
|
||||
"executable": item.executable,
|
||||
"size": item.size,
|
||||
}
|
||||
for item in digest.files
|
||||
if item.path not in {"SKILL.md", "version.json", "upstream.json"}
|
||||
}
|
||||
|
||||
@ -41,6 +41,55 @@ class SkillDraftSynthesizer:
|
||||
) -> 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,
|
||||
@ -119,6 +168,28 @@ class SkillDraftSynthesizer:
|
||||
+ "\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()
|
||||
@ -145,6 +216,33 @@ class SkillDraftSynthesizer:
|
||||
"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(
|
||||
|
||||
@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from beaver.skills.catalog.utils import strip_frontmatter
|
||||
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
|
||||
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
|
||||
from beaver.plugins.hashing import hash_plugin_skill_tree
|
||||
|
||||
|
||||
class SkillPublisher:
|
||||
@ -40,6 +41,7 @@ class SkillPublisher:
|
||||
summary=summarize_skill_content(body),
|
||||
tool_hints=self.store._extract_tool_hints(normalize_frontmatter(draft.proposed_frontmatter)),
|
||||
provenance={
|
||||
**dict(draft.provenance),
|
||||
"draft_id": draft_id,
|
||||
"proposal_kind": draft.proposal_kind,
|
||||
"trigger_run_id": draft.trigger_run_id,
|
||||
@ -47,7 +49,13 @@ class SkillPublisher:
|
||||
},
|
||||
)
|
||||
self.store.write_skill_version(version, content)
|
||||
self._copy_uploaded_supporting_files(draft, next_version)
|
||||
if draft.proposal_kind == "plugin_skill_update":
|
||||
self._copy_plugin_update_supporting_files(draft, next_version)
|
||||
version_dir = self.store.root / draft.skill_name / "versions" / next_version
|
||||
version.tree_hash = hash_plugin_skill_tree(version_dir).skill_tree_hash
|
||||
self.store._write_json(version_dir / "version.json", version.to_dict())
|
||||
else:
|
||||
self._copy_uploaded_supporting_files(draft, next_version)
|
||||
self.store.set_current_version(skill_name, next_version)
|
||||
|
||||
spec = self.store.get_skill_spec(skill_name)
|
||||
@ -194,6 +202,25 @@ class SkillPublisher:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
|
||||
def _copy_plugin_update_supporting_files(self, draft: SkillDraft, version: str) -> None:
|
||||
plugin_id = str(draft.provenance.get("plugin_id") or "")
|
||||
tree_hash = str(draft.provenance.get("new_upstream_tree_hash") or "")
|
||||
if not plugin_id or not tree_hash:
|
||||
raise ValueError("Plugin update draft is missing upstream provenance")
|
||||
upstream = self.store.read_upstream_snapshot(draft.skill_name, plugin_id, tree_hash)
|
||||
if upstream is None:
|
||||
raise ValueError("Plugin update upstream snapshot is missing")
|
||||
target_root = self.store.root / draft.skill_name / "versions" / version
|
||||
for source in sorted(upstream.root.rglob("*"), key=lambda item: item.relative_to(upstream.root).as_posix()):
|
||||
if not source.is_file() or source.is_symlink():
|
||||
continue
|
||||
relative = source.relative_to(upstream.root)
|
||||
if relative.as_posix() in {"SKILL.md", "upstream.json", "version.json"}:
|
||||
continue
|
||||
target = target_root / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
|
||||
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||
draft = self.store.read_draft(skill_name, draft_id)
|
||||
if draft is None:
|
||||
|
||||
@ -180,6 +180,7 @@ class SkillDraft:
|
||||
status: str = SkillReviewState.DRAFT.value
|
||||
evidence_refs: list[dict[str, Any]] = field(default_factory=list)
|
||||
proposal_kind: str = "revise_skill"
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@ -196,6 +197,7 @@ class SkillDraft:
|
||||
"status": self.status,
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
"proposal_kind": self.proposal_kind,
|
||||
"provenance": dict(self.provenance),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -214,6 +216,7 @@ class SkillDraft:
|
||||
status=str(payload.get("status") or SkillReviewState.DRAFT.value),
|
||||
evidence_refs=list(payload.get("evidence_refs") or []),
|
||||
proposal_kind=str(payload.get("proposal_kind") or "revise_skill"),
|
||||
provenance=dict(payload.get("provenance") or {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user