mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-04-25 11:18:06 +00:00
177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
"""Agent skill configuration models."""
|
|
|
|
from dataclasses import dataclass, field, replace
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Mapping
|
|
|
|
import yaml
|
|
|
|
from entity.configs.base import (
|
|
BaseConfig,
|
|
ConfigError,
|
|
ConfigFieldSpec,
|
|
EnumOption,
|
|
optional_bool,
|
|
extend_path,
|
|
require_mapping,
|
|
)
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
DEFAULT_SKILLS_ROOT = (REPO_ROOT / "skills").resolve()
|
|
def _discover_default_skills() -> List[tuple[str, str]]:
|
|
if not DEFAULT_SKILLS_ROOT.exists() or not DEFAULT_SKILLS_ROOT.is_dir():
|
|
return []
|
|
|
|
discovered: List[tuple[str, str]] = []
|
|
for candidate in sorted(DEFAULT_SKILLS_ROOT.iterdir()):
|
|
if not candidate.is_dir():
|
|
continue
|
|
skill_file = candidate / "SKILL.md"
|
|
if not skill_file.is_file():
|
|
continue
|
|
try:
|
|
frontmatter = _parse_frontmatter(skill_file)
|
|
except Exception:
|
|
continue
|
|
raw_name = frontmatter.get("name")
|
|
raw_description = frontmatter.get("description")
|
|
if not isinstance(raw_name, str) or not raw_name.strip():
|
|
continue
|
|
if not isinstance(raw_description, str) or not raw_description.strip():
|
|
continue
|
|
discovered.append((raw_name.strip(), raw_description.strip()))
|
|
return discovered
|
|
|
|
|
|
def _parse_frontmatter(skill_file: Path) -> Mapping[str, object]:
|
|
text = skill_file.read_text(encoding="utf-8")
|
|
if not text.startswith("---"):
|
|
raise ValueError("missing frontmatter")
|
|
lines = text.splitlines()
|
|
end_idx = None
|
|
for idx in range(1, len(lines)):
|
|
if lines[idx].strip() == "---":
|
|
end_idx = idx
|
|
break
|
|
if end_idx is None:
|
|
raise ValueError("missing closing delimiter")
|
|
payload = "\n".join(lines[1:end_idx])
|
|
data = yaml.safe_load(payload) or {}
|
|
if not isinstance(data, Mapping):
|
|
raise ValueError("frontmatter must be a mapping")
|
|
return data
|
|
|
|
|
|
@dataclass
|
|
class AgentSkillSelectionConfig(BaseConfig):
|
|
name: str
|
|
|
|
FIELD_SPECS = {
|
|
"name": ConfigFieldSpec(
|
|
name="name",
|
|
display_name="Skill Name",
|
|
type_hint="str",
|
|
required=True,
|
|
description="Discovered skill name from the default repo-level skills directory.",
|
|
),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillSelectionConfig":
|
|
mapping = require_mapping(data, path)
|
|
name = mapping.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ConfigError("skill name is required", extend_path(path, "name"))
|
|
return cls(name=name.strip(), path=path)
|
|
|
|
@classmethod
|
|
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
|
|
specs = super().field_specs()
|
|
name_spec = specs.get("name")
|
|
if name_spec is None:
|
|
return specs
|
|
|
|
discovered = _discover_default_skills()
|
|
enum_values = [name for name, _ in discovered] or None
|
|
enum_options = [
|
|
EnumOption(value=name, label=name, description=description)
|
|
for name, description in discovered
|
|
] or None
|
|
description = name_spec.description or "Skill name"
|
|
if not discovered:
|
|
description = (
|
|
f"{description} (no skills found in {DEFAULT_SKILLS_ROOT})"
|
|
)
|
|
else:
|
|
description = (
|
|
f"{description} Picker options come from {DEFAULT_SKILLS_ROOT}."
|
|
)
|
|
specs["name"] = replace(
|
|
name_spec,
|
|
enum=enum_values,
|
|
enum_options=enum_options,
|
|
description=description,
|
|
)
|
|
return specs
|
|
|
|
|
|
@dataclass
|
|
class AgentSkillsConfig(BaseConfig):
|
|
enabled: bool = False
|
|
allow: List[str] = field(default_factory=list)
|
|
|
|
FIELD_SPECS = {
|
|
"enabled": ConfigFieldSpec(
|
|
name="enabled",
|
|
display_name="Enable Skills",
|
|
type_hint="bool",
|
|
required=False,
|
|
default=False,
|
|
description="Enable Agent Skills discovery and the built-in skill tools for this agent.",
|
|
advance=True,
|
|
),
|
|
"allow": ConfigFieldSpec(
|
|
name="allow",
|
|
display_name="Allowed Skills",
|
|
type_hint="list[AgentSkillSelectionConfig]",
|
|
required=False,
|
|
description="Optional allowlist of discovered skill names. Leave empty to expose every discovered skill.",
|
|
child=AgentSkillSelectionConfig,
|
|
advance=True,
|
|
),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillsConfig":
|
|
mapping = require_mapping(data, path)
|
|
enabled = optional_bool(mapping, "enabled", path, default=False)
|
|
if enabled is None:
|
|
enabled = False
|
|
|
|
allow = cls._coerce_allow_entries(mapping.get("allow"), field_path=extend_path(path, "allow"))
|
|
|
|
return cls(enabled=enabled, allow=allow, path=path)
|
|
|
|
@staticmethod
|
|
def _coerce_allow_entries(value: Any, *, field_path: str) -> List[str]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list):
|
|
raise ConfigError("expected list of skill entries", field_path)
|
|
|
|
result: List[str] = []
|
|
for idx, item in enumerate(value):
|
|
item_path = f"{field_path}[{idx}]"
|
|
if isinstance(item, str):
|
|
normalized = item.strip()
|
|
if normalized:
|
|
result.append(normalized)
|
|
continue
|
|
if isinstance(item, Mapping):
|
|
entry = AgentSkillSelectionConfig.from_dict(item, path=item_path)
|
|
result.append(entry.name)
|
|
continue
|
|
raise ConfigError("expected skill entry mapping or string", item_path)
|
|
return result
|