feat(skill-learning): merge plugin skill updates
This commit is contained in:
@ -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"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user