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
SkillResultis 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 explicitSKILL_CLASS_MAPis the deliberate boundary againstarchitecture-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().