feat(skill-learning): merge plugin skill updates
This commit is contained in:
@ -33,7 +33,17 @@ class SkillLearningStore:
|
|||||||
self.eval_reports_dir = self.root / "eval-reports"
|
self.eval_reports_dir = self.root / "eval-reports"
|
||||||
|
|
||||||
def record_learning_candidate(self, candidate: SkillLearningCandidate) -> None:
|
def record_learning_candidate(self, candidate: SkillLearningCandidate) -> None:
|
||||||
self.record_learning_candidate_if_absent(candidate)
|
normalized = SkillLearningCandidate.from_dict(candidate.to_dict())
|
||||||
|
self._append_jsonl(self.candidates_path, normalized.to_dict())
|
||||||
|
self.append_audit_event(
|
||||||
|
normalized.candidate_id,
|
||||||
|
"candidate_created",
|
||||||
|
{
|
||||||
|
"kind": normalized.kind,
|
||||||
|
"status": normalized.status,
|
||||||
|
"reason": normalized.reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def record_learning_candidate_if_absent(
|
def record_learning_candidate_if_absent(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -13,7 +13,6 @@ from .models import (
|
|||||||
PluginState,
|
PluginState,
|
||||||
)
|
)
|
||||||
from .state import PluginStateStore
|
from .state import PluginStateStore
|
||||||
from .skills import PluginManager
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PluginDiscoveryError",
|
"PluginDiscoveryError",
|
||||||
@ -25,7 +24,6 @@ __all__ = [
|
|||||||
"PluginSkillTreeDigest",
|
"PluginSkillTreeDigest",
|
||||||
"PluginState",
|
"PluginState",
|
||||||
"PluginStateStore",
|
"PluginStateStore",
|
||||||
"PluginManager",
|
|
||||||
"hash_plugin_skill_tree",
|
"hash_plugin_skill_tree",
|
||||||
"load_plugin_manifest",
|
"load_plugin_manifest",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -109,6 +109,77 @@ class PluginManager:
|
|||||||
results[state.plugin_id] = self._sync_plugin(state, manifest)
|
results[state.plugin_id] = self._sync_plugin(state, manifest)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def pause(self, plugin_id: str) -> PluginState:
|
||||||
|
with self.write_lock.acquire(timeout_seconds=10):
|
||||||
|
state = self._require_state(plugin_id)
|
||||||
|
state.updates_paused = True
|
||||||
|
self.state_store.upsert_plugin(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def resume(self, plugin_id: str) -> PluginState:
|
||||||
|
with self.write_lock.acquire(timeout_seconds=10):
|
||||||
|
state = self._require_state(plugin_id)
|
||||||
|
state.updates_paused = False
|
||||||
|
self.state_store.upsert_plugin(state)
|
||||||
|
return self.sync_enabled().get(plugin_id) or self._require_state(plugin_id)
|
||||||
|
|
||||||
|
def disable(self, plugin_id: str, *, disable_linked_skills: bool) -> PluginState:
|
||||||
|
if not disable_linked_skills:
|
||||||
|
raise ValueError("disable_linked_skills confirmation is required")
|
||||||
|
with self.write_lock.acquire(timeout_seconds=10):
|
||||||
|
state = self._require_state(plugin_id)
|
||||||
|
for skill_name in list(state.skills):
|
||||||
|
self.publisher.disable(skill_name, actor="plugin-manager", reason=f"plugin_disabled:{plugin_id}")
|
||||||
|
state.skills[skill_name].status = "disabled"
|
||||||
|
state.enabled = False
|
||||||
|
state.updates_paused = True
|
||||||
|
state.status = "disabled"
|
||||||
|
self.state_store.upsert_plugin(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def adopt(self, plugin_id: str, skill_name: str) -> SkillSpec:
|
||||||
|
with self.write_lock.acquire(timeout_seconds=10):
|
||||||
|
state = self._require_state(plugin_id)
|
||||||
|
if skill_name not in state.skills:
|
||||||
|
raise ValueError(f"Plugin skill binding not found: {plugin_id}/{skill_name}")
|
||||||
|
spec = self.skill_store.get_skill_spec(skill_name)
|
||||||
|
if spec is None:
|
||||||
|
raise ValueError(f"Skill spec not found: {skill_name}")
|
||||||
|
spec.source_kind = "managed"
|
||||||
|
spec.status = SkillStatus.ACTIVE.value
|
||||||
|
spec.updated_at = _utc_now()
|
||||||
|
marker = f"adopted_from_plugin:{plugin_id}"
|
||||||
|
if marker not in spec.lineage:
|
||||||
|
spec.lineage.append(marker)
|
||||||
|
self.skill_store.write_skill_spec(spec)
|
||||||
|
del state.skills[skill_name]
|
||||||
|
if not state.skills:
|
||||||
|
state.status = "adopted"
|
||||||
|
state.enabled = False
|
||||||
|
self.state_store.upsert_plugin(state)
|
||||||
|
self.publisher._refresh_indexes(skill_name, spec.status)
|
||||||
|
return spec
|
||||||
|
|
||||||
|
def on_skill_published(self, draft: SkillDraft, published: SkillVersion | SkillSpec) -> None:
|
||||||
|
if draft.proposal_kind != "plugin_skill_update" or not isinstance(published, SkillVersion):
|
||||||
|
return
|
||||||
|
plugin_id = str(draft.provenance.get("plugin_id") or "")
|
||||||
|
skill_name = str(draft.provenance.get("skill_name") or draft.skill_name)
|
||||||
|
tree_hash = str(draft.provenance.get("new_upstream_tree_hash") or "")
|
||||||
|
if not plugin_id or not skill_name or not tree_hash:
|
||||||
|
raise ValueError("Plugin publish acknowledgement is missing provenance")
|
||||||
|
state = self._require_state(plugin_id)
|
||||||
|
binding = state.skills.get(skill_name) or PluginSkillBinding()
|
||||||
|
binding.accepted_upstream_tree_hash = tree_hash
|
||||||
|
binding.observed_upstream_tree_hash = tree_hash
|
||||||
|
binding.accepted_beaver_version = published.version
|
||||||
|
binding.current_beaver_version = published.version
|
||||||
|
binding.pending_candidate_id = None
|
||||||
|
binding.status = "synced"
|
||||||
|
state.skills[skill_name] = binding
|
||||||
|
state.status = "synced"
|
||||||
|
self.state_store.upsert_plugin(state)
|
||||||
|
|
||||||
def _prepare_initial_mirror(
|
def _prepare_initial_mirror(
|
||||||
self,
|
self,
|
||||||
manifest: PluginManifest,
|
manifest: PluginManifest,
|
||||||
@ -174,6 +245,12 @@ class PluginManager:
|
|||||||
)
|
)
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
|
def _require_state(self, plugin_id: str) -> PluginState:
|
||||||
|
state = self.state_store.get_plugin(plugin_id)
|
||||||
|
if state is None:
|
||||||
|
raise ValueError(f"Unknown plugin state: {plugin_id}")
|
||||||
|
return state
|
||||||
|
|
||||||
def _sync_plugin(self, state: PluginState, manifest: PluginManifest) -> PluginState:
|
def _sync_plugin(self, state: PluginState, manifest: PluginManifest) -> PluginState:
|
||||||
transaction = PluginSkillTransaction(self.workspace)
|
transaction = PluginSkillTransaction(self.workspace)
|
||||||
try:
|
try:
|
||||||
|
|||||||
65
app-instance/backend/beaver/plugins/tree_merge.py
Normal file
65
app-instance/backend/beaver/plugins/tree_merge.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""Deterministic path-level three-way merge for plugin supporting files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SupportingFileDecision:
|
||||||
|
path: str
|
||||||
|
source: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"path": self.path, "source": self.source}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SupportingFileConflict:
|
||||||
|
path: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"path": self.path, "reason": self.reason}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SupportingFileMergePlan:
|
||||||
|
files: dict[str, SupportingFileDecision] = field(default_factory=dict)
|
||||||
|
conflicts: list[SupportingFileConflict] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"files": {path: decision.to_dict() for path, decision in sorted(self.files.items())},
|
||||||
|
"conflicts": [conflict.to_dict() for conflict in self.conflicts],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_supporting_file_trees(
|
||||||
|
*,
|
||||||
|
base: dict[str, Any],
|
||||||
|
local: dict[str, Any],
|
||||||
|
upstream: dict[str, Any],
|
||||||
|
) -> SupportingFileMergePlan:
|
||||||
|
decisions: dict[str, SupportingFileDecision] = {}
|
||||||
|
conflicts: list[SupportingFileConflict] = []
|
||||||
|
for path in sorted({*base.keys(), *local.keys(), *upstream.keys()} - {"SKILL.md"}):
|
||||||
|
b = base.get(path)
|
||||||
|
l = local.get(path)
|
||||||
|
u = upstream.get(path)
|
||||||
|
if l == u and l is not None:
|
||||||
|
decisions[path] = SupportingFileDecision(path=path, source="local")
|
||||||
|
elif l == b and u is not None:
|
||||||
|
decisions[path] = SupportingFileDecision(path=path, source="upstream")
|
||||||
|
elif u == b and l is not None:
|
||||||
|
decisions[path] = SupportingFileDecision(path=path, source="local")
|
||||||
|
elif b is None and l is None and u is not None:
|
||||||
|
decisions[path] = SupportingFileDecision(path=path, source="upstream")
|
||||||
|
elif b is None and u is None and l is not None:
|
||||||
|
decisions[path] = SupportingFileDecision(path=path, source="local")
|
||||||
|
elif b is not None and l is None and u is None:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
conflicts.append(SupportingFileConflict(path=path, reason="divergent supporting-file change"))
|
||||||
|
return SupportingFileMergePlan(files=decisions, conflicts=conflicts)
|
||||||
@ -94,6 +94,34 @@ class DraftService:
|
|||||||
self.store.write_draft(draft)
|
self.store.write_draft(draft)
|
||||||
return 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(
|
def create_retire_proposal(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class SkillLearningPipelineService:
|
|||||||
publisher: SkillPublisher,
|
publisher: SkillPublisher,
|
||||||
safety_checker: SkillDraftSafetyChecker | None = None,
|
safety_checker: SkillDraftSafetyChecker | None = None,
|
||||||
evaluator: SkillDraftEvaluator | None = None,
|
evaluator: SkillDraftEvaluator | None = None,
|
||||||
|
publish_observer: Callable[[SkillDraft, SkillVersion | SkillSpec], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.learning_store = learning_store
|
self.learning_store = learning_store
|
||||||
self.learning_service = learning_service
|
self.learning_service = learning_service
|
||||||
@ -43,6 +44,7 @@ class SkillLearningPipelineService:
|
|||||||
self.publisher = publisher
|
self.publisher = publisher
|
||||||
self.safety_checker = safety_checker or SkillDraftSafetyChecker()
|
self.safety_checker = safety_checker or SkillDraftSafetyChecker()
|
||||||
self.evaluator = evaluator
|
self.evaluator = evaluator
|
||||||
|
self.publish_observer = publish_observer
|
||||||
|
|
||||||
def list_candidates(self, status: str | None = None) -> list[SkillLearningCandidate]:
|
def list_candidates(self, status: str | None = None) -> list[SkillLearningCandidate]:
|
||||||
return self.learning_store.list_learning_candidates(status=status)
|
return self.learning_store.list_learning_candidates(status=status)
|
||||||
@ -238,6 +240,16 @@ class SkillLearningPipelineService:
|
|||||||
else:
|
else:
|
||||||
result = self.publisher.publish(skill_name, draft_id, publisher=publisher, notes=notes)
|
result = self.publisher.publish(skill_name, draft_id, publisher=publisher, notes=notes)
|
||||||
self._mark_candidate_by_draft(skill_name, draft_id, "published", "published")
|
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
|
return result
|
||||||
|
|
||||||
def rollback(
|
def rollback(
|
||||||
@ -391,6 +403,14 @@ class SkillLearningPipelineService:
|
|||||||
preservation = eval_report.preservation_report or {}
|
preservation = eval_report.preservation_report or {}
|
||||||
if preservation.get("passed") is False:
|
if preservation.get("passed") is False:
|
||||||
raise ValueError("Draft preservation check did not pass")
|
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(
|
def _mark_candidate_by_draft(
|
||||||
self,
|
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]:
|
def _sections(content: str) -> dict[str, str]:
|
||||||
current = "body"
|
current = "body"
|
||||||
sections: dict[str, list[str]] = {current: []}
|
sections: dict[str, list[str]] = {current: []}
|
||||||
@ -51,3 +75,13 @@ def _sections(content: str) -> dict[str, str]:
|
|||||||
|
|
||||||
def _normalize(value: str) -> str:
|
def _normalize(value: str) -> str:
|
||||||
return re.sub(r"\s+", " ", value or "").strip().lower()
|
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 dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from itertools import combinations
|
from itertools import combinations
|
||||||
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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.runs.store import RunMemoryStore
|
||||||
from beaver.memory.skills.models import SkillLearningCandidate, SkillPerformanceSnapshot
|
from beaver.memory.skills.models import SkillLearningCandidate, SkillPerformanceSnapshot
|
||||||
from beaver.memory.skills.store import SkillLearningStore
|
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.drafts.service import DraftService
|
||||||
from beaver.skills.learning.evidence import EvidencePacket, EvidenceSelector
|
from beaver.skills.learning.evidence import EvidencePacket, EvidenceSelector
|
||||||
from beaver.skills.learning.synthesizer import SkillDraftSynthesizer
|
from beaver.skills.learning.synthesizer import SkillDraftSynthesizer
|
||||||
|
from beaver.skills.catalog.utils import parse_frontmatter
|
||||||
from beaver.skills.specs import SkillActivationReceipt
|
from beaver.skills.specs import SkillActivationReceipt
|
||||||
|
|
||||||
|
|
||||||
@ -179,6 +183,8 @@ class SkillLearningService:
|
|||||||
candidate = candidates.get(candidate_id)
|
candidate = candidates.get(candidate_id)
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
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":
|
if candidate.kind == "retire_skill":
|
||||||
target_skill = candidate.related_skill_names[0]
|
target_skill = candidate.related_skill_names[0]
|
||||||
return self.draft_service.create_retire_proposal(
|
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],
|
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:
|
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)
|
loaded = self.draft_service.store.read_published_skill(skill_name, version)
|
||||||
if loaded is None:
|
if loaded is None:
|
||||||
@ -515,3 +600,16 @@ class SkillLearningService:
|
|||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
return parsed.replace(tzinfo=timezone.utc)
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
return parsed.astimezone(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]:
|
) -> dict[str, Any]:
|
||||||
return await self._synthesize(candidate, evidence_packet, provider, model, "merge", base_skill=base_skill)
|
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(
|
async def _synthesize(
|
||||||
self,
|
self,
|
||||||
candidate: SkillLearningCandidate,
|
candidate: SkillLearningCandidate,
|
||||||
@ -119,6 +168,28 @@ class SkillDraftSynthesizer:
|
|||||||
+ "\nThe JSON may include preserved_sections, changed_sections, and dropped_sections arrays."
|
+ "\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
|
@staticmethod
|
||||||
def _parse_payload(content: str) -> dict[str, Any]:
|
def _parse_payload(content: str) -> dict[str, Any]:
|
||||||
cleaned = content.strip()
|
cleaned = content.strip()
|
||||||
@ -145,6 +216,33 @@ class SkillDraftSynthesizer:
|
|||||||
"dropped_sections": _coerce_string_list(payload.get("dropped_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
|
@staticmethod
|
||||||
def _normalize_payload(payload: dict[str, Any], evidence_packet: EvidencePacket) -> dict[str, Any]:
|
def _normalize_payload(payload: dict[str, Any], evidence_packet: EvidencePacket) -> dict[str, Any]:
|
||||||
frontmatter = normalize_skill_frontmatter(
|
frontmatter = normalize_skill_frontmatter(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from beaver.skills.catalog.utils import strip_frontmatter
|
from beaver.skills.catalog.utils import strip_frontmatter
|
||||||
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
|
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.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
|
||||||
|
from beaver.plugins.hashing import hash_plugin_skill_tree
|
||||||
|
|
||||||
|
|
||||||
class SkillPublisher:
|
class SkillPublisher:
|
||||||
@ -40,6 +41,7 @@ class SkillPublisher:
|
|||||||
summary=summarize_skill_content(body),
|
summary=summarize_skill_content(body),
|
||||||
tool_hints=self.store._extract_tool_hints(normalize_frontmatter(draft.proposed_frontmatter)),
|
tool_hints=self.store._extract_tool_hints(normalize_frontmatter(draft.proposed_frontmatter)),
|
||||||
provenance={
|
provenance={
|
||||||
|
**dict(draft.provenance),
|
||||||
"draft_id": draft_id,
|
"draft_id": draft_id,
|
||||||
"proposal_kind": draft.proposal_kind,
|
"proposal_kind": draft.proposal_kind,
|
||||||
"trigger_run_id": draft.trigger_run_id,
|
"trigger_run_id": draft.trigger_run_id,
|
||||||
@ -47,6 +49,12 @@ class SkillPublisher:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.store.write_skill_version(version, content)
|
self.store.write_skill_version(version, content)
|
||||||
|
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._copy_uploaded_supporting_files(draft, next_version)
|
||||||
self.store.set_current_version(skill_name, next_version)
|
self.store.set_current_version(skill_name, next_version)
|
||||||
|
|
||||||
@ -194,6 +202,25 @@ class SkillPublisher:
|
|||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copyfile(source, target)
|
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:
|
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||||
draft = self.store.read_draft(skill_name, draft_id)
|
draft = self.store.read_draft(skill_name, draft_id)
|
||||||
if draft is None:
|
if draft is None:
|
||||||
|
|||||||
@ -180,6 +180,7 @@ class SkillDraft:
|
|||||||
status: str = SkillReviewState.DRAFT.value
|
status: str = SkillReviewState.DRAFT.value
|
||||||
evidence_refs: list[dict[str, Any]] = field(default_factory=list)
|
evidence_refs: list[dict[str, Any]] = field(default_factory=list)
|
||||||
proposal_kind: str = "revise_skill"
|
proposal_kind: str = "revise_skill"
|
||||||
|
provenance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@ -196,6 +197,7 @@ class SkillDraft:
|
|||||||
"status": self.status,
|
"status": self.status,
|
||||||
"evidence_refs": list(self.evidence_refs),
|
"evidence_refs": list(self.evidence_refs),
|
||||||
"proposal_kind": self.proposal_kind,
|
"proposal_kind": self.proposal_kind,
|
||||||
|
"provenance": dict(self.provenance),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -214,6 +216,7 @@ class SkillDraft:
|
|||||||
status=str(payload.get("status") or SkillReviewState.DRAFT.value),
|
status=str(payload.get("status") or SkillReviewState.DRAFT.value),
|
||||||
evidence_refs=list(payload.get("evidence_refs") or []),
|
evidence_refs=list(payload.get("evidence_refs") or []),
|
||||||
proposal_kind=str(payload.get("proposal_kind") or "revise_skill"),
|
proposal_kind=str(payload.get("proposal_kind") or "revise_skill"),
|
||||||
|
provenance=dict(payload.get("provenance") or {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
239
app-instance/backend/tests/unit/test_plugin_skill_learning.py
Normal file
239
app-instance/backend/tests/unit/test_plugin_skill_learning.py
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||||
|
from beaver.engine.providers.factory import ProviderBundle
|
||||||
|
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
|
||||||
|
from beaver.memory.runs import RunMemoryStore
|
||||||
|
from beaver.memory.skills import SkillLearningCandidate, SkillLearningStore
|
||||||
|
from beaver.plugins.discovery import discover_plugins
|
||||||
|
from beaver.plugins.skills import PluginManager
|
||||||
|
from beaver.plugins.state import PluginStateStore
|
||||||
|
from beaver.plugins.tree_merge import merge_supporting_file_trees
|
||||||
|
from beaver.skills.drafts import DraftService
|
||||||
|
from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningService
|
||||||
|
from beaver.skills.learning.safety import SkillDraftSafetyChecker
|
||||||
|
from beaver.skills.publisher import SkillPublisher
|
||||||
|
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpecStore
|
||||||
|
|
||||||
|
|
||||||
|
class CountingProvider(LLMProvider):
|
||||||
|
def __init__(self, content: str = "{}") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.content = content
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
self.calls.append({"messages": messages, "model": model})
|
||||||
|
return LLMResponse(content=self.content)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return "stub"
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(provider: CountingProvider) -> ProviderBundle:
|
||||||
|
runtime = SimpleNamespace(model="stub", provider_name="stub")
|
||||||
|
return ProviderBundle(main_runtime=runtime, main_provider=provider) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(root: Path, *, version: str = "1.0.0", body: str = "# Comic\n\nV1.\n", template: str = "v1") -> Path:
|
||||||
|
plugin_root = root / "baoyu-comic"
|
||||||
|
skill_root = plugin_root / "skills" / "baoyu-comic"
|
||||||
|
skill_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_root / "SKILL.md").write_text(
|
||||||
|
"---\nname: baoyu-comic\ndescription: Comic workflow\ntools: []\n---\n\n" + body,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(skill_root / "templates").mkdir(exist_ok=True)
|
||||||
|
(skill_root / "templates" / "panel.txt").write_text(template, encoding="utf-8")
|
||||||
|
(plugin_root / "beaver.plugin.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "baoyu-comic",
|
||||||
|
"name": "Baoyu Comic",
|
||||||
|
"version": version,
|
||||||
|
"skills": [{"name": "baoyu-comic", "path": "skills/baoyu-comic"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return plugin_root
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_plugin(plugin_root: Path, *, version: str, body: str, template: str) -> None:
|
||||||
|
manifest_path = plugin_root / "beaver.plugin.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["version"] = version
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
skill_root = plugin_root / "skills" / "baoyu-comic"
|
||||||
|
(skill_root / "SKILL.md").write_text(
|
||||||
|
"---\nname: baoyu-comic\ndescription: Comic workflow\ntools: []\n---\n\n" + body,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(skill_root / "templates" / "panel.txt").write_text(template, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(workspace: Path) -> tuple[PluginManager, SkillSpecStore, SkillLearningStore]:
|
||||||
|
discovery = discover_plugins(workspace, search_paths=[])
|
||||||
|
skill_store = SkillSpecStore(workspace)
|
||||||
|
learning_store = SkillLearningStore(workspace / "memory" / "skills")
|
||||||
|
manager = PluginManager(
|
||||||
|
workspace=workspace,
|
||||||
|
manifests=discovery.manifests,
|
||||||
|
discovery_errors=discovery.errors,
|
||||||
|
state_store=PluginStateStore(workspace),
|
||||||
|
skill_store=skill_store,
|
||||||
|
learning_store=learning_store,
|
||||||
|
publisher=SkillPublisher(skill_store),
|
||||||
|
safety_checker=SkillDraftSafetyChecker(),
|
||||||
|
write_lock=WorkspaceWriteLock(workspace),
|
||||||
|
)
|
||||||
|
return manager, skill_store, learning_store
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_draft_from_legacy_payload_has_empty_provenance() -> None:
|
||||||
|
draft = SkillDraft.from_dict(
|
||||||
|
{
|
||||||
|
"draft_id": "draft-1",
|
||||||
|
"skill_name": "debug",
|
||||||
|
"proposed_content": "# Debug\n",
|
||||||
|
"created_at": "now",
|
||||||
|
"created_by": "tester",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert draft.provenance == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fast_forward_plugin_update_synthesis_uses_exact_upstream_without_llm(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
plugin_root = _write_plugin(workspace / "plugins")
|
||||||
|
manager, skill_store, learning_store = _manager(workspace)
|
||||||
|
manager.enable("baoyu-comic")
|
||||||
|
_rewrite_plugin(plugin_root, version="1.1.0", body="# Comic\n\nV2.\n", template="v2")
|
||||||
|
_manager(workspace)[0].sync_enabled()
|
||||||
|
candidate = learning_store.list_learning_candidates()[0]
|
||||||
|
provider = CountingProvider()
|
||||||
|
service = SkillLearningService(
|
||||||
|
run_store=RunMemoryStore(workspace / "memory" / "runs"),
|
||||||
|
learning_store=learning_store,
|
||||||
|
draft_service=DraftService(skill_store),
|
||||||
|
evidence_selector=EvidenceSelector(RunMemoryStore(workspace / "memory" / "runs")),
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = asyncio.run(service.synthesize_draft(candidate.candidate_id, _bundle(provider)))
|
||||||
|
upstream = skill_store.read_upstream_snapshot(
|
||||||
|
"baoyu-comic",
|
||||||
|
"baoyu-comic",
|
||||||
|
candidate.evidence["new_upstream_tree_hash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert upstream is not None
|
||||||
|
assert draft.proposal_kind == "plugin_skill_update"
|
||||||
|
assert draft.proposed_content == "# Comic\n\nV2."
|
||||||
|
assert draft.base_version == "v0001"
|
||||||
|
assert draft.provenance["merge_mode"] == "fast_forward"
|
||||||
|
assert draft.provenance["new_upstream_tree_hash"] == upstream.snapshot.skill_tree_hash
|
||||||
|
assert provider.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_plugin_update_materializes_referenced_supporting_files(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
plugin_root = _write_plugin(workspace / "plugins", template="v1")
|
||||||
|
manager, skill_store, learning_store = _manager(workspace)
|
||||||
|
manager.enable("baoyu-comic")
|
||||||
|
_rewrite_plugin(plugin_root, version="1.1.0", body="# Comic\n\nV2.\n", template="v2")
|
||||||
|
_manager(workspace)[0].sync_enabled()
|
||||||
|
candidate = learning_store.list_learning_candidates()[0]
|
||||||
|
service = SkillLearningService(
|
||||||
|
run_store=RunMemoryStore(workspace / "memory" / "runs"),
|
||||||
|
learning_store=learning_store,
|
||||||
|
draft_service=DraftService(skill_store),
|
||||||
|
evidence_selector=EvidenceSelector(RunMemoryStore(workspace / "memory" / "runs")),
|
||||||
|
)
|
||||||
|
draft = asyncio.run(service.synthesize_draft(candidate.candidate_id, _bundle(CountingProvider())))
|
||||||
|
draft.status = SkillReviewState.APPROVED.value
|
||||||
|
skill_store.write_draft(draft)
|
||||||
|
|
||||||
|
version = SkillPublisher(skill_store).publish("baoyu-comic", draft.draft_id, publisher="tester")
|
||||||
|
|
||||||
|
assert version.version == "v0002"
|
||||||
|
assert (workspace / "skills" / "baoyu-comic" / "versions" / "v0002" / "templates" / "panel.txt").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_supporting_file_merge_adopts_upstream_when_local_is_unchanged() -> None:
|
||||||
|
plan = merge_supporting_file_trees(
|
||||||
|
base={"a.txt": {"content_hash": "A", "executable": False}},
|
||||||
|
local={"a.txt": {"content_hash": "A", "executable": False}},
|
||||||
|
upstream={"a.txt": {"content_hash": "U", "executable": False}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.files["a.txt"].source == "upstream"
|
||||||
|
assert plan.conflicts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_supporting_file_merge_blocks_divergent_edits() -> None:
|
||||||
|
plan = merge_supporting_file_trees(
|
||||||
|
base={"a.txt": {"content_hash": "A", "executable": False}},
|
||||||
|
local={"a.txt": {"content_hash": "L", "executable": False}},
|
||||||
|
upstream={"a.txt": {"content_hash": "U", "executable": False}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.conflicts[0].path == "a.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_way_synthesizer_prompt_labels_all_inputs() -> None:
|
||||||
|
provider = CountingProvider(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"frontmatter": {"name": "baoyu-comic", "description": "Comic workflow", "tools": []},
|
||||||
|
"content": "# Baoyu Comic\n\nMerged.",
|
||||||
|
"change_reason": "Adopt upstream while preserving local review.",
|
||||||
|
"preserved_local_sections": ["Review"],
|
||||||
|
"adopted_upstream_sections": ["Panel Layout"],
|
||||||
|
"resolved_conflicts": ["Output ordering"],
|
||||||
|
"dropped_sections": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def run_case() -> dict:
|
||||||
|
return await SkillDraftSynthesizer().synthesize_plugin_update(
|
||||||
|
SkillLearningCandidate(
|
||||||
|
candidate_id="candidate",
|
||||||
|
kind="plugin_skill_update",
|
||||||
|
source_run_ids=[],
|
||||||
|
source_session_ids=[],
|
||||||
|
related_skill_names=["baoyu-comic"],
|
||||||
|
reason="merge",
|
||||||
|
),
|
||||||
|
EvidenceSelector(RunMemoryStore(Path("/tmp/unused-runs"))).build_evidence_packet([], []),
|
||||||
|
provider,
|
||||||
|
"stub",
|
||||||
|
old_upstream={"content": "# Old\n"},
|
||||||
|
current_local={"content": "# Local\n"},
|
||||||
|
new_upstream={"content": "# New\n"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(run_case())
|
||||||
|
prompt = provider.calls[0]["messages"][1]["content"]
|
||||||
|
|
||||||
|
assert "OLD UPSTREAM" in prompt
|
||||||
|
assert "CURRENT LOCAL" in prompt
|
||||||
|
assert "NEW UPSTREAM" in prompt
|
||||||
|
assert payload["preserved_local_sections"] == ["Review"]
|
||||||
|
assert payload["adopted_upstream_sections"] == ["Panel Layout"]
|
||||||
@ -234,3 +234,58 @@ def test_sync_enabled_supersedes_stale_pending_update(tmp_path: Path) -> None:
|
|||||||
assert len(candidates) == 2
|
assert len(candidates) == 2
|
||||||
assert {candidate.status for candidate in candidates} == {"open", "superseded"}
|
assert {candidate.status for candidate in candidates} == {"open", "superseded"}
|
||||||
assert any(candidate.candidate_id != first_candidate.candidate_id for candidate in candidates)
|
assert any(candidate.candidate_id != first_candidate.candidate_id for candidate in candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_leaves_skill_active_and_suppresses_update_candidates(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
plugin_root = _write_skill_plugin(workspace / "plugins")
|
||||||
|
_manager(workspace).enable("baoyu-comic")
|
||||||
|
_manager(workspace).pause("baoyu-comic")
|
||||||
|
_rewrite_plugin_version(plugin_root, version="1.1.0", skill_text="# Baoyu Comic\n\nPaused update.\n")
|
||||||
|
|
||||||
|
_manager(workspace).sync_enabled()
|
||||||
|
|
||||||
|
assert SkillSpecStore(workspace).get_skill_spec("baoyu-comic").status == "active" # type: ignore[union-attr]
|
||||||
|
assert SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_reconciles_and_syncs_updates(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
plugin_root = _write_skill_plugin(workspace / "plugins")
|
||||||
|
_manager(workspace).enable("baoyu-comic")
|
||||||
|
_manager(workspace).pause("baoyu-comic")
|
||||||
|
_rewrite_plugin_version(plugin_root, version="1.1.0", skill_text="# Baoyu Comic\n\nResume update.\n")
|
||||||
|
|
||||||
|
state = _manager(workspace).resume("baoyu-comic")
|
||||||
|
|
||||||
|
assert state.status == "update_pending"
|
||||||
|
assert SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_plugin_disables_linked_skills_without_deleting_versions(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
_write_skill_plugin(workspace / "plugins")
|
||||||
|
_manager(workspace).enable("baoyu-comic")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="disable_linked_skills"):
|
||||||
|
_manager(workspace).disable("baoyu-comic", disable_linked_skills=False)
|
||||||
|
state = _manager(workspace).disable("baoyu-comic", disable_linked_skills=True)
|
||||||
|
|
||||||
|
spec = SkillSpecStore(workspace).get_skill_spec("baoyu-comic")
|
||||||
|
assert state.enabled is False
|
||||||
|
assert spec is not None and spec.status == "disabled"
|
||||||
|
assert SkillSpecStore(workspace).read_published_skill("baoyu-comic", "v0001") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_detaches_plugin_binding_and_keeps_skill_active(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
_write_skill_plugin(workspace / "plugins")
|
||||||
|
_manager(workspace).enable("baoyu-comic")
|
||||||
|
|
||||||
|
spec = _manager(workspace).adopt("baoyu-comic", "baoyu-comic")
|
||||||
|
state = PluginStateStore(workspace).get_plugin("baoyu-comic")
|
||||||
|
|
||||||
|
assert spec.source_kind == "managed"
|
||||||
|
assert spec.status == "active"
|
||||||
|
assert "adopted_from_plugin:baoyu-comic" in spec.lineage
|
||||||
|
assert state is not None and "baoyu-comic" not in state.skills
|
||||||
|
|||||||
@ -222,3 +222,80 @@ def test_publish_blocks_failed_preservation_report(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="preservation"):
|
with pytest.raises(ValueError, match="preservation"):
|
||||||
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_blocks_plugin_three_way_without_plugin_preservation_report(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(tmp_path)
|
||||||
|
draft = pipeline.draft_service.create_plugin_update_draft(
|
||||||
|
skill_name="plugin-skill",
|
||||||
|
base_version="v0001",
|
||||||
|
proposed_content="# Plugin\n\nDo it.",
|
||||||
|
proposed_frontmatter={"description": "plugin", "tools": []},
|
||||||
|
created_by="test",
|
||||||
|
reason="plugin update",
|
||||||
|
provenance={"merge_mode": "three_way"},
|
||||||
|
)
|
||||||
|
pipeline.learning_store.write_eval_report(
|
||||||
|
SkillDraftEvalReport(
|
||||||
|
report_id="eval-plugin",
|
||||||
|
skill_name=draft.skill_name,
|
||||||
|
draft_id=draft.draft_id,
|
||||||
|
candidate_id="candidate-1",
|
||||||
|
passed=True,
|
||||||
|
baseline_score_avg=0.8,
|
||||||
|
candidate_score_avg=0.9,
|
||||||
|
score_delta=0.1,
|
||||||
|
regression_count=0,
|
||||||
|
improved_count=1,
|
||||||
|
unchanged_count=0,
|
||||||
|
confidence="medium",
|
||||||
|
mode="replay",
|
||||||
|
eval_version="replay-v1",
|
||||||
|
preservation_report={"passed": True, "mode": "ordinary"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
|
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="three-way preservation"):
|
||||||
|
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_blocks_plugin_update_with_unresolved_supporting_file_conflicts(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(tmp_path)
|
||||||
|
draft = pipeline.draft_service.create_plugin_update_draft(
|
||||||
|
skill_name="plugin-skill",
|
||||||
|
base_version="v0001",
|
||||||
|
proposed_content="# Plugin\n\nDo it.",
|
||||||
|
proposed_frontmatter={"description": "plugin", "tools": []},
|
||||||
|
created_by="test",
|
||||||
|
reason="plugin update",
|
||||||
|
provenance={
|
||||||
|
"merge_mode": "three_way",
|
||||||
|
"supporting_file_plan": {"conflicts": [{"path": "a.txt", "reason": "diverged"}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pipeline.learning_store.write_eval_report(
|
||||||
|
SkillDraftEvalReport(
|
||||||
|
report_id="eval-plugin-conflict",
|
||||||
|
skill_name=draft.skill_name,
|
||||||
|
draft_id=draft.draft_id,
|
||||||
|
candidate_id="candidate-1",
|
||||||
|
passed=True,
|
||||||
|
baseline_score_avg=0.8,
|
||||||
|
candidate_score_avg=0.9,
|
||||||
|
score_delta=0.1,
|
||||||
|
regression_count=0,
|
||||||
|
improved_count=1,
|
||||||
|
unchanged_count=0,
|
||||||
|
confidence="medium",
|
||||||
|
mode="replay",
|
||||||
|
eval_version="replay-v1",
|
||||||
|
preservation_report={"passed": True, "mode": "plugin_three_way", "unresolved_conflicts": []},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
|
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="supporting-file conflicts"):
|
||||||
|
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from beaver.skills.learning.preservation import check_preservation
|
from beaver.skills.learning.preservation import check_plugin_merge_preservation, check_preservation
|
||||||
|
|
||||||
|
|
||||||
def test_preservation_passes_when_base_sections_remain() -> None:
|
def test_preservation_passes_when_base_sections_remain() -> None:
|
||||||
@ -25,3 +25,29 @@ def test_preservation_flags_dropped_section() -> None:
|
|||||||
assert report["passed"] is False
|
assert report["passed"] is False
|
||||||
assert report["risk_level"] == "high"
|
assert report["risk_level"] == "high"
|
||||||
assert "Safety" in report["dropped_sections"]
|
assert "Safety" in report["dropped_sections"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_merge_preservation_checks_local_and_upstream_and_conflicts() -> None:
|
||||||
|
report = check_plugin_merge_preservation(
|
||||||
|
local_content="# Local\n\n## Review\n\nKeep review.\n",
|
||||||
|
upstream_content="# Upstream\n\n## Safety\n\nDo not leak secrets.\n",
|
||||||
|
draft_content="# Draft\n\n## Review\n\nKeep review.\n\n## Safety\n\nDo not leak secrets.\n",
|
||||||
|
merge_decisions={"resolved_conflicts": ["ordering"], "unresolved_conflicts": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report["mode"] == "plugin_three_way"
|
||||||
|
assert report["passed"] is True
|
||||||
|
assert report["local"]["passed"] is True
|
||||||
|
assert report["upstream"]["passed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_merge_preservation_fails_unresolved_conflicts() -> None:
|
||||||
|
report = check_plugin_merge_preservation(
|
||||||
|
local_content="# Local\n\n## Review\n\nKeep review.\n",
|
||||||
|
upstream_content="# Upstream\n\n## Safety\n\nDo not leak secrets.\n",
|
||||||
|
draft_content="# Draft\n\n## Review\n\nKeep review.\n",
|
||||||
|
merge_decisions={"unresolved_conflicts": ["Safety conflict"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report["passed"] is False
|
||||||
|
assert report["unresolved_conflicts"] == ["Safety conflict"]
|
||||||
|
|||||||
Reference in New Issue
Block a user