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

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