backend/src/skills/factory.py
"""Skill Factory — config/skills/registry.yaml entries -> constructed BaseSkill instances. ``class:`` values resolve against an explicit hand-maintained map — never ``importlib`` or filesystem scanning (skill-factory-design.md §5). """ from __future__ import annotations from pathlib import Path from typing import Any import yaml from config.paths import resolve_repo_path from config.schemas import SkillRegistryEntry, SkillsRegistryConfig from config.settings import get_settings from observability import log_event from services.llm_service import LLMService from skills.base_skill import BaseSkill from skills.company_skill import CompanySkill from skills.financial_skill import FinancialSkill from skills.generic_chat_skill import GenericChatSkill from skills.news_skill import NewsSkill from tools.registry_loader import ToolRegistry, ToolRegistryError SKILL_CLASS_MAP: dict[str, type[BaseSkill]] = { "CompanySkill": CompanySkill, "FinancialSkill": FinancialSkill, "NewsSkill": NewsSkill, "GenericChatSkill": GenericChatSkill, } class SkillFactoryError(RuntimeError): """Raised when the skills registry cannot be loaded or a class cannot be resolved.""" def resolve_skill_class(class_name: str) -> type[BaseSkill]: """Resolve a registry ``class:`` value via ``SKILL_CLASS_MAP`` (fail fast).""" try: return SKILL_CLASS_MAP[class_name] except KeyError as exc: known = ", ".join(sorted(SKILL_CLASS_MAP)) or "(none)" raise SkillFactoryError( f"Unknown skill class '{class_name}'. " f"Add it to SKILL_CLASS_MAP in skills/factory.py. Known: {known}" ) from exc def load_skills_config(registry_path: Path | None = None) -> SkillsRegistryConfig: """Load and schema-validate ``config/skills/registry.yaml``.""" settings = get_settings() path = registry_path or resolve_repo_path(settings.skills_registry_path) log_event( "skills_registry_load", component="SkillFactory", fields={"path": str(path)}, ) try: with path.open(encoding="utf-8") as handle: data: Any = yaml.safe_load(handle) except OSError as exc: raise SkillFactoryError(f"Cannot read skills registry '{path}': {exc}") from exc except yaml.YAMLError as exc: raise SkillFactoryError(f"Invalid YAML in skills registry '{path}': {exc}") from exc if data is None: data = {} try: return SkillsRegistryConfig.model_validate(data) except Exception as exc: raise SkillFactoryError( f"Schema validation failed for skills registry '{path}': {exc}" ) from exc class SkillFactory: """Constructs ``BaseSkill`` instances from ``config/skills/registry.yaml`` entries. Resolves each entry's declared Tools (from the Tool Registry), ``LLMService`` (shared singleton), and ``RulesEngineService`` (Phase 3+, ``None`` today) and injects them into the named Skill class's constructor. Once built, the factory has no further role — ``execute()`` is ordinary Python (skill-factory-design.md §3). """ def __init__( self, llm_service: LLMService, tool_registry: ToolRegistry, rules_engine_service: Any | None = None, ) -> None: self._llm_service = llm_service self._tool_registry = tool_registry self._rules_engine_service = rules_engine_service def build(self, name: str, entry: SkillRegistryEntry) -> BaseSkill: skill_cls = resolve_skill_class(entry.class_) tool_kwargs: dict[str, Any] = {} for tool_name in entry.required_tools: try: tool_kwargs[tool_name] = self._tool_registry.get(tool_name) except ToolRegistryError as exc: raise SkillFactoryError( f"Skill registry entry '{name}' declares required tool " f"'{tool_name}', which is not in the Tool Registry: {exc}" ) from exc # Phase 1: every Skill receives None here regardless of `rules_engine:` — # RulesEngineService doesn't exist until Phase 3 (rules-engine-design.md). # `self._rules_engine_service` stays None until a real instance is wired in. rules_engine_service = self._rules_engine_service if entry.rules_engine else None try: skill = skill_cls( llm_service=self._llm_service, rules_engine_service=rules_engine_service, **tool_kwargs, ) except TypeError as exc: raise SkillFactoryError( f"Failed to construct skill '{name}' (class '{entry.class_}') " f"with required_tools={entry.required_tools}: {exc}" ) from exc if skill.name != name: raise SkillFactoryError( f"Registry key '{name}' does not match Skill.name '{skill.name}' " f"for class '{entry.class_}'." ) return skill def build_all(self, registry_path: Path | None = None) -> dict[str, BaseSkill]: config = load_skills_config(registry_path) skills: dict[str, BaseSkill] = {} for name, entry in config.skills.items(): skills[name] = self.build(name, entry) log_event( "skills_registry_loaded", component="SkillFactory", fields={"skill_count": len(skills), "skills": sorted(skills.keys())}, ) return skills
Follow the work.
Occasional updates on SignalFoundry, MarketCompass, and what we are building at CompassFoundry Labs.