Skip to content
← How we build

architecture

MarketCompass — Skill Factory Design

Status: Design accepted for Phase 1 implementation Owner: CompassFoundry Labs Depends on: architecture-overview.md §7c (Skill Factory: Construction, Not Behavior) Referenced by: architecture-overview.md §15 (Forward Compatibility), phase1-implementation-plan.md Milestone 5


1. Purpose and Scope

This document designs the Skill Factory: a component that constructs Skill instances from config/skills/registry.yaml entries, wiring each one to its declared LLMService, Tools, and (per ADR-008) RulesEngineService dependencies — without a hand-written constructor call for every Skill.

What this document explicitly does not do: it does not let YAML describe what a Skill does. Per architecture-overview.md §7c, that boundary is architectural, not a detail left to this document to relitigate. Everywhere below, "the factory" means an object-construction mechanism, not an interpreter.

2. Why This Needed a Decision at All

The project already has three precedents for "should X live in YAML or in code," and each was decided on the same axis: declarative composition is fine in YAML; behavior belongs in code.

Precedent Declarative in YAML Stays in code
Routing (ADR-002) Confidence thresholds, required Skills per route Pattern-matching/entity-extraction logic itself
Rules Engine (ADR-008) Thresholds, enabled/disabled flags The evaluator that applies them
Skill Factory (this doc) Which Tools/services a Skill depends on What the Skill does with them

The Skill Factory is this same axis applied a third time, not a new question. It's documented explicitly anyway because "create Skills in YAML" is easy to interpret more broadly than intended, and because the boundary has a specific, named failure mode worth naming once rather than rediscovering per Skill: it would recreate a bespoke, YAML-shaped scripting language for exactly the kind of branchy, LLM-prompt-sensitive logic that YAML is worst at expressing and hardest to test, debug, and secure.

3. What the Factory Does

config/skills/registry.yaml entry
    ↓
SkillFactory.build(entry) 
    ↓
Resolves: LLMService (shared singleton), 
          required Tools (from Tool Registry),
          RulesEngineService (if declared, per ADR-008 — optional)
    ↓
Constructs the named Skill class with resolved dependencies injected
    ↓
Skill instance, ready for the Skill Registry to hand to the Orchestrator

The factory's job ends at construction. Once a Skill instance exists, the factory has no further role — execute() is ordinary Python, calling LLMService.generate() and, where applicable, RulesEngineService.evaluate() exactly as already designed in llm-service-design.md and rules-engine-design.md. The factory does not intercept, wrap, or participate in that call.

FallbackAgentSkill (Phase 2+, skills-design.md §3a) needs no special-casing here. It's constructed from a config/skills/registry.yaml entry exactly like FinancialSkill or CompanySkill — declared Tools, llm_config_ref, resolved and injected the same way. That its execute() happens to use a LangChain agent internally to pick among its declared Tools (langchain-rag-router-decision.md) is invisible to the factory: construction only ever sees "this Skill declares these dependencies," never how execute() uses them.

4. What a YAML Skill Entry Declares

Extends the existing config/skills/registry.yaml shape (already established in architecture-overview.md §4) — no new file, no new top-level config tree, just a fuller schema for entries that already exist:

skills:
  financial_skill:
    class: FinancialSkill          # dotted import path or registered class name
    required_tools:
      - financial_metrics_tool
      - company_profile_tool
    rules_engine: true              # optional — per ADR-008, only financial-analysis Skills declare this
    llm_config_ref: default         # which LLMService model/provider policy to use, per llm-service-design.md

  news_skill:
    class: NewsSkill
    required_tools:
      - news_tool
    rules_engine: false
    llm_config_ref: default

Every field here is a reference (a Tool name, a boolean, a config key) — never a template string, a conditional, or an expression. That's the enforced boundary from §7c, made concrete: if a future entry needs anything beyond "which dependency, which reference," it's a signal that entry needs a real Python class change, not a richer YAML schema.

What is deliberately never a YAML field here

  • Prompt templates or prompt-construction logic — lives inside the Skill class, calling LLMService.
  • Conditional/branching logic ("if confidence < X, do Y") — lives inside the Skill class.
  • Response shaping / how SkillResult is assembled — lives inside the Skill class.
  • Anything resembling a step sequence or control flow — the moment YAML needs an ordering concept, it has become a scripting language, which is exactly the outcome this design avoids.

5. The class: Field — Resolution Strategy

The one field that could look like it reintroduces dynamic discovery deserves its own note, since architecture-overview.md §16 already rules that out as a non-goal ("No dynamic/plugin discovery for Skills or Tools — explicit registries only").

Adopted approach: a small, explicit import map inside the factory module itself — not importlib string-based dynamic imports, not filesystem scanning:

# backend/src/skills/factory.py
from backend.src.skills.financial_skill import FinancialSkill
from backend.src.skills.company_skill import CompanySkill
from backend.src.skills.news_skill import NewsSkill
from backend.src.skills.generic_chat_skill import GenericChatSkill

SKILL_CLASS_MAP: dict[str, type[BaseSkill]] = {
    "FinancialSkill": FinancialSkill,
    "CompanySkill": CompanySkill,
    "NewsSkill": NewsSkill,
    "GenericChatSkill": GenericChatSkill,
}

Adding a Skill still requires: (1) write the Python class, (2) add one line to SKILL_CLASS_MAP, (3) add the YAML entry. This is a deliberately small amount of code-side friction, kept on purpose — it's the difference between "explicit registry" (this) and "plugin auto-discovery" (the thing §16 rules out). A YAML entry referencing a class not in the map fails fast at startup with a clear error, the same fail-fast discipline already used for malformed config elsewhere in this architecture (§4, §13).

This also means the "no new Python for a new Skill" framing from the original idea isn't fully true, and shouldn't be oversold as a goal: a new Skill always needs a new Python class, because a new Skill always needs new behavior. What the factory removes is the boilerplate of wiring that class to its dependencies by hand — not the need to write the class.

6. Contracts

No new Models-layer types are needed. SkillFactory.build() consumes the existing config/skills/registry.yaml schema (extended per §4 above) and produces a BaseSkill instance — the same type every hand-constructed Skill already is. This is deliberate: the factory changes how a Skill comes into existence, not what a Skill is, so it introduces no new contract for other layers to depend on.

class SkillFactory:
    def __init__(self, llm_service: LLMService, tool_registry: ToolRegistry, rules_engine_service: RulesEngineService | None):
        ...

    def build(self, entry: SkillRegistryEntry) -> BaseSkill:
        ...

    def build_all(self, registry_path: Path) -> dict[str, BaseSkill]:
        ...

rules_engine_service is typed | None at the factory level because it doesn't exist yet in Phase 1 (Phase 3 deliverable, per ADR-008) — the factory's constructor signature is written now to already accept it, so Phase 3 wires in a real instance without touching the factory's shape, the same forward-compatible pattern already used for ContextBuilder's no-op.

7. Responsibilities (Layer Contract)

Following the format already used in architecture-overview.md §10 and rules-engine-design.md §7:

Responsibility Must NOT do
Resolve a YAML registry entry's declared dependencies (Tools, LLMService, RulesEngineService) and inject them into the named Skill class's constructor Interpret, template, or execute anything from the YAML entry as behavior
Fail fast at startup on an unresolvable class: reference or a missing declared Tool Fall back to dynamic import or filesystem scanning if a class isn't in SKILL_CLASS_MAP
Produce ordinary BaseSkill instances the existing Skill Registry already knows how to hold Participate in execute() — once built, the factory has no runtime role

8. Testing Strategy

Extends the table in architecture-overview.md §14:

Layer Test Type Key Assertions
Skill Factory Unit A known-good registry entry produces a Skill instance with the correct injected Tools/LLMService/RulesEngineService; a class: value absent from SKILL_CLASS_MAP fails fast with a clear error, not a silent no-op
Skill Factory Contract Every Skill referenced in config/skills/registry.yaml exists in SKILL_CLASS_MAP (this extends, not duplicates, the existing Milestone 5/10 contract test that Skills' declared Tools exist in config/tools/registry.yaml)
Individual Skills Unchanged Skills are still tested as ordinary Python classes with mocked dependencies, per phase1-implementation-plan.md Milestone 5 — the factory's existence doesn't change how a Skill itself is tested, only how it's constructed in production wiring

9. Forward Compatibility

Future Need What this design already provides What gets added, not rewritten
Phase 3 Rules Engine wiring SkillFactory.__init__ already accepts rules_engine_service: RulesEngineService | None Pass a real instance instead of None; existing Skills with rules_engine: true start receiving it automatically
New Skills in Phase 4 (e.g. supporting the Investment Thesis Generator) Registry entry + SKILL_CLASS_MAP line + Skill class — same three-step process as today Just more entries; the factory's shape doesn't change
A future need for genuinely dynamic/pluggable Skills (e.g. third-party Skill packages) N/A — this is explicitly a non-goal, not a gap Would be a new architectural decision, not an extension of this design — flagged here so it isn't mistaken for an oversight

10. Non-Goals

  • No YAML-expressed prompt templates, conditionals, or control flow — see §4.
  • No dynamic import (importlib) or filesystem-based class discovery — see §5; the explicit SKILL_CLASS_MAP is the deliberate boundary against architecture-overview.md §16's "no plugin auto-discovery" non-goal.
  • No attempt to eliminate all Python for a new Skill — a new Skill always means new behavior, which always means a new class. The factory removes wiring boilerplate, not the need to write the Skill.
  • No runtime role for the factory after construction — it does not wrap, intercept, or participate in execute().

Source: docs/architecture/skill-factory-design.md

Follow the work.

Occasional updates on SignalFoundry, MarketCompass, and what we are building at CompassFoundry Labs.

No spam. Unsubscribe anytime.