Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ class RuntimeConfig:
action_dependencies: Optional[Dict[str, List[str]]] = None
knowledge_base: Optional[Dict[str, Any]] = None
mcp_servers: Optional[Any] = None
skills: Optional[List[str]] = None


def add_meta(
Expand Down Expand Up @@ -331,6 +332,7 @@ class ModeConfig:
action_execution_mode: Optional[str] = None
action_dependencies: Optional[Dict[str, List[str]]] = None
mcp_servers: Optional[Any] = None
skills: Optional[List[str]] = None

_raw_inputs: List[Dict] = field(default_factory=list)
_raw_llm: Optional[Dict] = None
Expand Down Expand Up @@ -377,6 +379,7 @@ def to_runtime_config(self, global_config: "ModeSystemConfig") -> RuntimeConfig:
action_dependencies=self.action_dependencies,
knowledge_base=global_config.knowledge_base,
mcp_servers=self.mcp_servers,
skills=self.skills,
)

def load_components(self, system_config: "ModeSystemConfig"):
Expand Down Expand Up @@ -610,6 +613,7 @@ def load_mode_config(config_name: str, mode_source_path: Optional[str] = None) -
_raw_backgrounds=mode_data.get("backgrounds", []),
_raw_lifecycle_hooks=mode_data.get("lifecycle_hooks", []),
_raw_mcp_servers=mode_data.get("mcp_servers", []),
skills=mode_data.get("skills", []),
)

mode_system_config.modes[mode_name] = mode_config
Expand Down
1 change: 1 addition & 0 deletions src/runtime/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def _build_mode_section(raw_config: dict) -> Dict:
"action_execution_mode": raw_config.get("action_execution_mode", "concurrent"),
"action_dependencies": raw_config.get("action_dependencies", {}),
"mcp_servers": raw_config.get("mcp_servers", []),
"skills": raw_config.get("skills", []),
}

@staticmethod
Expand Down
77 changes: 75 additions & 2 deletions src/runtime/cortex.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from runtime.manager import ModeManager
from simulators.orchestrator import SimulatorOrchestrator
from skills.orchestrator import SkillOrchestrator


class ModeCortexRuntime:
Expand Down Expand Up @@ -92,6 +93,7 @@ def __init__(
self.background_orchestrator: Optional[BackgroundOrchestrator] = None
self.input_orchestrator: Optional[InputOrchestrator] = None
self.mcp_orchestrator: Optional[MCPOrchestrator] = None
self.skill_orchestrator: Optional[SkillOrchestrator] = None

# Tasks for orchestrators
self.input_listener_task: Optional[asyncio.Task] = None
Expand Down Expand Up @@ -151,6 +153,17 @@ async def _initialize_mode(self, mode_name: str):
if self.current_config.mcp_servers:
self.mcp_orchestrator = MCPOrchestrator(self.current_config)

if self.current_config.skills:
self.skill_orchestrator = SkillOrchestrator(self.current_config.skills)
schema = self.skill_orchestrator.get_tool_schema()
if schema:
base_schemas = [
s
for s in self.current_config.cortex_llm.function_schemas
if s.get("function", {}).get("name") != "read_skill"
]
self.current_config.cortex_llm.function_schemas = base_schemas + [schema]

logging.info(f"Mode '{mode_name}' initialized successfully")

async def _handle_mode_transitions(self):
Expand Down Expand Up @@ -235,9 +248,12 @@ async def _stop_current_orchestrators(self) -> None:
self.background_orchestrator.stop()

if self.simulator_orchestrator:
logging.debug("Stopping simulator orchestrator")
self.simulator_orchestrator.stop()

if self.skill_orchestrator:
logging.debug("Clearing skill orchestrator")
self.skill_orchestrator = None

if self.action_orchestrator:
logging.debug("Stopping action orchestrator")
self.action_orchestrator.stop()
Expand Down Expand Up @@ -588,7 +604,64 @@ async def _tick(self, cortex_generation: int) -> None:
logging.info(f"Cortex loop generation {cortex_generation} invalidated during streaming, stopping")
return

if self.mcp_orchestrator:
# Skill round: read_skill tool calls inject instructions before MCP
if self.skill_orchestrator and output is not None:
skill_actions = [a for a in output.actions if a.type == "read_skill"]
if skill_actions:
succeeded_skill_calls = set()
skill_prompt = prompt

for skill_round_idx in range(3):
current_skill_actions = [a for a in output.actions if a.type == "read_skill"]
if not current_skill_actions:
break

# Dispatch OM1 actions immediately before skill recall
om1_pre_skill = [
a for a in output.actions if a.type != "read_skill" and not a.type.startswith("mcp_")
]
if om1_pre_skill:
await self.action_orchestrator.promise(om1_pre_skill)

skill_results = self.skill_orchestrator.execute_read_skills(
current_skill_actions, succeeded_skill_calls
)
if not skill_results:
break

skill_recall_prompt = self.skill_orchestrator.build_skill_recall_prompt(
skill_prompt, skill_results
)
skill_prompt = skill_recall_prompt

# Update the original prompt so MCP rounds inherit skill context
prompt = skill_prompt

if not self._is_generation_valid(cortex_generation, "skill recall prompt"):
return

try:
skill_streamed_output = None
async for skill_stream_output in self.current_config.cortex_llm.ask_stream(
skill_recall_prompt
):
if not self._is_generation_valid(cortex_generation, "skill streaming"):
return
if skill_stream_output is None:
continue
if skill_streamed_output is None:
skill_streamed_output = skill_stream_output
else:
skill_streamed_output.actions.extend(skill_stream_output.actions)
output = skill_streamed_output
except asyncio.CancelledError:
logging.info("Skill recall LLM call cancelled")
raise

if output is None:
break

if self.mcp_orchestrator and output is not None:
Comment on lines +607 to +664

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new skill-recall branch in _tick() introduces a multi-round control-flow (dispatch OM1 actions, read skills, recall LLM, and then proceed into MCP). There’s no unit test exercising this behavior (including deduplication across rounds and ensuring the MCP recall prompt inherits the augmented prompt). Adding a focused test in tests/runtime/test_cortex.py that simulates an LLM returning read_skill actions and verifies the subsequent recall + prompt propagation would help prevent regressions.

Copilot generated this review using guidance from repository custom instructions.
succeeded_calls = set()
original_prompt = prompt

Expand Down
9 changes: 9 additions & 0 deletions src/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from skills.loader import SkillEntry, SkillLoader
from skills.orchestrator import SkillOrchestrator, SkillProcessResult

__all__ = [
"SkillEntry",
"SkillLoader",
"SkillOrchestrator",
"SkillProcessResult",
]
169 changes: 169 additions & 0 deletions src/skills/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional


@dataclass
class SkillEntry:
"""A parsed skill definition.

Parameters
----------
name : str
Unique skill identifier.
description : str
Short description shown in the prompt catalog.
instructions : str
Full Markdown body.
source_path : Path
Absolute path to the ``SKILL.md`` file.
requires_tools : list[str]
tools required by this skill.
max_rounds : int
Max execution rounds for this skill.
priority : int
Priority among all skills.
"""

name: str
description: str
instructions: str
source_path: Path
requires_tools: List[str] = field(default_factory=list)
max_rounds: int = 8
priority: int = 10


_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)


def _parse_frontmatter(content: str) -> tuple:
"""Split a SKILL.md file into metadata and body.

Uses a simple line-based parser that handles flat key: value pairs
and list items. No external dependencies required.

Returns
-------
tuple[dict, str]
Parsed metadata dictionary and the Markdown body.
"""
match = _FRONTMATTER_RE.match(content)
if not match:
return {}, content

yaml_block = match.group(1)
body = match.group(2).strip()

meta: Dict = {}
current_key: Optional[str] = None
current_list: Optional[List[str]] = None

for line in yaml_block.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue

if stripped.startswith("- ") and current_key:
value = stripped[2:].strip().strip('"').strip("'")
if current_list is not None:
current_list.append(value)
continue

if ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")

if value:
meta[key] = value
current_key = key
current_list = None
else:
current_key = key
current_list = []
meta[key] = current_list

return meta, body


class SkillLoader:
"""Scan and parse SKILL.md files from a skills directory.

Parameters
----------
skills_dir : str
Root directory containing skill subdirectories.
"""

def __init__(self, skills_dir: str) -> None:
self.skills_dir = Path(skills_dir)
self.skills: Dict[str, SkillEntry] = {}
self._load_all()

def _load_all(self) -> None:
"""Scan and parse all SKILL.md files."""
if not self.skills_dir.exists():
logging.warning(f"Skills directory does not exist: {self.skills_dir}")
return

count = 0
for child in sorted(self.skills_dir.iterdir()):
if not child.is_dir():
continue
skill_file = child / "SKILL.md"
if not skill_file.exists():
continue
try:
entry = self._parse_skill(skill_file)
self.skills[entry.name] = entry
count += 1
logging.info(f"Loaded skill: {entry.name} ({skill_file})")
except Exception as exc:
logging.error(f"Failed to load skill from {skill_file}: {exc}")
Comment on lines +124 to +125

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This broad exception handler similarly logs only the exception string, losing the traceback. Using logging.exception(...) here would align with existing patterns (e.g., actions/orchestrator.py) and make it much easier to diagnose malformed SKILL.md files in the field.

Suggested change
except Exception as exc:
logging.error(f"Failed to load skill from {skill_file}: {exc}")
except Exception:
logging.exception(f"Failed to load skill from {skill_file}")

Copilot uses AI. Check for mistakes.

logging.info(f"SkillLoader: {count} skill(s) loaded from {self.skills_dir}")

def _parse_skill(self, path: Path) -> SkillEntry:
"""Parse a single SKILL.md file into a SkillEntry."""
content = path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(content)

name = meta.get("name")
if not name:
name = path.parent.name

description = meta.get("description", "")

requires_tools = meta.get("requires_tools", [])
if not isinstance(requires_tools, list):
requires_tools = []

max_rounds = meta.get("max_rounds", 8)
try:
max_rounds = int(max_rounds)
except (ValueError, TypeError):
max_rounds = 8

priority = meta.get("priority", 10)
try:
priority = int(priority)
except (ValueError, TypeError):
priority = 10

return SkillEntry(
name=name,
description=description,
instructions=body,
source_path=path,
requires_tools=requires_tools,
max_rounds=max_rounds,
priority=priority,
)

def reload(self) -> None:
"""Re-scan and reload all skills from disk."""
self.skills.clear()
self._load_all()
Loading
Loading