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

@ -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,

View File

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

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

View File

@ -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(