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:
2026-05-08 17:14:14 +08:00
parent 5ba5c7e4c1
commit 8a12c30141
93 changed files with 16724 additions and 1247 deletions

View 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",
]

View 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

View 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()

View 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")