Skip to content
← How we build

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.

No spam. Unsubscribe anytime.