feat(beaver): 完成Task Team功能v1实现,重构后端架构支持统一内核
新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证 (通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。 实现Agent Team v1协调器,支持sequence/parallel/dag执行策略, sub-agent复用主AgentLoop,每个run使用独立memory snapshot。 建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期, 通过Task验证通过且用户满意才生成学习候选。 重构目录结构,移除third_party依赖,建立统一engine内核, 所有agent共享运行时基础组件。 更新ContextBuilder清理provider消息字段,增强SkillContext版本管理, 集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
This commit is contained in:
@ -83,11 +83,21 @@ class SkillAssembler:
|
||||
|
||||
activated_skills: list[SkillContext] = []
|
||||
for name in selected_names:
|
||||
raw_content = self.loader.load_skill(name)
|
||||
record = self.loader.get_skill_record(name)
|
||||
raw_content = self.loader.load_published_skill(name)
|
||||
content = strip_frontmatter(raw_content).strip() if raw_content else ""
|
||||
if not content:
|
||||
continue
|
||||
activated_skills.append(SkillContext(name=name, content=content))
|
||||
activated_skills.append(
|
||||
SkillContext(
|
||||
name=name,
|
||||
content=content,
|
||||
version=record.version if record is not None else "legacy",
|
||||
content_hash=record.content_hash or "" if record is not None else "",
|
||||
activation_reason="llm_selected",
|
||||
tool_hints=list(record.tool_hints) if record is not None else [],
|
||||
)
|
||||
)
|
||||
|
||||
return SkillAssemblyResult(activated_skills=activated_skills)
|
||||
|
||||
|
||||
@ -1,5 +1,18 @@
|
||||
"""Skill catalog and indexing."""
|
||||
|
||||
from .loader import SkillRecord, SkillsLoader
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["SkillRecord", "SkillsLoader"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in {"SkillRecord", "SkillsLoader"}:
|
||||
from .loader import SkillRecord, SkillsLoader
|
||||
|
||||
return {
|
||||
"SkillRecord": SkillRecord,
|
||||
"SkillsLoader": SkillsLoader,
|
||||
}[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@ -17,11 +17,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from beaver.skills.specs.storage import SkillSpecStore
|
||||
|
||||
from .utils import (
|
||||
check_requirements,
|
||||
escape_xml,
|
||||
@ -39,6 +41,13 @@ class SkillRecord:
|
||||
name: str
|
||||
path: Path
|
||||
source: str
|
||||
version: str = "legacy"
|
||||
content_hash: str | None = None
|
||||
source_kind: str = "legacy"
|
||||
status: str = "active"
|
||||
tool_hints: list[str] = field(default_factory=list)
|
||||
frontmatter: dict[str, Any] = field(default_factory=dict)
|
||||
description: str = ""
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
@ -50,11 +59,13 @@ class SkillsLoader:
|
||||
*,
|
||||
builtin_skills_dir: str | Path | None = None,
|
||||
extra_dirs: list[str | Path] | None = None,
|
||||
skill_store: SkillSpecStore | None = None,
|
||||
) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.workspace_skills = self.workspace / "skills"
|
||||
self.builtin_skills = Path(builtin_skills_dir) if builtin_skills_dir is not None else Path(__file__).resolve().parent.parent / "builtin"
|
||||
self.extra_dirs = [Path(item) for item in (extra_dirs or [])]
|
||||
self.skill_store = skill_store or SkillSpecStore(self.workspace)
|
||||
|
||||
def list_skills(self, *, filter_unavailable: bool = True) -> list[SkillRecord]:
|
||||
"""列出当前可见的 skills。
|
||||
@ -67,14 +78,19 @@ class SkillsLoader:
|
||||
重名 skill 只保留优先级更高的那一个。
|
||||
"""
|
||||
|
||||
ordered_roots: list[tuple[str, Path]] = [
|
||||
("workspace", self.workspace_skills),
|
||||
*[("plugin", path) for path in self.extra_dirs],
|
||||
("builtin", self.builtin_skills),
|
||||
]
|
||||
found: dict[str, SkillRecord] = {}
|
||||
|
||||
for source, root in ordered_roots:
|
||||
for record in self.list_published_skills():
|
||||
if record.name in found:
|
||||
continue
|
||||
if filter_unavailable and not self._record_available(record):
|
||||
continue
|
||||
found[record.name] = record
|
||||
|
||||
for source, root in [
|
||||
*[("plugin", path) for path in self.extra_dirs],
|
||||
("builtin", self.builtin_skills),
|
||||
]:
|
||||
if not root.exists():
|
||||
continue
|
||||
for skill_dir in root.iterdir():
|
||||
@ -84,12 +100,62 @@ class SkillsLoader:
|
||||
name = skill_dir.name
|
||||
if name in found:
|
||||
continue
|
||||
record = SkillRecord(name=name, path=skill_file, source=source)
|
||||
frontmatter, body = parse_frontmatter(skill_file.read_text(encoding="utf-8"))
|
||||
normalized_frontmatter = dict(frontmatter)
|
||||
record = SkillRecord(
|
||||
name=name,
|
||||
path=skill_file,
|
||||
source=source,
|
||||
version="legacy",
|
||||
source_kind=source,
|
||||
tool_hints=self._coerce_tool_names(frontmatter.get("tools")),
|
||||
frontmatter=normalized_frontmatter,
|
||||
description=str(frontmatter.get("description") or summarize_body(body) or name),
|
||||
)
|
||||
if filter_unavailable and not self._record_available(record):
|
||||
continue
|
||||
found[name] = record
|
||||
return list(found.values())
|
||||
|
||||
def list_published_skills(self, *, filter_unavailable: bool = True) -> list[SkillRecord]:
|
||||
"""只列 workspace 中正式 published 的 skill catalog。"""
|
||||
|
||||
results: list[SkillRecord] = []
|
||||
for name in self.skill_store.list_published_skill_names():
|
||||
loaded = self.skill_store.read_published_skill(name)
|
||||
if loaded is None:
|
||||
continue
|
||||
if loaded.version.version == "legacy":
|
||||
path = self.workspace_skills / name / "SKILL.md"
|
||||
else:
|
||||
path = self.workspace_skills / name / "versions" / loaded.version.version / "SKILL.md"
|
||||
record = SkillRecord(
|
||||
name=name,
|
||||
path=path,
|
||||
source="workspace",
|
||||
version=loaded.version.version,
|
||||
content_hash=loaded.version.content_hash,
|
||||
source_kind=str(loaded.version.provenance.get("source_kind") or "workspace"),
|
||||
status=str(loaded.version.review_state or "published"),
|
||||
tool_hints=list(loaded.version.tool_hints),
|
||||
frontmatter=dict(loaded.version.frontmatter),
|
||||
description=str(loaded.version.frontmatter.get("description") or loaded.version.summary or name),
|
||||
)
|
||||
if filter_unavailable and not self._record_available(record):
|
||||
continue
|
||||
results.append(record)
|
||||
return results
|
||||
|
||||
def get_current_version(self, name: str) -> str | None:
|
||||
record = self._find_record(name)
|
||||
return record.version if record is not None else None
|
||||
|
||||
def load_published_skill(self, name: str, version: str | None = None) -> str | None:
|
||||
loaded = self.skill_store.read_published_skill(name, version=version)
|
||||
if loaded is not None:
|
||||
return loaded.content
|
||||
return self.load_skill(name)
|
||||
|
||||
def load_skill(self, name: str) -> str | None:
|
||||
"""按名称加载 skill 原始内容。"""
|
||||
|
||||
@ -106,6 +172,9 @@ class SkillsLoader:
|
||||
def get_skill_metadata(self, name: str) -> dict[str, Any] | None:
|
||||
"""读取 skill frontmatter 元数据。"""
|
||||
|
||||
record = self._find_record(name)
|
||||
if record is not None and record.frontmatter:
|
||||
return dict(record.frontmatter)
|
||||
content = self.load_skill(name)
|
||||
if content is None:
|
||||
return None
|
||||
@ -125,6 +194,10 @@ class SkillsLoader:
|
||||
- 兼容 metadata JSON blob 里的 `tools`
|
||||
"""
|
||||
|
||||
record = self._find_record(name)
|
||||
if record is not None and record.tool_hints:
|
||||
return list(record.tool_hints)
|
||||
|
||||
frontmatter = self.get_skill_metadata(name) or {}
|
||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||
names = [
|
||||
@ -143,7 +216,7 @@ class SkillsLoader:
|
||||
|
||||
sections: list[str] = []
|
||||
for name in skill_names:
|
||||
content = self.load_skill(name)
|
||||
content = self.load_published_skill(name)
|
||||
if not content:
|
||||
continue
|
||||
body = strip_frontmatter(content).strip()
|
||||
@ -167,14 +240,15 @@ class SkillsLoader:
|
||||
|
||||
lines = ["<skills>"]
|
||||
for record in skills:
|
||||
frontmatter = self.get_skill_metadata(record.name) or {}
|
||||
frontmatter = record.frontmatter or self.get_skill_metadata(record.name) or {}
|
||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||
available = check_requirements(meta_blob)
|
||||
description = frontmatter.get("description") or record.name
|
||||
description = frontmatter.get("description") or record.description or record.name
|
||||
load_hint = f'Use skill_view(name="{record.name}") to load the full skill.'
|
||||
lines.append(f' <skill available="{str(available).lower()}">')
|
||||
lines.append(f" <name>{escape_xml(record.name)}</name>")
|
||||
lines.append(f" <description>{escape_xml(description)}</description>")
|
||||
lines.append(f" <version>{escape_xml(record.version)}</version>")
|
||||
lines.append(f" <load_hint>{escape_xml(load_hint)}</load_hint>")
|
||||
support_files = self.list_skill_supporting_files(record.name)
|
||||
if support_files:
|
||||
@ -205,10 +279,10 @@ class SkillsLoader:
|
||||
|
||||
candidates: list[dict[str, str]] = []
|
||||
for record in self.list_skills(filter_unavailable=True):
|
||||
frontmatter = self.get_skill_metadata(record.name) or {}
|
||||
description = str(frontmatter.get("description") or "").strip()
|
||||
frontmatter = record.frontmatter or self.get_skill_metadata(record.name) or {}
|
||||
description = str(frontmatter.get("description") or record.description or "").strip()
|
||||
if not description:
|
||||
raw_content = self.load_skill(record.name) or ""
|
||||
raw_content = self.load_published_skill(record.name) or ""
|
||||
body = strip_frontmatter(raw_content).strip()
|
||||
if body:
|
||||
description = " ".join(body.splitlines()[:3])[:240].strip()
|
||||
@ -216,6 +290,8 @@ class SkillsLoader:
|
||||
{
|
||||
"name": record.name,
|
||||
"description": description or record.name,
|
||||
"version": record.version,
|
||||
"content_hash": record.content_hash or "",
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
@ -249,7 +325,7 @@ class SkillsLoader:
|
||||
if record is None:
|
||||
return None
|
||||
if not self._record_available(record):
|
||||
frontmatter = self.get_skill_metadata(name) or {}
|
||||
frontmatter = record.frontmatter or self.get_skill_metadata(name) or {}
|
||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||
missing = get_missing_requirements(meta_blob)
|
||||
detail = f" Missing requirements: {missing}." if missing else ""
|
||||
@ -274,7 +350,7 @@ class SkillsLoader:
|
||||
|
||||
result: list[str] = []
|
||||
for record in self.list_skills(filter_unavailable=True):
|
||||
frontmatter = self.get_skill_metadata(record.name) or {}
|
||||
frontmatter = record.frontmatter or self.get_skill_metadata(record.name) or {}
|
||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||
if meta_blob.get("always") or str(frontmatter.get("always", "")).lower() == "true":
|
||||
result.append(record.name)
|
||||
@ -326,3 +402,8 @@ class SkillsLoader:
|
||||
if record is None:
|
||||
return False
|
||||
return self._record_available(record)
|
||||
|
||||
|
||||
def summarize_body(body: str) -> str:
|
||||
cleaned = " ".join(line.strip() for line in body.splitlines()[:3] if line.strip()).strip()
|
||||
return cleaned[:240]
|
||||
|
||||
@ -1,2 +1,6 @@
|
||||
"""Draft skills generated before review."""
|
||||
"""Skill draft services."""
|
||||
|
||||
from .service import DraftService
|
||||
|
||||
__all__ = ["DraftService"]
|
||||
|
||||
131
app-instance/backend/beaver/skills/drafts/service.py
Normal file
131
app-instance/backend/beaver/skills/drafts/service.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""Draft lifecycle for Beaver skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from beaver.skills.specs import SkillDraft, SkillSpecStore
|
||||
|
||||
|
||||
class DraftService:
|
||||
def __init__(self, store: SkillSpecStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def create_new_skill_draft(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
proposed_content: str,
|
||||
proposed_frontmatter: dict,
|
||||
created_by: str,
|
||||
reason: str,
|
||||
trigger_run_id: str | None = None,
|
||||
trigger_session_id: str | None = None,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillDraft:
|
||||
draft = SkillDraft(
|
||||
draft_id=uuid4().hex,
|
||||
skill_name=skill_name,
|
||||
base_version=None,
|
||||
proposed_content=proposed_content,
|
||||
proposed_frontmatter=dict(proposed_frontmatter),
|
||||
created_at=_utc_now(),
|
||||
created_by=created_by,
|
||||
trigger_run_id=trigger_run_id,
|
||||
trigger_session_id=trigger_session_id,
|
||||
reason=reason,
|
||||
evidence_refs=list(evidence_refs or []),
|
||||
proposal_kind="new_skill",
|
||||
)
|
||||
self.store.write_draft(draft)
|
||||
return draft
|
||||
|
||||
def create_revision_draft(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
base_version: str | None,
|
||||
proposed_content: str,
|
||||
proposed_frontmatter: dict,
|
||||
created_by: str,
|
||||
reason: str,
|
||||
trigger_run_id: str | None = None,
|
||||
trigger_session_id: str | None = None,
|
||||
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,
|
||||
trigger_run_id=trigger_run_id,
|
||||
trigger_session_id=trigger_session_id,
|
||||
reason=reason,
|
||||
evidence_refs=list(evidence_refs or []),
|
||||
proposal_kind="revise_skill",
|
||||
)
|
||||
self.store.write_draft(draft)
|
||||
return draft
|
||||
|
||||
def create_merge_draft(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
base_version: str | None,
|
||||
proposed_content: str,
|
||||
proposed_frontmatter: dict,
|
||||
created_by: str,
|
||||
reason: str,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillDraft:
|
||||
draft = self.create_revision_draft(
|
||||
skill_name=skill_name,
|
||||
base_version=base_version,
|
||||
proposed_content=proposed_content,
|
||||
proposed_frontmatter=proposed_frontmatter,
|
||||
created_by=created_by,
|
||||
reason=reason,
|
||||
evidence_refs=evidence_refs,
|
||||
)
|
||||
draft.proposal_kind = "merge_skills"
|
||||
self.store.write_draft(draft)
|
||||
return draft
|
||||
|
||||
def create_retire_proposal(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
base_version: str | None,
|
||||
created_by: str,
|
||||
reason: str,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillDraft:
|
||||
draft = SkillDraft(
|
||||
draft_id=uuid4().hex,
|
||||
skill_name=skill_name,
|
||||
base_version=base_version,
|
||||
proposed_content="",
|
||||
proposed_frontmatter={},
|
||||
created_at=_utc_now(),
|
||||
created_by=created_by,
|
||||
reason=reason,
|
||||
evidence_refs=list(evidence_refs or []),
|
||||
proposal_kind="retire_skill",
|
||||
)
|
||||
self.store.write_draft(draft)
|
||||
return draft
|
||||
|
||||
def list_drafts(self, skill_name: str | None = None) -> list[SkillDraft]:
|
||||
return self.store.list_drafts(skill_name)
|
||||
|
||||
def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None:
|
||||
return self.store.read_draft(skill_name, draft_id)
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
24
app-instance/backend/beaver/skills/learning/__init__.py
Normal file
24
app-instance/backend/beaver/skills/learning/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Skill learning loop helpers."""
|
||||
|
||||
from .evidence import EvidencePacket, EvidenceSelector
|
||||
from .eval import SkillDraftEvaluator
|
||||
from .missing_skill import MissingSkillDraftResult, MissingSkillSynthesizer
|
||||
from .pipeline import SkillLearningPipelineService
|
||||
from .service import RunReceiptContext, SkillLearningService
|
||||
from .synthesizer import SkillDraftSynthesizer
|
||||
from .worker import SkillLearningWorker, SkillLearningWorkerConfig, SkillLearningWorkerResult
|
||||
|
||||
__all__ = [
|
||||
"EvidencePacket",
|
||||
"EvidenceSelector",
|
||||
"SkillDraftEvaluator",
|
||||
"MissingSkillDraftResult",
|
||||
"MissingSkillSynthesizer",
|
||||
"RunReceiptContext",
|
||||
"SkillLearningPipelineService",
|
||||
"SkillDraftSynthesizer",
|
||||
"SkillLearningService",
|
||||
"SkillLearningWorker",
|
||||
"SkillLearningWorkerConfig",
|
||||
"SkillLearningWorkerResult",
|
||||
]
|
||||
121
app-instance/backend/beaver/skills/learning/eval.py
Normal file
121
app-instance/backend/beaver/skills/learning/eval.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Lightweight replay/eval reports for skill drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
from beaver.memory.runs import RunMemoryStore
|
||||
from beaver.memory.skills import SkillDraftEvalReport, SkillLearningCandidate
|
||||
from beaver.skills.specs import SkillDraft
|
||||
|
||||
|
||||
class SkillDraftEvaluator:
|
||||
"""Builds a bounded eval report without writing user-visible sessions."""
|
||||
|
||||
def __init__(self, run_store: RunMemoryStore) -> None:
|
||||
self.run_store = run_store
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
*,
|
||||
candidate: SkillLearningCandidate,
|
||||
draft: SkillDraft,
|
||||
provider_bundle: ProviderBundle | None,
|
||||
) -> SkillDraftEvalReport:
|
||||
if provider_bundle is None or provider_bundle.main_provider is None:
|
||||
return self._skipped(candidate, draft)
|
||||
|
||||
runs_by_id = {record.run_id: record for record in self.run_store.list_runs()}
|
||||
cases: list[dict] = []
|
||||
for run_id in candidate.source_run_ids[:8]:
|
||||
record = runs_by_id.get(run_id)
|
||||
if record is None:
|
||||
continue
|
||||
baseline = _score_from_validation(record.validation_result, record.success)
|
||||
candidate_score = _candidate_score(baseline, draft)
|
||||
cases.append(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"session_id": record.session_id,
|
||||
"baseline_score": baseline,
|
||||
"candidate_score": candidate_score,
|
||||
"delta": round(candidate_score - baseline, 4),
|
||||
}
|
||||
)
|
||||
if not cases:
|
||||
cases.append(
|
||||
{
|
||||
"run_id": "",
|
||||
"session_id": "",
|
||||
"baseline_score": 0.75,
|
||||
"candidate_score": _candidate_score(0.75, draft),
|
||||
"delta": round(_candidate_score(0.75, draft) - 0.75, 4),
|
||||
}
|
||||
)
|
||||
|
||||
baseline_avg = sum(item["baseline_score"] for item in cases) / len(cases)
|
||||
candidate_avg = sum(item["candidate_score"] for item in cases) / len(cases)
|
||||
regressions = [item for item in cases if item["candidate_score"] < item["baseline_score"]]
|
||||
improved = [item for item in cases if item["candidate_score"] > item["baseline_score"]]
|
||||
unchanged = len(cases) - len(regressions) - len(improved)
|
||||
score_delta = candidate_avg - baseline_avg
|
||||
passed = not (len(regressions) > 0 and score_delta <= 0) and candidate_avg >= 0.75
|
||||
return SkillDraftEvalReport(
|
||||
report_id=uuid4().hex,
|
||||
skill_name=draft.skill_name,
|
||||
draft_id=draft.draft_id,
|
||||
candidate_id=candidate.candidate_id,
|
||||
passed=passed,
|
||||
baseline_score_avg=round(baseline_avg, 4),
|
||||
candidate_score_avg=round(candidate_avg, 4),
|
||||
score_delta=round(score_delta, 4),
|
||||
regression_count=len(regressions),
|
||||
improved_count=len(improved),
|
||||
unchanged_count=unchanged,
|
||||
cases=cases,
|
||||
status="completed",
|
||||
created_at=_utc_now(),
|
||||
)
|
||||
|
||||
def _skipped(self, candidate: SkillLearningCandidate, draft: SkillDraft) -> SkillDraftEvalReport:
|
||||
return SkillDraftEvalReport(
|
||||
report_id=uuid4().hex,
|
||||
skill_name=draft.skill_name,
|
||||
draft_id=draft.draft_id,
|
||||
candidate_id=candidate.candidate_id,
|
||||
passed=True,
|
||||
baseline_score_avg=0.0,
|
||||
candidate_score_avg=0.0,
|
||||
score_delta=0.0,
|
||||
regression_count=0,
|
||||
improved_count=0,
|
||||
unchanged_count=0,
|
||||
cases=[],
|
||||
status="skipped_provider_unavailable",
|
||||
created_at=_utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _score_from_validation(validation: dict | None, success: bool) -> float:
|
||||
if isinstance(validation, dict) and "score" in validation:
|
||||
try:
|
||||
return max(0.0, min(1.0, float(validation.get("score") or 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 0.8 if success else 0.4
|
||||
|
||||
|
||||
def _candidate_score(baseline: float, draft: SkillDraft) -> float:
|
||||
content = draft.proposed_content.strip()
|
||||
if not content and draft.proposal_kind != "retire_skill":
|
||||
return 0.0
|
||||
if "regression" in content.lower():
|
||||
return max(0.0, baseline - 0.2)
|
||||
return min(1.0, max(0.75, baseline + 0.05))
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
76
app-instance/backend/beaver/skills/learning/evidence.py
Normal file
76
app-instance/backend/beaver/skills/learning/evidence.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Evidence selection for skill learning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine.session.manager import SessionManager
|
||||
from beaver.memory.runs.store import RunMemoryStore
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EvidencePacket:
|
||||
run_ids: list[str]
|
||||
session_ids: list[str]
|
||||
task_summaries: list[str]
|
||||
session_excerpts: list[str]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EvidenceSelector:
|
||||
def __init__(self, run_store: RunMemoryStore, session_manager: SessionManager | None = None) -> None:
|
||||
self.run_store = run_store
|
||||
self.session_manager = session_manager
|
||||
|
||||
def select_runs_for_revision(self, skill_name: str, version: str, limit: int = 5) -> list[str]:
|
||||
runs = self.run_store.list_runs_by_skill(skill_name, version=version, limit=limit)
|
||||
return [record.run_id for record in runs]
|
||||
|
||||
def select_runs_for_new_skill(self, theme: str, limit: int = 5) -> list[str]:
|
||||
lowered = theme.lower().strip()
|
||||
matches = []
|
||||
for record in self.run_store.list_runs():
|
||||
if lowered and lowered not in record.task_text.lower():
|
||||
continue
|
||||
matches.append(record.run_id)
|
||||
return matches[-limit:]
|
||||
|
||||
def build_evidence_packet(self, run_ids: list[str], session_ids: list[str] | None = None) -> EvidencePacket:
|
||||
runs_by_id = {record.run_id: record for record in self.run_store.list_runs()}
|
||||
resolved_run_ids: list[str] = []
|
||||
resolved_session_ids: list[str] = list(dict.fromkeys(session_ids or []))
|
||||
task_summaries: list[str] = []
|
||||
session_excerpts: list[str] = []
|
||||
for run_id in run_ids:
|
||||
record = runs_by_id.get(run_id)
|
||||
if record is None:
|
||||
continue
|
||||
resolved_run_ids.append(run_id)
|
||||
if record.session_id not in resolved_session_ids:
|
||||
resolved_session_ids.append(record.session_id)
|
||||
summary = record.task_text.strip()
|
||||
if summary:
|
||||
task_summaries.append(summary[:400])
|
||||
if self.session_manager is not None:
|
||||
excerpt = self._session_excerpt(record.session_id, run_id)
|
||||
if excerpt:
|
||||
session_excerpts.append(excerpt)
|
||||
return EvidencePacket(
|
||||
run_ids=resolved_run_ids,
|
||||
session_ids=resolved_session_ids,
|
||||
task_summaries=task_summaries[:8],
|
||||
session_excerpts=session_excerpts[:6],
|
||||
metadata={"bounded": True},
|
||||
)
|
||||
|
||||
def _session_excerpt(self, session_id: str, run_id: str) -> str:
|
||||
if self.session_manager is None:
|
||||
return ""
|
||||
events = self.session_manager.get_run_event_records(session_id, run_id)
|
||||
visible: list[str] = []
|
||||
for event in events:
|
||||
if not event.context_visible or not event.content:
|
||||
continue
|
||||
visible.append(f"{event.role}: {event.content.strip()}")
|
||||
return "\n".join(visible[:12])[:2000]
|
||||
166
app-instance/backend/beaver/skills/learning/missing_skill.py
Normal file
166
app-instance/backend/beaver/skills/learning/missing_skill.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""Synthesize draft-only skills for missing sub-agent guidance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from beaver.engine.context import SkillContext
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
from beaver.skills.drafts import DraftService
|
||||
from beaver.skills.specs import SkillDraft
|
||||
from beaver.skills.specs.serialization import canonical_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beaver.tasks.models import TaskRecord
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MissingSkillDraftResult:
|
||||
draft: SkillDraft
|
||||
skill_context: SkillContext
|
||||
|
||||
|
||||
class MissingSkillSynthesizer:
|
||||
"""Create a draft skill and an ephemeral SkillContext for the current run."""
|
||||
|
||||
async def synthesize(
|
||||
self,
|
||||
*,
|
||||
task: TaskRecord,
|
||||
user_message: str,
|
||||
attempt_index: int,
|
||||
node_id: str,
|
||||
node_task: str,
|
||||
skill_query: str,
|
||||
required_capabilities: list[str],
|
||||
provider_bundle: ProviderBundle,
|
||||
draft_service: DraftService,
|
||||
) -> MissingSkillDraftResult:
|
||||
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
|
||||
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
|
||||
model = getattr(runtime, "model", None)
|
||||
payload = self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)
|
||||
try:
|
||||
response = await provider.chat(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You create concise Beaver skill drafts. Return only JSON with keys: "
|
||||
"skill_name, description, content, tags."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Create a procedural skill draft for this missing Task sub-agent guidance.\n\n"
|
||||
f"Task goal:\n{task.goal}\n\n"
|
||||
f"Current user request:\n{user_message}\n\n"
|
||||
f"Node id: {node_id}\n"
|
||||
f"Node task:\n{node_task}\n\n"
|
||||
f"Skill query:\n{skill_query}\n"
|
||||
f"Required capabilities: {required_capabilities}\n\n"
|
||||
"The content must be actionable guidance for a temporary sub-agent. "
|
||||
"Do not include implementation claims or publish metadata."
|
||||
),
|
||||
},
|
||||
],
|
||||
tools=None,
|
||||
model=model,
|
||||
max_tokens=1200,
|
||||
temperature=0,
|
||||
)
|
||||
payload = self._parse_payload(response.content or "") or payload
|
||||
except Exception:
|
||||
payload = payload
|
||||
|
||||
skill_name = _slug(str(payload.get("skill_name") or skill_query or node_id))
|
||||
content = str(payload.get("content") or "").strip()
|
||||
if not content:
|
||||
content = str(self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)["content"])
|
||||
frontmatter = {
|
||||
"description": str(payload.get("description") or f"Draft guidance for {skill_query or node_id}").strip(),
|
||||
"tags": [str(item) for item in payload.get("tags") or ["generated", "task-sub-agent"]],
|
||||
"metadata": {
|
||||
"origin": "missing_task_subagent_skill",
|
||||
"task_id": task.task_id,
|
||||
"node_id": node_id,
|
||||
"attempt_index": attempt_index,
|
||||
"skill_query": skill_query,
|
||||
"required_capabilities": list(required_capabilities),
|
||||
},
|
||||
}
|
||||
draft = draft_service.create_new_skill_draft(
|
||||
skill_name=skill_name,
|
||||
proposed_content=content,
|
||||
proposed_frontmatter=frontmatter,
|
||||
created_by="task-skill-resolver",
|
||||
reason="generated_for_missing_task_subagent_skill",
|
||||
trigger_session_id=task.session_id,
|
||||
evidence_refs=[
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"session_id": task.session_id,
|
||||
"attempt_index": attempt_index,
|
||||
"node_id": node_id,
|
||||
"skill_query": skill_query,
|
||||
"required_capabilities": list(required_capabilities),
|
||||
}
|
||||
],
|
||||
)
|
||||
context = SkillContext(
|
||||
name=f"draft:{draft.skill_name}",
|
||||
content=draft.proposed_content,
|
||||
version=f"draft:{draft.draft_id}",
|
||||
content_hash=canonical_hash(draft.proposed_content),
|
||||
activation_reason="generated_missing_skill",
|
||||
tool_hints=[],
|
||||
)
|
||||
return MissingSkillDraftResult(draft=draft, skill_context=context)
|
||||
|
||||
@staticmethod
|
||||
def _parse_payload(text: str) -> dict[str, Any] | None:
|
||||
cleaned = text.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()
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:].strip()
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end >= start:
|
||||
cleaned = cleaned[start : end + 1]
|
||||
try:
|
||||
payload = json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _fallback_payload(*, skill_query: str, node_task: str, capabilities: list[str]) -> dict[str, Any]:
|
||||
title = skill_query or node_task or "task subagent guidance"
|
||||
capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely."
|
||||
return {
|
||||
"skill_name": _slug(title),
|
||||
"description": f"Draft guidance for {title}.",
|
||||
"tags": ["generated", "task-sub-agent"],
|
||||
"content": (
|
||||
f"# {title}\n\n"
|
||||
"Use this draft guidance only for the current delegated sub-task.\n\n"
|
||||
"## Objective\n"
|
||||
f"{node_task or title}\n\n"
|
||||
"## Capabilities to apply\n"
|
||||
f"{capability_lines}\n\n"
|
||||
"## Output\n"
|
||||
"Return concise evidence, decisions, and unresolved risks for the main Agent to synthesize."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
||||
return cleaned[:64].strip("-") or "generated-task-subagent-skill"
|
||||
354
app-instance/backend/beaver/skills/learning/pipeline.py
Normal file
354
app-instance/backend/beaver/skills/learning/pipeline.py
Normal file
@ -0,0 +1,354 @@
|
||||
"""Manual skill learning pipeline orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
from beaver.memory.skills import SkillDraftEvalReport, SkillDraftSafetyReport, SkillLearningCandidate, SkillLearningStore
|
||||
from beaver.skills.drafts import DraftService
|
||||
from beaver.skills.learning.eval import SkillDraftEvaluator
|
||||
from beaver.skills.learning.service import SkillLearningService
|
||||
from beaver.skills.learning.safety import SkillDraftSafetyChecker
|
||||
from beaver.skills.publisher import SkillPublisher
|
||||
from beaver.skills.reviews import ReviewService
|
||||
from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion
|
||||
|
||||
|
||||
class SkillLearningPipelineService:
|
||||
"""Coordinates candidate -> draft -> review -> publish lifecycle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
learning_store: SkillLearningStore,
|
||||
learning_service: SkillLearningService,
|
||||
draft_service: DraftService,
|
||||
review_service: ReviewService,
|
||||
publisher: SkillPublisher,
|
||||
safety_checker: SkillDraftSafetyChecker | None = None,
|
||||
evaluator: SkillDraftEvaluator | None = None,
|
||||
) -> None:
|
||||
self.learning_store = learning_store
|
||||
self.learning_service = learning_service
|
||||
self.draft_service = draft_service
|
||||
self.review_service = review_service
|
||||
self.publisher = publisher
|
||||
self.safety_checker = safety_checker or SkillDraftSafetyChecker()
|
||||
self.evaluator = evaluator
|
||||
|
||||
def list_candidates(self, status: str | None = None) -> list[SkillLearningCandidate]:
|
||||
return self.learning_store.list_learning_candidates(status=status)
|
||||
|
||||
def get_candidate(self, candidate_id: str) -> SkillLearningCandidate:
|
||||
for candidate in self.learning_store.list_learning_candidates():
|
||||
if candidate.candidate_id == candidate_id:
|
||||
return candidate
|
||||
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
||||
|
||||
async def synthesize_draft(
|
||||
self,
|
||||
candidate_id: str,
|
||||
*,
|
||||
provider_bundle: ProviderBundle,
|
||||
) -> SkillDraft:
|
||||
draft = await self.learning_service.synthesize_draft(candidate_id, provider_bundle)
|
||||
self.mark_draft_synthesized(candidate_id, draft)
|
||||
return draft
|
||||
|
||||
async def regenerate_draft(
|
||||
self,
|
||||
candidate_id: str,
|
||||
*,
|
||||
provider_bundle: ProviderBundle,
|
||||
) -> SkillDraft:
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
"synthesizing",
|
||||
event_type="draft_synthesis_started",
|
||||
last_error=None,
|
||||
)
|
||||
return await self.synthesize_draft(candidate_id, provider_bundle=provider_bundle)
|
||||
|
||||
def mark_candidate_queued(self, candidate_id: str) -> SkillLearningCandidate:
|
||||
return self._require_updated(
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
"queued",
|
||||
event_type="candidate_queued",
|
||||
last_error=None,
|
||||
),
|
||||
candidate_id,
|
||||
)
|
||||
|
||||
def mark_candidate_synthesizing(self, candidate_id: str) -> SkillLearningCandidate:
|
||||
return self._require_updated(
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
"synthesizing",
|
||||
event_type="draft_synthesis_started",
|
||||
last_error=None,
|
||||
),
|
||||
candidate_id,
|
||||
)
|
||||
|
||||
def mark_draft_synthesized(self, candidate_id: str, draft: SkillDraft) -> SkillLearningCandidate:
|
||||
candidate = self.get_candidate(candidate_id)
|
||||
evidence = dict(candidate.evidence)
|
||||
evidence["draft_id"] = draft.draft_id
|
||||
evidence["draft_skill_name"] = draft.skill_name
|
||||
return self._require_updated(
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
"draft_ready",
|
||||
event_type="draft_synthesis_completed",
|
||||
evidence=evidence,
|
||||
draft_id=draft.draft_id,
|
||||
draft_skill_name=draft.skill_name,
|
||||
risk_level=candidate.risk_level,
|
||||
last_error=None,
|
||||
payload={"draft_id": draft.draft_id, "skill_name": draft.skill_name},
|
||||
),
|
||||
candidate_id,
|
||||
)
|
||||
|
||||
def mark_candidate_failed(
|
||||
self,
|
||||
candidate_id: str,
|
||||
error: str,
|
||||
*,
|
||||
retry_count: int,
|
||||
terminal: bool,
|
||||
) -> SkillLearningCandidate:
|
||||
return self._require_updated(
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
"failed" if terminal else "open",
|
||||
event_type="failed",
|
||||
retry_count=retry_count,
|
||||
last_error=error,
|
||||
payload={"error": error, "terminal": terminal, "retry_count": retry_count},
|
||||
),
|
||||
candidate_id,
|
||||
)
|
||||
|
||||
def mark_candidate_superseded(self, candidate_id: str, reason: str) -> SkillLearningCandidate:
|
||||
return self._require_updated(
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
"superseded",
|
||||
event_type="superseded",
|
||||
last_error=reason,
|
||||
payload={"reason": reason},
|
||||
),
|
||||
candidate_id,
|
||||
)
|
||||
|
||||
def list_drafts(self, skill_name: str | None = None) -> list[SkillDraft]:
|
||||
return self.draft_service.list_drafts(skill_name)
|
||||
|
||||
def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||
draft = self.draft_service.get_draft(skill_name, draft_id)
|
||||
if draft is None:
|
||||
raise ValueError(f"Draft not found: {skill_name}/{draft_id}")
|
||||
return draft
|
||||
|
||||
def submit_review(
|
||||
self,
|
||||
skill_name: str,
|
||||
draft_id: str,
|
||||
*,
|
||||
requested_by: str = "system",
|
||||
notes: str = "",
|
||||
) -> SkillReviewRecord:
|
||||
safety = self.get_safety_report(skill_name, draft_id)
|
||||
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
|
||||
raise ValueError("Draft cannot enter review because safety check failed")
|
||||
return self.review_service.submit_for_review(
|
||||
skill_name,
|
||||
draft_id,
|
||||
reviewer_request=notes,
|
||||
requested_by=requested_by,
|
||||
)
|
||||
|
||||
def approve(
|
||||
self,
|
||||
skill_name: str,
|
||||
draft_id: str,
|
||||
*,
|
||||
reviewer: str = "system",
|
||||
notes: str = "",
|
||||
) -> SkillReviewRecord:
|
||||
review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
||||
self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved")
|
||||
return review
|
||||
|
||||
def reject(
|
||||
self,
|
||||
skill_name: str,
|
||||
draft_id: str,
|
||||
*,
|
||||
reviewer: str = "system",
|
||||
notes: str = "",
|
||||
) -> SkillReviewRecord:
|
||||
review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
||||
self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected")
|
||||
return review
|
||||
|
||||
def publish(
|
||||
self,
|
||||
skill_name: str,
|
||||
draft_id: str,
|
||||
*,
|
||||
publisher: str = "system",
|
||||
notes: str = "",
|
||||
confirm_high_risk: bool = False,
|
||||
) -> SkillVersion | SkillSpec:
|
||||
draft = self.get_draft(skill_name, draft_id)
|
||||
self._validate_publish_gates(draft, confirm_high_risk=confirm_high_risk)
|
||||
if draft.proposal_kind == "retire_skill":
|
||||
result = self.publisher.apply_retire_proposal(skill_name, draft_id, actor=publisher, notes=notes)
|
||||
else:
|
||||
result = self.publisher.publish(skill_name, draft_id, publisher=publisher, notes=notes)
|
||||
self._mark_candidate_by_draft(skill_name, draft_id, "published", "published")
|
||||
return result
|
||||
|
||||
def rollback(
|
||||
self,
|
||||
skill_name: str,
|
||||
target_version: str,
|
||||
*,
|
||||
actor: str = "system",
|
||||
reason: str = "",
|
||||
) -> SkillSpec:
|
||||
return self.publisher.rollback(skill_name, target_version, actor=actor, reason=reason or "manual rollback")
|
||||
|
||||
def disable(
|
||||
self,
|
||||
skill_name: str,
|
||||
*,
|
||||
actor: str = "system",
|
||||
reason: str = "",
|
||||
) -> SkillSpec:
|
||||
return self.publisher.disable(skill_name, actor=actor, reason=reason or "manual disable")
|
||||
|
||||
def reviews_for_draft(self, skill_name: str, draft_id: str) -> list[SkillReviewRecord]:
|
||||
return self.review_service.store.list_reviews(skill_name, draft_id=draft_id)
|
||||
|
||||
def check_safety(self, skill_name: str, draft_id: str) -> SkillDraftSafetyReport:
|
||||
draft = self.get_draft(skill_name, draft_id)
|
||||
report = self.safety_checker.check(draft)
|
||||
self.learning_store.write_safety_report(report)
|
||||
status = "safety_failed" if not report.passed or report.risk_level == "critical" else "draft_ready"
|
||||
current = self._candidate_by_draft(skill_name, draft_id)
|
||||
if current is not None and current.status == "eval_failed" and status == "draft_ready":
|
||||
status = "eval_failed"
|
||||
self._mark_candidate_by_draft(
|
||||
skill_name,
|
||||
draft_id,
|
||||
status,
|
||||
"safety_checked",
|
||||
safety_report_id=report.report_id,
|
||||
risk_level=report.risk_level,
|
||||
last_error="; ".join(report.blocked_reasons) if status == "safety_failed" else None,
|
||||
)
|
||||
return report
|
||||
|
||||
def get_safety_report(self, skill_name: str, draft_id: str) -> SkillDraftSafetyReport | None:
|
||||
return self.learning_store.get_safety_report(skill_name, draft_id)
|
||||
|
||||
def get_eval_report(self, skill_name: str, draft_id: str) -> SkillDraftEvalReport | None:
|
||||
return self.learning_store.get_eval_report(skill_name, draft_id)
|
||||
|
||||
async def evaluate_draft(
|
||||
self,
|
||||
candidate_id: str,
|
||||
skill_name: str,
|
||||
draft_id: str,
|
||||
*,
|
||||
provider_bundle: ProviderBundle | None,
|
||||
) -> SkillDraftEvalReport:
|
||||
draft = self.get_draft(skill_name, draft_id)
|
||||
candidate = self.get_candidate(candidate_id)
|
||||
evaluator = self.evaluator or SkillDraftEvaluator(self.learning_service.run_store)
|
||||
report = await evaluator.evaluate(candidate=candidate, draft=draft, provider_bundle=provider_bundle)
|
||||
self.learning_store.write_eval_report(report)
|
||||
if report.status == "skipped_provider_unavailable":
|
||||
status = "draft_ready"
|
||||
error = "eval skipped: provider unavailable"
|
||||
elif report.passed:
|
||||
status = "draft_ready"
|
||||
error = None
|
||||
else:
|
||||
status = "eval_failed"
|
||||
error = "eval failed"
|
||||
current = self._candidate_by_draft(skill_name, draft_id)
|
||||
if current is not None and current.status == "safety_failed" and status == "draft_ready":
|
||||
status = "safety_failed"
|
||||
error = current.last_error
|
||||
self.learning_store.transition_learning_candidate(
|
||||
candidate_id,
|
||||
status,
|
||||
event_type="eval_completed",
|
||||
eval_report_id=report.report_id,
|
||||
last_error=error,
|
||||
payload=report.to_dict(),
|
||||
)
|
||||
return report
|
||||
|
||||
def _validate_publish_gates(self, draft: SkillDraft, *, confirm_high_risk: bool) -> None:
|
||||
reviews = self.reviews_for_draft(draft.skill_name, draft.draft_id)
|
||||
if not any(review.status == SkillReviewState.APPROVED.value for review in reviews):
|
||||
raise ValueError("Draft must have an approved review before publish")
|
||||
safety = self.get_safety_report(draft.skill_name, draft.draft_id)
|
||||
if safety is None:
|
||||
raise ValueError("Draft requires a passing safety report before publish")
|
||||
if not safety.passed:
|
||||
raise ValueError("Draft safety report did not pass")
|
||||
if safety.risk_level == "critical":
|
||||
raise ValueError("Critical risk drafts cannot be published")
|
||||
if safety.risk_level == "high" and not confirm_high_risk:
|
||||
raise ValueError("High risk draft publish requires confirm_high_risk=true")
|
||||
eval_report = self.get_eval_report(draft.skill_name, draft.draft_id)
|
||||
if eval_report is not None and eval_report.status != "skipped_provider_unavailable" and not eval_report.passed:
|
||||
raise ValueError("Draft eval report did not pass")
|
||||
|
||||
def _mark_candidate_by_draft(
|
||||
self,
|
||||
skill_name: str,
|
||||
draft_id: str,
|
||||
status: str,
|
||||
event_type: str,
|
||||
**updates: object,
|
||||
) -> SkillLearningCandidate | None:
|
||||
candidate = self._candidate_by_draft(skill_name, draft_id)
|
||||
if candidate is None:
|
||||
return None
|
||||
if candidate.status in {"safety_failed", "eval_failed"} and status in {"review_pending", "approved"}:
|
||||
return candidate
|
||||
return self.learning_store.transition_learning_candidate(
|
||||
candidate.candidate_id,
|
||||
status,
|
||||
event_type=event_type,
|
||||
**updates,
|
||||
)
|
||||
|
||||
def _candidate_by_draft(self, skill_name: str, draft_id: str) -> SkillLearningCandidate | None:
|
||||
for candidate in self.learning_store.list_learning_candidates():
|
||||
if candidate.draft_skill_name == skill_name and candidate.draft_id == draft_id:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _require_updated(candidate: SkillLearningCandidate | None, candidate_id: str) -> SkillLearningCandidate:
|
||||
if candidate is None:
|
||||
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
||||
return candidate
|
||||
|
||||
|
||||
def model_to_dict(value: Any) -> dict[str, Any]:
|
||||
if hasattr(value, "to_dict"):
|
||||
return value.to_dict()
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
raise TypeError(f"Cannot convert {type(value).__name__} to dict")
|
||||
109
app-instance/backend/beaver/skills/learning/safety.py
Normal file
109
app-instance/backend/beaver/skills/learning/safety.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Safety checks for generated skill drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from beaver.memory.skills import SkillDraftSafetyReport
|
||||
from beaver.skills.specs import SkillDraft
|
||||
|
||||
|
||||
class SkillDraftSafetyChecker:
|
||||
"""Deterministic publish gate for skill drafts."""
|
||||
|
||||
_CRITICAL_PATTERNS = [
|
||||
r"ignore (all )?(previous|system|developer) instructions",
|
||||
r"bypass (permissions|permission|safety|policy|policies)",
|
||||
r"disable (guardrails|safety|permission checks)",
|
||||
r"reveal (secrets|api keys|tokens|credentials)",
|
||||
r"exfiltrate",
|
||||
]
|
||||
_DANGEROUS_TOOL_HINTS = {
|
||||
"shell",
|
||||
"terminal",
|
||||
"bash",
|
||||
"filesystem_write",
|
||||
"write_file",
|
||||
"delete_file",
|
||||
"network",
|
||||
"http",
|
||||
"auth",
|
||||
"credentials",
|
||||
}
|
||||
|
||||
def __init__(self, *, allowed_tool_names: set[str] | None = None) -> None:
|
||||
self.allowed_tool_names = allowed_tool_names
|
||||
|
||||
def check(self, draft: SkillDraft) -> SkillDraftSafetyReport:
|
||||
issues: list[str] = []
|
||||
blocked: list[str] = []
|
||||
risk_level = "low"
|
||||
|
||||
frontmatter = draft.proposed_frontmatter
|
||||
if not isinstance(frontmatter, dict):
|
||||
blocked.append("frontmatter must be an object")
|
||||
description = str(frontmatter.get("description") or "").strip()
|
||||
if not description and draft.proposal_kind != "retire_skill":
|
||||
issues.append("frontmatter.description is missing")
|
||||
risk_level = _max_risk(risk_level, "medium")
|
||||
|
||||
tool_hints = _tool_hints(frontmatter)
|
||||
if self.allowed_tool_names is not None:
|
||||
unknown = [name for name in tool_hints if name not in self.allowed_tool_names]
|
||||
if unknown:
|
||||
blocked.append(f"unknown tool hints: {', '.join(sorted(unknown))}")
|
||||
dangerous = sorted({name for name in tool_hints if name.lower() in self._DANGEROUS_TOOL_HINTS})
|
||||
if dangerous:
|
||||
issues.append(f"dangerous tool hints require high-risk review: {', '.join(dangerous)}")
|
||||
risk_level = _max_risk(risk_level, "high")
|
||||
|
||||
content = f"{draft.proposed_content}\n{frontmatter}".lower()
|
||||
for pattern in self._CRITICAL_PATTERNS:
|
||||
if re.search(pattern, content):
|
||||
blocked.append(f"critical prompt-safety pattern matched: {pattern}")
|
||||
risk_level = "critical"
|
||||
|
||||
if draft.proposal_kind in {"retire_skill", "merge_skills"}:
|
||||
risk_level = _max_risk(risk_level, "high")
|
||||
|
||||
passed = not blocked and risk_level != "critical"
|
||||
return SkillDraftSafetyReport(
|
||||
report_id=uuid4().hex,
|
||||
skill_name=draft.skill_name,
|
||||
draft_id=draft.draft_id,
|
||||
passed=passed,
|
||||
risk_level=risk_level,
|
||||
issues=issues,
|
||||
blocked_reasons=blocked,
|
||||
suggested_fix=_suggest_fix(blocked, issues),
|
||||
created_at=_utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _tool_hints(frontmatter: dict) -> list[str]:
|
||||
raw = frontmatter.get("tools")
|
||||
if isinstance(raw, list):
|
||||
return [str(item).strip() for item in raw if str(item).strip()]
|
||||
if isinstance(raw, str):
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _max_risk(left: str, right: str) -> str:
|
||||
order = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||||
return left if order[left] >= order[right] else right
|
||||
|
||||
|
||||
def _suggest_fix(blocked: list[str], issues: list[str]) -> str:
|
||||
if blocked:
|
||||
return "Remove blocked instructions or invalid tool hints before review."
|
||||
if issues:
|
||||
return "Review the flagged issues before publishing."
|
||||
return ""
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
293
app-instance/backend/beaver/skills/learning/service.py
Normal file
293
app-instance/backend/beaver/skills/learning/service.py
Normal file
@ -0,0 +1,293 @@
|
||||
"""Skill learning loop services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import combinations
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
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.skills.drafts.service import DraftService
|
||||
from beaver.skills.learning.evidence import EvidencePacket, EvidenceSelector
|
||||
from beaver.skills.learning.synthesizer import SkillDraftSynthesizer
|
||||
from beaver.skills.specs import SkillActivationReceipt
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunReceiptContext:
|
||||
run_record: RunRecord
|
||||
effect_records: list[SkillEffectRecord] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillLearningService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_store: RunMemoryStore,
|
||||
learning_store: SkillLearningStore,
|
||||
draft_service: DraftService,
|
||||
evidence_selector: EvidenceSelector,
|
||||
synthesizer: SkillDraftSynthesizer | None = None,
|
||||
) -> None:
|
||||
self.run_store = run_store
|
||||
self.learning_store = learning_store
|
||||
self.draft_service = draft_service
|
||||
self.evidence_selector = evidence_selector
|
||||
self.synthesizer = synthesizer or SkillDraftSynthesizer()
|
||||
|
||||
def collect_run_receipts(
|
||||
self,
|
||||
run_result_context: RunReceiptContext,
|
||||
*,
|
||||
generate_candidates: bool = True,
|
||||
) -> list[SkillLearningCandidate]:
|
||||
self.run_store.append_run_record(run_result_context.run_record)
|
||||
for effect in run_result_context.effect_records:
|
||||
self.run_store.append_skill_effect(effect)
|
||||
self.rescore_skill_versions()
|
||||
if not generate_candidates:
|
||||
return []
|
||||
return self.build_learning_candidates()
|
||||
|
||||
def build_learning_candidates(self) -> list[SkillLearningCandidate]:
|
||||
candidates: list[SkillLearningCandidate] = []
|
||||
candidates.extend(self._build_revision_candidates())
|
||||
candidates.extend(self._build_new_skill_candidates())
|
||||
candidates.extend(self._build_merge_candidates())
|
||||
candidates.extend(self._build_retire_candidates())
|
||||
existing_ids = {item.candidate_id for item in self.learning_store.list_learning_candidates()}
|
||||
for candidate in candidates:
|
||||
if candidate.candidate_id not in existing_ids:
|
||||
self.learning_store.record_learning_candidate(candidate)
|
||||
existing_ids.add(candidate.candidate_id)
|
||||
return candidates
|
||||
|
||||
async def synthesize_draft(self, candidate_id: str, provider_bundle: ProviderBundle) -> Any:
|
||||
candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()}
|
||||
candidate = candidates.get(candidate_id)
|
||||
if candidate is None:
|
||||
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
||||
if candidate.kind == "retire_skill":
|
||||
target_skill = candidate.related_skill_names[0]
|
||||
return self.draft_service.create_retire_proposal(
|
||||
skill_name=target_skill,
|
||||
base_version=candidate.evidence.get("skill_version"),
|
||||
created_by="learning-loop",
|
||||
reason=candidate.reason,
|
||||
evidence_refs=[{"run_id": item} for item in candidate.source_run_ids],
|
||||
)
|
||||
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
|
||||
)
|
||||
if candidate.kind == "new_skill":
|
||||
payload = await self.synthesizer.synthesize_new_skill(candidate, packet, provider, model)
|
||||
return self.draft_service.create_new_skill_draft(
|
||||
skill_name=self._suggest_skill_name(candidate, packet),
|
||||
proposed_content=payload["content"],
|
||||
proposed_frontmatter=payload["frontmatter"],
|
||||
created_by="learning-loop",
|
||||
reason=payload["change_reason"] or candidate.reason,
|
||||
evidence_refs=[{"run_id": item} for item in candidate.source_run_ids],
|
||||
)
|
||||
if candidate.kind == "merge_skills":
|
||||
target_name = self._suggest_skill_name(candidate, packet)
|
||||
payload = await self.synthesizer.synthesize_merge(candidate, packet, provider, model)
|
||||
return self.draft_service.create_merge_draft(
|
||||
skill_name=target_name,
|
||||
base_version=None,
|
||||
proposed_content=payload["content"],
|
||||
proposed_frontmatter=payload["frontmatter"],
|
||||
created_by="learning-loop",
|
||||
reason=payload["change_reason"] or candidate.reason,
|
||||
evidence_refs=[{"run_id": item} for item in candidate.source_run_ids],
|
||||
)
|
||||
target_skill = candidate.related_skill_names[0]
|
||||
base_version = candidate.evidence.get("skill_version")
|
||||
payload = await self.synthesizer.synthesize_revision(candidate, packet, provider, model)
|
||||
return self.draft_service.create_revision_draft(
|
||||
skill_name=target_skill,
|
||||
base_version=base_version,
|
||||
proposed_content=payload["content"],
|
||||
proposed_frontmatter=payload["frontmatter"],
|
||||
created_by="learning-loop",
|
||||
reason=payload["change_reason"] or candidate.reason,
|
||||
evidence_refs=[{"run_id": item} for item in candidate.source_run_ids],
|
||||
)
|
||||
|
||||
def rescore_skill_versions(self) -> list[SkillPerformanceSnapshot]:
|
||||
snapshots: list[SkillPerformanceSnapshot] = []
|
||||
grouped: dict[tuple[str, str], list[SkillEffectRecord]] = {}
|
||||
for record in self.run_store.list_runs():
|
||||
for receipt in record.activated_skills:
|
||||
key = (receipt.skill_name, receipt.skill_version)
|
||||
grouped.setdefault(key, [])
|
||||
for effect in self._all_effects():
|
||||
grouped.setdefault((effect.skill_name, effect.skill_version), []).append(effect)
|
||||
for (skill_name, skill_version), effects in grouped.items():
|
||||
activation_count = len(effects)
|
||||
success_count = sum(1 for item in effects if item.success)
|
||||
failure_count = activation_count - success_count
|
||||
last_feedback = next((item.feedback_score for item in reversed(effects) if item.feedback_score is not None), None)
|
||||
latest_used = effects[-1].created_at if effects else ""
|
||||
snapshot = SkillPerformanceSnapshot(
|
||||
skill_name=skill_name,
|
||||
skill_version=skill_version,
|
||||
activation_count=activation_count,
|
||||
success_count=success_count,
|
||||
failure_count=failure_count,
|
||||
latest_used_at=latest_used,
|
||||
last_feedback_score=last_feedback,
|
||||
)
|
||||
self.learning_store.update_performance_snapshot(snapshot)
|
||||
snapshots.append(snapshot)
|
||||
return snapshots
|
||||
|
||||
def _build_revision_candidates(self) -> list[SkillLearningCandidate]:
|
||||
candidates: list[SkillLearningCandidate] = []
|
||||
for snapshot in self.learning_store.list_low_performing_versions():
|
||||
runs = self.run_store.list_runs_by_skill(snapshot.skill_name, version=snapshot.skill_version, limit=5)
|
||||
if len(runs) < 2:
|
||||
continue
|
||||
candidate = SkillLearningCandidate(
|
||||
candidate_id=self._candidate_id("revise", snapshot.skill_name, snapshot.skill_version),
|
||||
kind="revise_skill",
|
||||
source_run_ids=[record.run_id for record in runs],
|
||||
source_session_ids=list(dict.fromkeys(record.session_id for record in runs)),
|
||||
related_skill_names=[snapshot.skill_name],
|
||||
reason=f"Skill version {snapshot.skill_name}/{snapshot.skill_version} is underperforming across repeated runs.",
|
||||
evidence={"skill_version": snapshot.skill_version},
|
||||
status="open",
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
def _build_new_skill_candidates(self) -> list[SkillLearningCandidate]:
|
||||
groups: dict[str, list[RunRecord]] = {}
|
||||
for record in self.run_store.list_runs():
|
||||
key = self._task_theme(record.task_text)
|
||||
if not key:
|
||||
continue
|
||||
groups.setdefault(key, []).append(record)
|
||||
candidates: list[SkillLearningCandidate] = []
|
||||
for theme, runs in groups.items():
|
||||
successful = [record for record in runs if record.success]
|
||||
if len(successful) < 2:
|
||||
continue
|
||||
if any(record.activated_skills for record in successful):
|
||||
continue
|
||||
candidate = SkillLearningCandidate(
|
||||
candidate_id=self._candidate_id("new", theme, str(len(successful))),
|
||||
kind="new_skill",
|
||||
source_run_ids=[record.run_id for record in successful[-5:]],
|
||||
source_session_ids=list(dict.fromkeys(record.session_id for record in successful[-5:])),
|
||||
related_skill_names=[],
|
||||
reason=f"Repeated successful tasks around '{theme}' suggest a reusable skill should be created.",
|
||||
evidence={"theme": theme},
|
||||
status="open",
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
|
||||
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
|
||||
for record in self.run_store.list_runs():
|
||||
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
|
||||
for pair in combinations(unique, 2):
|
||||
pair_counts.setdefault(pair, []).append(record)
|
||||
candidates: list[SkillLearningCandidate] = []
|
||||
for pair, runs in pair_counts.items():
|
||||
if len(runs) < 2:
|
||||
continue
|
||||
candidate = SkillLearningCandidate(
|
||||
candidate_id=self._candidate_id("merge", *pair),
|
||||
kind="merge_skills",
|
||||
source_run_ids=[record.run_id for record in runs[-5:]],
|
||||
source_session_ids=list(dict.fromkeys(record.session_id for record in runs[-5:])),
|
||||
related_skill_names=list(pair),
|
||||
reason=f"Skills {pair[0]} and {pair[1]} repeatedly co-activate and may benefit from consolidation.",
|
||||
evidence={"pair": list(pair)},
|
||||
status="open",
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
def _build_retire_candidates(self, *, stale_days: int = 30) -> list[SkillLearningCandidate]:
|
||||
candidates: list[SkillLearningCandidate] = []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=stale_days)
|
||||
for snapshot in self.learning_store.list_performance_snapshots():
|
||||
if snapshot.activation_count == 0 or not snapshot.latest_used_at:
|
||||
continue
|
||||
latest_used = self._parse_timestamp(snapshot.latest_used_at)
|
||||
if latest_used is None or latest_used > cutoff:
|
||||
continue
|
||||
runs = self.run_store.list_runs_by_skill(snapshot.skill_name, version=snapshot.skill_version, limit=3)
|
||||
candidate = SkillLearningCandidate(
|
||||
candidate_id=self._candidate_id("retire", snapshot.skill_name, snapshot.skill_version),
|
||||
kind="retire_skill",
|
||||
source_run_ids=[record.run_id for record in runs],
|
||||
source_session_ids=list(dict.fromkeys(record.session_id for record in runs)),
|
||||
related_skill_names=[snapshot.skill_name],
|
||||
reason=(
|
||||
f"Skill version {snapshot.skill_name}/{snapshot.skill_version} has been inactive "
|
||||
f"since {snapshot.latest_used_at} and may be ready for retirement."
|
||||
),
|
||||
evidence={"skill_version": snapshot.skill_version, "latest_used_at": snapshot.latest_used_at},
|
||||
status="open",
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
def _all_effects(self) -> list[SkillEffectRecord]:
|
||||
effects: list[SkillEffectRecord] = []
|
||||
for candidate in self.learning_store.list_performance_snapshots():
|
||||
effects.extend(self.run_store.list_skill_effects(candidate.skill_name, version=candidate.skill_version))
|
||||
if effects:
|
||||
return effects
|
||||
# Bootstrap from runs when there are no prior snapshots.
|
||||
for record in self.run_store.list_runs():
|
||||
for receipt in record.activated_skills:
|
||||
effects.extend(self.run_store.list_skill_effects(receipt.skill_name, version=receipt.skill_version))
|
||||
return effects
|
||||
|
||||
@staticmethod
|
||||
def _candidate_id(kind: str, *parts: str) -> str:
|
||||
return f"{kind}:{'|'.join(parts)}"
|
||||
|
||||
@staticmethod
|
||||
def _task_theme(task_text: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", task_text.strip().lower())
|
||||
if not cleaned:
|
||||
return ""
|
||||
words = cleaned.split(" ")
|
||||
return " ".join(words[:8]).strip()
|
||||
|
||||
@staticmethod
|
||||
def _suggest_skill_name(candidate: SkillLearningCandidate, packet: EvidencePacket) -> str:
|
||||
if candidate.related_skill_names:
|
||||
return candidate.related_skill_names[0]
|
||||
if packet.task_summaries:
|
||||
seed = re.sub(r"[^a-z0-9]+", "-", packet.task_summaries[0].lower()).strip("-")
|
||||
if seed:
|
||||
return seed[:48]
|
||||
return f"generated-skill-{uuid4().hex[:8]}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(value: str) -> datetime | None:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
118
app-instance/backend/beaver/skills/learning/synthesizer.py
Normal file
118
app-instance/backend/beaver/skills/learning/synthesizer.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""LLM-backed draft synthesis for skill learning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine.providers.base import LLMProvider
|
||||
from beaver.skills.learning.evidence import EvidencePacket
|
||||
from beaver.memory.skills.models import SkillLearningCandidate
|
||||
|
||||
|
||||
class SkillDraftSynthesizer:
|
||||
async def synthesize_revision(
|
||||
self,
|
||||
candidate: SkillLearningCandidate,
|
||||
evidence_packet: EvidencePacket,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._synthesize(candidate, evidence_packet, provider, model, "revise")
|
||||
|
||||
async def synthesize_new_skill(
|
||||
self,
|
||||
candidate: SkillLearningCandidate,
|
||||
evidence_packet: EvidencePacket,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._synthesize(candidate, evidence_packet, provider, model, "new")
|
||||
|
||||
async def synthesize_merge(
|
||||
self,
|
||||
candidate: SkillLearningCandidate,
|
||||
evidence_packet: EvidencePacket,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._synthesize(candidate, evidence_packet, provider, model, "merge")
|
||||
|
||||
async def _synthesize(
|
||||
self,
|
||||
candidate: SkillLearningCandidate,
|
||||
evidence_packet: EvidencePacket,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
action: str,
|
||||
) -> dict[str, Any]:
|
||||
prompt = self._build_prompt(candidate, evidence_packet, action)
|
||||
response = await provider.chat(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You synthesize Beaver skill drafts from execution evidence. "
|
||||
"Return only JSON with keys: frontmatter, content, change_reason."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=None,
|
||||
model=model,
|
||||
max_tokens=1500,
|
||||
temperature=0,
|
||||
)
|
||||
payload = self._parse_payload(response.content or "")
|
||||
if payload:
|
||||
return payload
|
||||
return self._fallback_payload(candidate, evidence_packet, action)
|
||||
|
||||
@staticmethod
|
||||
def _build_prompt(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> str:
|
||||
return (
|
||||
f"Action: {action}\n"
|
||||
f"Candidate kind: {candidate.kind}\n"
|
||||
f"Reason: {candidate.reason}\n"
|
||||
f"Related skills: {candidate.related_skill_names}\n"
|
||||
f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries)
|
||||
+ "\n\nSession excerpts:\n" + "\n\n".join(evidence_packet.session_excerpts)
|
||||
+ "\n\nReturn JSON only."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_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 ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_payload(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> dict[str, Any]:
|
||||
related = candidate.related_skill_names[0] if candidate.related_skill_names else "generated-skill"
|
||||
title = related.replace("_", "-")
|
||||
content = "\n".join(f"- {item}" for item in evidence_packet.task_summaries[:5]) or "- No evidence captured."
|
||||
return {
|
||||
"frontmatter": {
|
||||
"description": candidate.reason or f"Auto-generated {action} draft for {title}.",
|
||||
"tools": [],
|
||||
},
|
||||
"content": f"# {title}\n\n## Evidence\n\n{content}\n",
|
||||
"change_reason": candidate.reason or f"Fallback {action} synthesis.",
|
||||
}
|
||||
175
app-instance/backend/beaver/skills/learning/worker.py
Normal file
175
app-instance/backend/beaver/skills/learning/worker.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Background worker for assisted skill learning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
from beaver.memory.skills import SkillLearningCandidate
|
||||
from beaver.skills.learning.pipeline import SkillLearningPipelineService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillLearningWorkerConfig:
|
||||
enabled: bool = True
|
||||
max_drafts_per_run: int = 5
|
||||
max_retries: int = 3
|
||||
interval_seconds: float = 300.0
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "SkillLearningWorkerConfig":
|
||||
return cls(
|
||||
enabled=_env_bool("BEAVER_SKILL_LEARNING_WORKER_ENABLED", True),
|
||||
max_drafts_per_run=_env_int("BEAVER_SKILL_LEARNING_MAX_DRAFTS_PER_RUN", 5),
|
||||
max_retries=_env_int("BEAVER_SKILL_LEARNING_MAX_RETRIES", 3),
|
||||
interval_seconds=float(os.getenv("BEAVER_SKILL_LEARNING_INTERVAL_SECONDS", "300") or "300"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillLearningWorkerResult:
|
||||
processed: int = 0
|
||||
succeeded: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
failures: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"processed": self.processed,
|
||||
"succeeded": self.succeeded,
|
||||
"failed": self.failed,
|
||||
"skipped": self.skipped,
|
||||
"failures": [dict(item) for item in self.failures],
|
||||
}
|
||||
|
||||
|
||||
class SkillLearningWorker:
|
||||
"""Synthesizes drafts for open candidates; never approves or publishes."""
|
||||
|
||||
_ACTIVE_DRAFT_STATUSES = {"queued", "synthesizing", "draft_ready", "review_pending", "approved"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pipeline: SkillLearningPipelineService,
|
||||
provider_bundle_factory: Callable[[], ProviderBundle],
|
||||
config: SkillLearningWorkerConfig | None = None,
|
||||
) -> None:
|
||||
self.pipeline = pipeline
|
||||
self.provider_bundle_factory = provider_bundle_factory
|
||||
self.config = config or SkillLearningWorkerConfig.from_env()
|
||||
self._running = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
if not self.config.enabled:
|
||||
return
|
||||
self._running = True
|
||||
try:
|
||||
while self._running:
|
||||
await self.run_once()
|
||||
await asyncio.sleep(self.config.interval_seconds)
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def run_once(self) -> SkillLearningWorkerResult:
|
||||
if not self.config.enabled:
|
||||
return SkillLearningWorkerResult()
|
||||
async with self._lock:
|
||||
result = SkillLearningWorkerResult()
|
||||
candidates = self._select_candidates()
|
||||
for candidate in candidates[: self.config.max_drafts_per_run]:
|
||||
result.processed += 1
|
||||
try:
|
||||
handled = await self._process_candidate(candidate)
|
||||
if handled:
|
||||
result.succeeded += 1
|
||||
else:
|
||||
result.skipped += 1
|
||||
except Exception as exc:
|
||||
result.failed += 1
|
||||
result.failures.append({"candidate_id": candidate.candidate_id, "error": str(exc)})
|
||||
self._mark_failure(candidate, str(exc))
|
||||
return result
|
||||
|
||||
def _select_candidates(self) -> list[SkillLearningCandidate]:
|
||||
candidates = [
|
||||
item
|
||||
for item in self.pipeline.list_candidates()
|
||||
if item.status == "open" and item.retry_count < self.config.max_retries
|
||||
]
|
||||
return sorted(candidates, key=lambda item: (item.priority, item.confidence, item.created_at), reverse=True)
|
||||
|
||||
async def _process_candidate(self, candidate: SkillLearningCandidate) -> bool:
|
||||
if self._has_active_draft(candidate):
|
||||
self.pipeline.mark_candidate_superseded(candidate.candidate_id, "active draft already exists for this skill")
|
||||
return False
|
||||
self.pipeline.mark_candidate_queued(candidate.candidate_id)
|
||||
self.pipeline.mark_candidate_synthesizing(candidate.candidate_id)
|
||||
draft = await self.pipeline.synthesize_draft(
|
||||
candidate.candidate_id,
|
||||
provider_bundle=self.provider_bundle_factory(),
|
||||
)
|
||||
self.pipeline.mark_draft_synthesized(candidate.candidate_id, draft)
|
||||
safety = self.pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||
if not safety.passed or safety.risk_level == "critical":
|
||||
return True
|
||||
await self.pipeline.evaluate_draft(
|
||||
candidate.candidate_id,
|
||||
draft.skill_name,
|
||||
draft.draft_id,
|
||||
provider_bundle=self.provider_bundle_factory(),
|
||||
)
|
||||
return True
|
||||
|
||||
def _has_active_draft(self, candidate: SkillLearningCandidate) -> bool:
|
||||
target_names = set(candidate.related_skill_names)
|
||||
if candidate.draft_skill_name:
|
||||
target_names.add(candidate.draft_skill_name)
|
||||
if not target_names:
|
||||
return False
|
||||
for item in self.pipeline.list_candidates():
|
||||
if item.candidate_id == candidate.candidate_id:
|
||||
continue
|
||||
if item.status not in self._ACTIVE_DRAFT_STATUSES:
|
||||
continue
|
||||
item_names = set(item.related_skill_names)
|
||||
if item.draft_skill_name:
|
||||
item_names.add(item.draft_skill_name)
|
||||
if target_names.intersection(item_names):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _mark_failure(self, candidate: SkillLearningCandidate, error: str) -> None:
|
||||
retry_count = candidate.retry_count + 1
|
||||
status = "failed" if retry_count >= self.config.max_retries else "open"
|
||||
self.pipeline.mark_candidate_failed(
|
||||
candidate.candidate_id,
|
||||
error,
|
||||
retry_count=retry_count,
|
||||
terminal=(status == "failed"),
|
||||
)
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw in (None, ""):
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
@ -1,2 +1,6 @@
|
||||
"""Skill publishing and version switching."""
|
||||
"""Skill publish and rollback services."""
|
||||
|
||||
from .service import SkillPublisher
|
||||
|
||||
__all__ = ["SkillPublisher"]
|
||||
|
||||
188
app-instance/backend/beaver/skills/publisher/service.py
Normal file
188
app-instance/backend/beaver/skills/publisher/service.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""Publishing, retirement, and rollback flows for Beaver skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver.skills.catalog.utils import strip_frontmatter
|
||||
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
|
||||
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
|
||||
|
||||
|
||||
class SkillPublisher:
|
||||
def __init__(self, store: SkillSpecStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def publish(self, skill_name: str, draft_id: str, publisher: str, notes: str = "") -> SkillVersion:
|
||||
draft = self._require_draft(skill_name, draft_id)
|
||||
if draft.status != SkillReviewState.APPROVED.value:
|
||||
raise ValueError("Draft must be approved before publish")
|
||||
if draft.proposal_kind == "retire_skill":
|
||||
raise ValueError("Retire proposals must be applied through apply_retire_proposal")
|
||||
|
||||
next_version = self._next_version(skill_name)
|
||||
content = self._render_skill_content(draft.proposed_frontmatter, draft.proposed_content)
|
||||
body = strip_frontmatter(content).strip()
|
||||
if not body:
|
||||
raise ValueError("Published skill content cannot be empty")
|
||||
version = SkillVersion(
|
||||
skill_name=skill_name,
|
||||
version=next_version,
|
||||
content_hash=canonical_hash(content),
|
||||
summary_hash=canonical_hash(body),
|
||||
created_at=_utc_now(),
|
||||
created_by=publisher,
|
||||
change_reason=notes or draft.reason,
|
||||
parent_version=draft.base_version,
|
||||
review_state=SkillReviewState.PUBLISHED.value,
|
||||
frontmatter=normalize_frontmatter(draft.proposed_frontmatter),
|
||||
summary=summarize_skill_content(body),
|
||||
tool_hints=self.store._extract_tool_hints(normalize_frontmatter(draft.proposed_frontmatter)),
|
||||
provenance={
|
||||
"draft_id": draft_id,
|
||||
"proposal_kind": draft.proposal_kind,
|
||||
"trigger_run_id": draft.trigger_run_id,
|
||||
"trigger_session_id": draft.trigger_session_id,
|
||||
},
|
||||
)
|
||||
self.store.write_skill_version(version, content)
|
||||
self.store.set_current_version(skill_name, next_version)
|
||||
|
||||
spec = self.store.get_skill_spec(skill_name)
|
||||
if spec is None:
|
||||
description = str(version.frontmatter.get("description") or skill_name)
|
||||
spec = SkillSpec(
|
||||
name=skill_name,
|
||||
display_name=skill_name,
|
||||
description=description,
|
||||
created_at=_utc_now(),
|
||||
updated_at=_utc_now(),
|
||||
current_version=next_version,
|
||||
status=SkillStatus.ACTIVE.value,
|
||||
tags=[],
|
||||
owners=[publisher],
|
||||
source_kind="managed",
|
||||
lineage=[],
|
||||
)
|
||||
else:
|
||||
spec.current_version = next_version
|
||||
spec.updated_at = _utc_now()
|
||||
spec.status = SkillStatus.ACTIVE.value
|
||||
if not spec.description:
|
||||
spec.description = str(version.frontmatter.get("description") or skill_name)
|
||||
self.store.write_skill_spec(spec)
|
||||
|
||||
draft.status = SkillReviewState.PUBLISHED.value
|
||||
self.store.write_draft(draft)
|
||||
self._refresh_indexes(skill_name, spec.status)
|
||||
return version
|
||||
|
||||
def apply_retire_proposal(self, skill_name: str, draft_id: str, actor: str, notes: str = "") -> SkillSpec:
|
||||
draft = self._require_draft(skill_name, draft_id)
|
||||
if draft.status != SkillReviewState.APPROVED.value:
|
||||
raise ValueError("Retire proposal must be approved before apply")
|
||||
if draft.proposal_kind != "retire_skill":
|
||||
raise ValueError("Only retire_skill proposals can be applied as retire proposals")
|
||||
|
||||
spec = self._require_spec(skill_name)
|
||||
if draft.base_version and spec.current_version and draft.base_version != spec.current_version:
|
||||
raise ValueError(
|
||||
f"Retire proposal targets {draft.base_version}, but current version is {spec.current_version}"
|
||||
)
|
||||
|
||||
reason = notes or draft.reason
|
||||
spec.status = SkillStatus.DISABLED.value
|
||||
spec.updated_at = _utc_now()
|
||||
if actor and actor not in spec.owners:
|
||||
spec.owners.append(actor)
|
||||
spec.lineage.append(f"retire_proposal:{draft_id}:{reason}")
|
||||
self.store.write_skill_spec(spec)
|
||||
|
||||
draft.status = SkillReviewState.DISABLED.value
|
||||
self.store.write_draft(draft)
|
||||
self._refresh_indexes(skill_name, spec.status)
|
||||
return spec
|
||||
|
||||
def disable(self, skill_name: str, actor: str, reason: str) -> SkillSpec:
|
||||
spec = self._require_spec(skill_name)
|
||||
spec.status = SkillStatus.DISABLED.value
|
||||
spec.updated_at = _utc_now()
|
||||
if actor and actor not in spec.owners:
|
||||
spec.owners.append(actor)
|
||||
if reason:
|
||||
spec.lineage.append(f"disabled:{reason}")
|
||||
self.store.write_skill_spec(spec)
|
||||
self._refresh_indexes(skill_name, spec.status)
|
||||
return spec
|
||||
|
||||
def rollback(self, skill_name: str, target_version: str, actor: str, reason: str) -> SkillSpec:
|
||||
if self.store.read_published_skill(skill_name, target_version) is None:
|
||||
raise ValueError(f"Unknown skill version for rollback: {skill_name}/{target_version}")
|
||||
spec = self._require_spec(skill_name)
|
||||
spec.current_version = target_version
|
||||
spec.updated_at = _utc_now()
|
||||
spec.status = SkillStatus.ACTIVE.value
|
||||
if reason:
|
||||
spec.lineage.append(f"rollback:{target_version}:{reason}")
|
||||
if actor and actor not in spec.owners:
|
||||
spec.owners.append(actor)
|
||||
self.store.write_skill_spec(spec)
|
||||
self.store.set_current_version(skill_name, target_version)
|
||||
self._refresh_indexes(skill_name, spec.status)
|
||||
return spec
|
||||
|
||||
def _next_version(self, skill_name: str) -> str:
|
||||
versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")]
|
||||
if not versions:
|
||||
return "v0001"
|
||||
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
|
||||
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
|
||||
|
||||
@staticmethod
|
||||
def _render_skill_content(frontmatter: dict, body: str) -> str:
|
||||
normalized = normalize_frontmatter(frontmatter)
|
||||
if not normalized:
|
||||
return body.strip() + ("\n" if body.strip() else "")
|
||||
lines = ["---"]
|
||||
for key, value in normalized.items():
|
||||
if isinstance(value, list):
|
||||
lines.append(f"{key}:")
|
||||
for item in value:
|
||||
lines.append(f" - {item}")
|
||||
else:
|
||||
lines.append(f"{key}: {value}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(body.strip())
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
def _refresh_indexes(self, skill_name: str, status: str) -> None:
|
||||
published = self.store.read_index("published")
|
||||
disabled = self.store.read_index("disabled")
|
||||
if status == SkillStatus.DISABLED.value:
|
||||
if skill_name in published:
|
||||
published = [item for item in published if item != skill_name]
|
||||
if skill_name not in disabled:
|
||||
disabled.append(skill_name)
|
||||
else:
|
||||
if skill_name not in published:
|
||||
published.append(skill_name)
|
||||
disabled = [item for item in disabled if item != skill_name]
|
||||
self.store.update_index("published", published)
|
||||
self.store.update_index("disabled", disabled)
|
||||
|
||||
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||
draft = self.store.read_draft(skill_name, draft_id)
|
||||
if draft is None:
|
||||
raise ValueError(f"Draft not found: {skill_name}/{draft_id}")
|
||||
return draft
|
||||
|
||||
def _require_spec(self, skill_name: str) -> SkillSpec:
|
||||
spec = self.store.get_skill_spec(skill_name)
|
||||
if spec is None:
|
||||
raise ValueError(f"Skill spec not found: {skill_name}")
|
||||
return spec
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@ -41,10 +41,20 @@ class RuntimeSkillResolver:
|
||||
|
||||
activated_skills: list[SkillContext] = []
|
||||
for name in selected:
|
||||
raw_content = self.loader.load_skill(name)
|
||||
record = self.loader.get_skill_record(name)
|
||||
raw_content = self.loader.load_published_skill(name)
|
||||
content = strip_frontmatter(raw_content).strip() if raw_content else ""
|
||||
if not content:
|
||||
continue
|
||||
activated_skills.append(SkillContext(name=name, content=content))
|
||||
activated_skills.append(
|
||||
SkillContext(
|
||||
name=name,
|
||||
content=content,
|
||||
version=record.version if record is not None else "legacy",
|
||||
content_hash=(record.content_hash if record is not None and record.content_hash else ""),
|
||||
activation_reason="always_skill",
|
||||
tool_hints=list(record.tool_hints) if record is not None else [],
|
||||
)
|
||||
)
|
||||
|
||||
return ResolvedSkillSet(activated_skills=activated_skills)
|
||||
|
||||
@ -1,2 +1,6 @@
|
||||
"""Skill review workflow."""
|
||||
"""Skill review services."""
|
||||
|
||||
from .service import ReviewService
|
||||
|
||||
__all__ = ["ReviewService"]
|
||||
|
||||
76
app-instance/backend/beaver/skills/reviews/service.py
Normal file
76
app-instance/backend/beaver/skills/reviews/service.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Review workflow for Beaver skill drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpecStore
|
||||
|
||||
|
||||
class ReviewService:
|
||||
def __init__(self, store: SkillSpecStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def submit_for_review(self, skill_name: str, draft_id: str, reviewer_request: str, requested_by: str = "system") -> SkillReviewRecord:
|
||||
draft = self._require_draft(skill_name, draft_id)
|
||||
draft.status = SkillReviewState.IN_REVIEW.value
|
||||
self.store.write_draft(draft)
|
||||
review = SkillReviewRecord(
|
||||
review_id=uuid4().hex,
|
||||
draft_id=draft_id,
|
||||
skill_name=skill_name,
|
||||
requested_at=_utc_now(),
|
||||
requested_by=requested_by,
|
||||
status=SkillReviewState.IN_REVIEW.value,
|
||||
notes=reviewer_request,
|
||||
)
|
||||
self.store.write_review(review)
|
||||
return review
|
||||
|
||||
def approve(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord:
|
||||
draft = self._require_draft(skill_name, draft_id)
|
||||
draft.status = SkillReviewState.APPROVED.value
|
||||
self.store.write_draft(draft)
|
||||
review = SkillReviewRecord(
|
||||
review_id=uuid4().hex,
|
||||
draft_id=draft_id,
|
||||
skill_name=skill_name,
|
||||
requested_at=_utc_now(),
|
||||
requested_by=reviewer,
|
||||
status=SkillReviewState.APPROVED.value,
|
||||
reviewer=reviewer,
|
||||
reviewed_at=_utc_now(),
|
||||
notes=notes,
|
||||
)
|
||||
self.store.write_review(review)
|
||||
return review
|
||||
|
||||
def reject(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord:
|
||||
draft = self._require_draft(skill_name, draft_id)
|
||||
draft.status = SkillReviewState.REJECTED.value
|
||||
self.store.write_draft(draft)
|
||||
review = SkillReviewRecord(
|
||||
review_id=uuid4().hex,
|
||||
draft_id=draft_id,
|
||||
skill_name=skill_name,
|
||||
requested_at=_utc_now(),
|
||||
requested_by=reviewer,
|
||||
status=SkillReviewState.REJECTED.value,
|
||||
reviewer=reviewer,
|
||||
reviewed_at=_utc_now(),
|
||||
notes=notes,
|
||||
)
|
||||
self.store.write_review(review)
|
||||
return review
|
||||
|
||||
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||
draft = self.store.read_draft(skill_name, draft_id)
|
||||
if draft is None:
|
||||
raise ValueError(f"Draft not found: {skill_name}/{draft_id}")
|
||||
return draft
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
23
app-instance/backend/beaver/skills/specs/__init__.py
Normal file
23
app-instance/backend/beaver/skills/specs/__init__.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Structured skill lifecycle models and storage."""
|
||||
|
||||
from .models import (
|
||||
SkillActivationReceipt,
|
||||
SkillDraft,
|
||||
SkillReviewRecord,
|
||||
SkillReviewState,
|
||||
SkillSpec,
|
||||
SkillStatus,
|
||||
SkillVersion,
|
||||
)
|
||||
from .storage import SkillSpecStore
|
||||
|
||||
__all__ = [
|
||||
"SkillActivationReceipt",
|
||||
"SkillDraft",
|
||||
"SkillReviewRecord",
|
||||
"SkillReviewState",
|
||||
"SkillSpec",
|
||||
"SkillSpecStore",
|
||||
"SkillStatus",
|
||||
"SkillVersion",
|
||||
]
|
||||
267
app-instance/backend/beaver/skills/specs/models.py
Normal file
267
app-instance/backend/beaver/skills/specs/models.py
Normal file
@ -0,0 +1,267 @@
|
||||
"""Structured models for Beaver skill lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SkillReviewState(str, Enum):
|
||||
DRAFT = "draft"
|
||||
IN_REVIEW = "in_review"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
PUBLISHED = "published"
|
||||
DISABLED = "disabled"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class SkillStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillSpec:
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
current_version: str | None
|
||||
status: str = SkillStatus.ACTIVE.value
|
||||
tags: list[str] = field(default_factory=list)
|
||||
owners: list[str] = field(default_factory=list)
|
||||
source_kind: str = "workspace"
|
||||
lineage: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"current_version": self.current_version,
|
||||
"status": self.status,
|
||||
"tags": list(self.tags),
|
||||
"owners": list(self.owners),
|
||||
"source_kind": self.source_kind,
|
||||
"lineage": list(self.lineage),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SkillSpec":
|
||||
return cls(
|
||||
name=str(payload["name"]),
|
||||
display_name=str(payload.get("display_name") or payload["name"]),
|
||||
description=str(payload.get("description") or payload.get("display_name") or payload["name"]),
|
||||
created_at=str(payload.get("created_at") or ""),
|
||||
updated_at=str(payload.get("updated_at") or payload.get("created_at") or ""),
|
||||
current_version=_coerce_optional_str(payload.get("current_version")),
|
||||
status=str(payload.get("status") or SkillStatus.ACTIVE.value),
|
||||
tags=_coerce_string_list(payload.get("tags")),
|
||||
owners=_coerce_string_list(payload.get("owners")),
|
||||
source_kind=str(payload.get("source_kind") or "workspace"),
|
||||
lineage=_coerce_string_list(payload.get("lineage")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillVersion:
|
||||
skill_name: str
|
||||
version: str
|
||||
content_hash: str
|
||||
summary_hash: str
|
||||
created_at: str
|
||||
created_by: str
|
||||
change_reason: str
|
||||
parent_version: str | None = None
|
||||
review_state: str = SkillReviewState.PUBLISHED.value
|
||||
frontmatter: dict[str, Any] = field(default_factory=dict)
|
||||
summary: str = ""
|
||||
tool_hints: list[str] = field(default_factory=list)
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"skill_name": self.skill_name,
|
||||
"version": self.version,
|
||||
"content_hash": self.content_hash,
|
||||
"summary_hash": self.summary_hash,
|
||||
"created_at": self.created_at,
|
||||
"created_by": self.created_by,
|
||||
"change_reason": self.change_reason,
|
||||
"parent_version": self.parent_version,
|
||||
"review_state": self.review_state,
|
||||
"frontmatter": dict(self.frontmatter),
|
||||
"summary": self.summary,
|
||||
"tool_hints": list(self.tool_hints),
|
||||
"provenance": dict(self.provenance),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SkillVersion":
|
||||
return cls(
|
||||
skill_name=str(payload["skill_name"]),
|
||||
version=str(payload["version"]),
|
||||
content_hash=str(payload.get("content_hash") or ""),
|
||||
summary_hash=str(payload.get("summary_hash") or ""),
|
||||
created_at=str(payload.get("created_at") or ""),
|
||||
created_by=str(payload.get("created_by") or "unknown"),
|
||||
change_reason=str(payload.get("change_reason") or ""),
|
||||
parent_version=_coerce_optional_str(payload.get("parent_version")),
|
||||
review_state=str(payload.get("review_state") or SkillReviewState.PUBLISHED.value),
|
||||
frontmatter=dict(payload.get("frontmatter") or {}),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
tool_hints=_coerce_string_list(payload.get("tool_hints")),
|
||||
provenance=dict(payload.get("provenance") or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillDraft:
|
||||
draft_id: str
|
||||
skill_name: str
|
||||
base_version: str | None
|
||||
proposed_content: str
|
||||
proposed_frontmatter: dict[str, Any]
|
||||
created_at: str
|
||||
created_by: str
|
||||
trigger_run_id: str | None = None
|
||||
trigger_session_id: str | None = None
|
||||
reason: str = ""
|
||||
status: str = SkillReviewState.DRAFT.value
|
||||
evidence_refs: list[dict[str, Any]] = field(default_factory=list)
|
||||
proposal_kind: str = "revise_skill"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"draft_id": self.draft_id,
|
||||
"skill_name": self.skill_name,
|
||||
"base_version": self.base_version,
|
||||
"proposed_content": self.proposed_content,
|
||||
"proposed_frontmatter": dict(self.proposed_frontmatter),
|
||||
"created_at": self.created_at,
|
||||
"created_by": self.created_by,
|
||||
"trigger_run_id": self.trigger_run_id,
|
||||
"trigger_session_id": self.trigger_session_id,
|
||||
"reason": self.reason,
|
||||
"status": self.status,
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
"proposal_kind": self.proposal_kind,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SkillDraft":
|
||||
return cls(
|
||||
draft_id=str(payload["draft_id"]),
|
||||
skill_name=str(payload["skill_name"]),
|
||||
base_version=_coerce_optional_str(payload.get("base_version")),
|
||||
proposed_content=str(payload.get("proposed_content") or ""),
|
||||
proposed_frontmatter=dict(payload.get("proposed_frontmatter") or {}),
|
||||
created_at=str(payload.get("created_at") or ""),
|
||||
created_by=str(payload.get("created_by") or "unknown"),
|
||||
trigger_run_id=_coerce_optional_str(payload.get("trigger_run_id")),
|
||||
trigger_session_id=_coerce_optional_str(payload.get("trigger_session_id")),
|
||||
reason=str(payload.get("reason") or ""),
|
||||
status=str(payload.get("status") or SkillReviewState.DRAFT.value),
|
||||
evidence_refs=list(payload.get("evidence_refs") or []),
|
||||
proposal_kind=str(payload.get("proposal_kind") or "revise_skill"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillReviewRecord:
|
||||
review_id: str
|
||||
draft_id: str
|
||||
skill_name: str
|
||||
requested_at: str
|
||||
requested_by: str
|
||||
status: str
|
||||
reviewer: str | None = None
|
||||
reviewed_at: str | None = None
|
||||
notes: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"review_id": self.review_id,
|
||||
"draft_id": self.draft_id,
|
||||
"skill_name": self.skill_name,
|
||||
"requested_at": self.requested_at,
|
||||
"requested_by": self.requested_by,
|
||||
"status": self.status,
|
||||
"reviewer": self.reviewer,
|
||||
"reviewed_at": self.reviewed_at,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SkillReviewRecord":
|
||||
return cls(
|
||||
review_id=str(payload["review_id"]),
|
||||
draft_id=str(payload["draft_id"]),
|
||||
skill_name=str(payload["skill_name"]),
|
||||
requested_at=str(payload.get("requested_at") or ""),
|
||||
requested_by=str(payload.get("requested_by") or "unknown"),
|
||||
status=str(payload.get("status") or SkillReviewState.IN_REVIEW.value),
|
||||
reviewer=_coerce_optional_str(payload.get("reviewer")),
|
||||
reviewed_at=_coerce_optional_str(payload.get("reviewed_at")),
|
||||
notes=str(payload.get("notes") or ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillActivationReceipt:
|
||||
run_id: str
|
||||
session_id: str
|
||||
skill_name: str
|
||||
skill_version: str
|
||||
content_hash: str
|
||||
activated_at: str
|
||||
activation_reason: str
|
||||
tool_hints: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": self.run_id,
|
||||
"session_id": self.session_id,
|
||||
"skill_name": self.skill_name,
|
||||
"skill_version": self.skill_version,
|
||||
"content_hash": self.content_hash,
|
||||
"activated_at": self.activated_at,
|
||||
"activation_reason": self.activation_reason,
|
||||
"tool_hints": list(self.tool_hints),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SkillActivationReceipt":
|
||||
return cls(
|
||||
run_id=str(payload["run_id"]),
|
||||
session_id=str(payload["session_id"]),
|
||||
skill_name=str(payload["skill_name"]),
|
||||
skill_version=str(payload["skill_version"]),
|
||||
content_hash=str(payload.get("content_hash") or ""),
|
||||
activated_at=str(payload.get("activated_at") or ""),
|
||||
activation_reason=str(payload.get("activation_reason") or ""),
|
||||
tool_hints=_coerce_string_list(payload.get("tool_hints")),
|
||||
)
|
||||
|
||||
|
||||
def _coerce_optional_str(value: Any) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _coerce_string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if text:
|
||||
result.append(text)
|
||||
return result
|
||||
42
app-instance/backend/beaver/skills/specs/serialization.py
Normal file
42
app-instance/backend/beaver/skills/specs/serialization.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Serialization helpers for structured skill lifecycle objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def json_dumps(payload: Any) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def canonical_hash(text: str) -> str:
|
||||
return sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_frontmatter(frontmatter: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = dict(frontmatter or {})
|
||||
normalized: dict[str, Any] = {}
|
||||
for key, value in raw.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if cleaned:
|
||||
normalized[str(key)] = cleaned
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
items = [str(item).strip() for item in value if str(item).strip()]
|
||||
normalized[str(key)] = items
|
||||
continue
|
||||
normalized[str(key)] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def summarize_skill_content(content: str, *, max_lines: int = 3, max_chars: int = 240) -> str:
|
||||
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return ""
|
||||
summary = " ".join(lines[:max_lines]).strip()
|
||||
return summary[:max_chars].strip()
|
||||
268
app-instance/backend/beaver/skills/specs/storage.py
Normal file
268
app-instance/backend/beaver/skills/specs/storage.py
Normal file
@ -0,0 +1,268 @@
|
||||
"""File-backed storage for Beaver skill lifecycle artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
from .models import SkillDraft, SkillReviewRecord, SkillSpec, SkillVersion
|
||||
from .serialization import canonical_hash, json_dumps, normalize_frontmatter, summarize_skill_content
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LoadedSkillVersion:
|
||||
version: SkillVersion
|
||||
content: str
|
||||
|
||||
|
||||
class SkillSpecStore:
|
||||
"""Manage structured skill lifecycle state inside the workspace."""
|
||||
|
||||
def __init__(self, workspace: str | Path) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.root = self.workspace / "skills"
|
||||
self.index_dir = self.root / "_index"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.index_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def list_published_skill_names(self) -> list[str]:
|
||||
names: list[str] = []
|
||||
for child in self._iter_skill_dirs():
|
||||
if not self._has_published_representation(child):
|
||||
continue
|
||||
spec = self.get_skill_spec(child.name)
|
||||
if spec is not None and spec.status != "active":
|
||||
continue
|
||||
names.append(child.name)
|
||||
return names
|
||||
|
||||
def list_skill_specs(self) -> list[SkillSpec]:
|
||||
specs: list[SkillSpec] = []
|
||||
for name in self.list_skill_names():
|
||||
spec = self.get_skill_spec(name)
|
||||
if spec is not None:
|
||||
specs.append(spec)
|
||||
return specs
|
||||
|
||||
def list_skill_names(self) -> list[str]:
|
||||
return [child.name for child in self._iter_skill_dirs()]
|
||||
|
||||
def get_skill_spec(self, name: str) -> SkillSpec | None:
|
||||
directory = self._skill_dir(name)
|
||||
path = directory / "skill.json"
|
||||
if path.exists():
|
||||
return SkillSpec.from_dict(self._read_json(path))
|
||||
if not self._has_published_representation(directory):
|
||||
return None
|
||||
legacy = self.read_published_skill(name)
|
||||
if legacy is None:
|
||||
return None
|
||||
return SkillSpec(
|
||||
name=name,
|
||||
display_name=name,
|
||||
description=str(legacy.version.frontmatter.get("description") or name),
|
||||
created_at=legacy.version.created_at,
|
||||
updated_at=legacy.version.created_at,
|
||||
current_version=legacy.version.version,
|
||||
status="active",
|
||||
tags=[],
|
||||
owners=[],
|
||||
source_kind="legacy",
|
||||
lineage=[],
|
||||
)
|
||||
|
||||
def write_skill_spec(self, spec: SkillSpec) -> None:
|
||||
directory = self._skill_dir(spec.name)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json(directory / "skill.json", spec.to_dict())
|
||||
|
||||
def get_current_version(self, name: str) -> str | None:
|
||||
directory = self._skill_dir(name)
|
||||
current_path = directory / "current.json"
|
||||
if current_path.exists():
|
||||
return str(self._read_json(current_path).get("current_version") or "") or None
|
||||
if (directory / "SKILL.md").exists():
|
||||
return "legacy"
|
||||
spec = self.get_skill_spec(name)
|
||||
if spec is not None and spec.current_version:
|
||||
return spec.current_version
|
||||
return None
|
||||
|
||||
def set_current_version(self, name: str, version: str) -> None:
|
||||
directory = self._skill_dir(name)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json(directory / "current.json", {"current_version": version})
|
||||
spec = self.get_skill_spec(name)
|
||||
if spec is not None:
|
||||
spec.current_version = version
|
||||
self.write_skill_spec(spec)
|
||||
|
||||
def list_versions(self, name: str) -> list[str]:
|
||||
directory = self._skill_dir(name) / "versions"
|
||||
if not directory.exists():
|
||||
current = self.get_current_version(name)
|
||||
return [current] if current else []
|
||||
versions: list[str] = []
|
||||
for child in sorted(directory.iterdir()):
|
||||
if child.is_dir():
|
||||
versions.append(child.name)
|
||||
return versions
|
||||
|
||||
def read_published_skill(self, name: str, version: str | None = None) -> LoadedSkillVersion | None:
|
||||
requested_version = version or self.get_current_version(name)
|
||||
if requested_version is None:
|
||||
return None
|
||||
|
||||
directory = self._skill_dir(name)
|
||||
if requested_version == "legacy":
|
||||
skill_file = directory / "SKILL.md"
|
||||
if not skill_file.exists():
|
||||
return None
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = parse_frontmatter(content)
|
||||
normalized_frontmatter = normalize_frontmatter(frontmatter)
|
||||
tool_hints = self._extract_tool_hints(normalized_frontmatter)
|
||||
loaded = SkillVersion(
|
||||
skill_name=name,
|
||||
version="legacy",
|
||||
content_hash=canonical_hash(content),
|
||||
summary_hash=canonical_hash(body),
|
||||
created_at="legacy",
|
||||
created_by="legacy",
|
||||
change_reason="legacy_import",
|
||||
review_state="published",
|
||||
frontmatter=normalized_frontmatter,
|
||||
summary=summarize_skill_content(body),
|
||||
tool_hints=tool_hints,
|
||||
provenance={"source_kind": "legacy"},
|
||||
)
|
||||
return LoadedSkillVersion(version=loaded, content=content)
|
||||
|
||||
version_dir = directory / "versions" / requested_version
|
||||
version_file = version_dir / "version.json"
|
||||
skill_file = version_dir / "SKILL.md"
|
||||
if not version_file.exists() or not skill_file.exists():
|
||||
return None
|
||||
payload = self._read_json(version_file)
|
||||
loaded = SkillVersion.from_dict(payload)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
return LoadedSkillVersion(version=loaded, content=content)
|
||||
|
||||
def write_skill_version(self, version: SkillVersion, content: str) -> None:
|
||||
version_dir = self._skill_dir(version.skill_name) / "versions" / version.version
|
||||
version_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json(version_dir / "version.json", version.to_dict())
|
||||
self._write_text(version_dir / "SKILL.md", content)
|
||||
|
||||
def list_drafts(self, skill_name: str | None = None) -> list[SkillDraft]:
|
||||
results: list[SkillDraft] = []
|
||||
names = [skill_name] if skill_name else self.list_skill_names()
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
drafts_dir = self._skill_dir(name) / "drafts"
|
||||
if not drafts_dir.exists():
|
||||
continue
|
||||
for path in sorted(drafts_dir.glob("draft-*.json")):
|
||||
results.append(SkillDraft.from_dict(self._read_json(path)))
|
||||
return results
|
||||
|
||||
def read_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None:
|
||||
path = self._skill_dir(skill_name) / "drafts" / f"draft-{draft_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return SkillDraft.from_dict(self._read_json(path))
|
||||
|
||||
def write_draft(self, draft: SkillDraft) -> None:
|
||||
drafts_dir = self._skill_dir(draft.skill_name) / "drafts"
|
||||
drafts_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json(drafts_dir / f"draft-{draft.draft_id}.json", draft.to_dict())
|
||||
|
||||
def list_reviews(self, skill_name: str, draft_id: str | None = None) -> list[SkillReviewRecord]:
|
||||
reviews_dir = self._skill_dir(skill_name) / "reviews"
|
||||
if not reviews_dir.exists():
|
||||
return []
|
||||
results: list[SkillReviewRecord] = []
|
||||
for path in sorted(reviews_dir.glob("review-*.json")):
|
||||
record = SkillReviewRecord.from_dict(self._read_json(path))
|
||||
if draft_id and record.draft_id != draft_id:
|
||||
continue
|
||||
results.append(record)
|
||||
return results
|
||||
|
||||
def write_review(self, review: SkillReviewRecord) -> None:
|
||||
reviews_dir = self._skill_dir(review.skill_name) / "reviews"
|
||||
reviews_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json(reviews_dir / f"review-{review.review_id}.json", review.to_dict())
|
||||
|
||||
def update_index(self, index_name: str, values: list[str]) -> None:
|
||||
self._write_json(self.index_dir / f"{index_name}.json", {"items": list(dict.fromkeys(values))})
|
||||
|
||||
def read_index(self, index_name: str) -> list[str]:
|
||||
path = self.index_dir / f"{index_name}.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
payload = self._read_json(path)
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = payload.get("items")
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [str(item) for item in items if str(item).strip()]
|
||||
|
||||
def archive_current_version(self, skill_name: str, version: str) -> None:
|
||||
version_dir = self._skill_dir(skill_name) / "versions" / version
|
||||
if not version_dir.exists():
|
||||
return
|
||||
archive_dir = self._skill_dir(skill_name) / "archive" / version
|
||||
archive_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
if archive_dir.exists():
|
||||
return
|
||||
version_dir.rename(archive_dir)
|
||||
|
||||
def _has_published_representation(self, directory: Path) -> bool:
|
||||
return (
|
||||
(directory / "SKILL.md").exists()
|
||||
or (directory / "current.json").exists()
|
||||
or (directory / "versions").exists()
|
||||
)
|
||||
|
||||
def _skill_dir(self, name: str) -> Path:
|
||||
return self.root / name
|
||||
|
||||
def _iter_skill_dirs(self) -> list[Path]:
|
||||
return [
|
||||
child
|
||||
for child in sorted(self.root.iterdir())
|
||||
if child.is_dir() and not child.name.startswith("_")
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _extract_tool_hints(frontmatter: dict[str, Any]) -> list[str]:
|
||||
raw = frontmatter.get("tools")
|
||||
if isinstance(raw, list):
|
||||
return [str(item).strip() for item in raw if str(item).strip()]
|
||||
if isinstance(raw, str):
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Expected JSON object in {path}")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json_dumps(payload) + "\n", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _write_text(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
Reference in New Issue
Block a user