feat(skill-learning): merge plugin skill updates

This commit is contained in:
2026-06-16 11:55:55 +08:00
parent c9e6c37b5c
commit a34b1219bc
15 changed files with 860 additions and 5 deletions

View File

@ -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"}
}