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