feat: structured skill packages with config overrides, chaining, and TUI integration
Add a skill package system where each skill is a directory with a skill.yaml manifest and prompt markdown files. Skills support /command triggers, scoped config overrides (temperature, max_tokens, tool filtering), chain dependencies with cycle-safe resolution, and a finish_skill completion signal. Includes four built-in skills: explore, brainstorm, write-document, and plan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ class ToolRegistry:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, BaseTool] = {}
|
||||
self._disabled: set[str] = set()
|
||||
|
||||
def register(self, tool: BaseTool) -> None:
|
||||
"""Register a tool instance. Raises ValueError on duplicate name."""
|
||||
@@ -29,24 +30,74 @@ class ToolRegistry:
|
||||
logger.debug("Registered tool: %s", tool.name)
|
||||
|
||||
def get(self, name: str) -> BaseTool | None:
|
||||
"""Look up a tool by name."""
|
||||
"""Look up a tool by name. Returns None if disabled or not found."""
|
||||
if name in self._disabled:
|
||||
return None
|
||||
return self._tools.get(name)
|
||||
|
||||
def get_all(self) -> dict[str, BaseTool]:
|
||||
"""Return all registered tools."""
|
||||
return dict(self._tools)
|
||||
"""Return all registered tools (excluding disabled)."""
|
||||
return {k: v for k, v in self._tools.items() if k not in self._disabled}
|
||||
|
||||
def get_openai_tools_schema(self) -> list[dict[str, Any]]:
|
||||
"""Return OpenAI function-calling schemas for all registered tools."""
|
||||
return [tool.get_openai_schema() for tool in self._tools.values()]
|
||||
"""Return OpenAI function-calling schemas for all active tools."""
|
||||
return [
|
||||
tool.get_openai_schema()
|
||||
for tool in self._tools.values()
|
||||
if tool.name not in self._disabled
|
||||
]
|
||||
|
||||
def apply_filter(
|
||||
self,
|
||||
*,
|
||||
enable: list[str] | None = None,
|
||||
disable: list[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Apply a tool filter, returning the previous disabled set for restoration.
|
||||
|
||||
Args:
|
||||
enable: If set, only these tools (plus always-on tools) are available.
|
||||
disable: Specific tools to disable.
|
||||
|
||||
Returns:
|
||||
The previous disabled set (snapshot for restore).
|
||||
"""
|
||||
previous = set(self._disabled)
|
||||
|
||||
if enable is not None:
|
||||
# Whitelist mode: disable everything not in the enable list
|
||||
self._disabled = {name for name in self._tools if name not in enable}
|
||||
elif disable is not None:
|
||||
# Blacklist mode: add to existing disabled set (preserves global disables)
|
||||
self._disabled = set(self._disabled) | set(disable)
|
||||
else:
|
||||
self._disabled = set()
|
||||
|
||||
return previous
|
||||
|
||||
def restore_filter(self, previous: set[str]) -> None:
|
||||
"""Restore a previous filter state."""
|
||||
self._disabled = previous
|
||||
|
||||
def all_tool_names(self) -> list[str]:
|
||||
"""Return all registered tool names (including disabled)."""
|
||||
return list(self._tools.keys())
|
||||
|
||||
|
||||
def create_default_registry(
|
||||
workspace_root: Path,
|
||||
config: AppConfig,
|
||||
skills_manager: SkillsManager | None = None,
|
||||
skill_runner: object | None = None,
|
||||
) -> ToolRegistry:
|
||||
"""Create a ToolRegistry populated with all built-in tools."""
|
||||
"""Create a ToolRegistry populated with all built-in tools.
|
||||
|
||||
Args:
|
||||
workspace_root: Workspace root path.
|
||||
config: Application configuration.
|
||||
skills_manager: Optional skills manager for skill tools.
|
||||
skill_runner: Optional SkillRunner for package skill activation.
|
||||
"""
|
||||
# Read tools
|
||||
from app.tools.filesystem import ListDirTool, ReadFileTool
|
||||
|
||||
@@ -92,8 +143,11 @@ def create_default_registry(
|
||||
|
||||
# Skills (conditional)
|
||||
if skills_manager is not None:
|
||||
from app.tools.skills import LoadSkillTool
|
||||
from app.services.skill_runner import SkillRunner as SkillRunnerType
|
||||
from app.tools.skills import FinishSkillTool, LoadSkillTool
|
||||
|
||||
registry.register(LoadSkillTool(workspace_root, config, skills_manager))
|
||||
runner = skill_runner if isinstance(skill_runner, SkillRunnerType) else None
|
||||
registry.register(LoadSkillTool(workspace_root, config, skills_manager, runner))
|
||||
registry.register(FinishSkillTool(workspace_root, config, runner))
|
||||
|
||||
return registry
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""Load skill tool — allows the LLM to load skill instructions on demand."""
|
||||
"""Skill tools — load and finish skills during agent operation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.config import AppConfig
|
||||
from app.models.tool_call import ToolResult, ToolResultStatus
|
||||
from app.services.skills import SkillsManager
|
||||
from app.tools.base import BaseTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.skill_runner import SkillRunner
|
||||
from app.services.skills import SkillsManager
|
||||
|
||||
|
||||
class LoadSkillParams(BaseModel):
|
||||
"""Parameters for the load_skill tool."""
|
||||
@@ -23,6 +26,8 @@ class LoadSkillTool(BaseTool):
|
||||
"""Load a skill's full instructions by name.
|
||||
|
||||
Use when a skill is relevant to the current task.
|
||||
For package skills, this activates the full skill lifecycle
|
||||
(config overrides, chaining, prompt injection).
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = "load_skill"
|
||||
@@ -37,14 +42,22 @@ class LoadSkillTool(BaseTool):
|
||||
workspace_root: Path,
|
||||
config: AppConfig,
|
||||
skills_manager: SkillsManager,
|
||||
skill_runner: SkillRunner | None = None,
|
||||
) -> None:
|
||||
super().__init__(workspace_root, config)
|
||||
self._skills = skills_manager
|
||||
self._runner = skill_runner
|
||||
|
||||
def set_skill_runner(self, runner: SkillRunner) -> None:
|
||||
"""Late-bind the SkillRunner (avoids circular init dependencies)."""
|
||||
self._runner = runner
|
||||
|
||||
def execute(self, *, tool_call_id: str, **kwargs: Any) -> ToolResult:
|
||||
skill_name: str = kwargs["name"]
|
||||
content = self._skills.load_skill(skill_name)
|
||||
if content is None:
|
||||
|
||||
# Check if skill exists
|
||||
skill = self._skills.get_skill(skill_name)
|
||||
if skill is None:
|
||||
available = [s.name for s in self._skills.list_skills()]
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
@@ -52,9 +65,94 @@ class LoadSkillTool(BaseTool):
|
||||
status=ToolResultStatus.ERROR,
|
||||
error=f"Unknown skill '{skill_name}'. Available: {available}",
|
||||
)
|
||||
|
||||
# For package skills with a runner, use full activation flow
|
||||
if skill.manifest is not None and self._runner is not None:
|
||||
content = self._runner.activate(skill_name)
|
||||
if content is None:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.ERROR,
|
||||
error=f"Failed to activate skill '{skill_name}'",
|
||||
)
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.SUCCESS,
|
||||
output=f"Skill '{skill_name}' activated.\n\n{content}",
|
||||
)
|
||||
|
||||
# Legacy skill: just load content
|
||||
content = self._skills.load_skill(skill_name)
|
||||
if content is None:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.ERROR,
|
||||
error=f"Failed to load skill '{skill_name}'",
|
||||
)
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.SUCCESS,
|
||||
output=content,
|
||||
)
|
||||
|
||||
|
||||
class FinishSkillParams(BaseModel):
|
||||
"""Parameters for the finish_skill tool."""
|
||||
|
||||
summary: str = Field(
|
||||
default="Skill complete.",
|
||||
description="Brief summary of what was accomplished during the skill",
|
||||
)
|
||||
|
||||
|
||||
class FinishSkillTool(BaseTool):
|
||||
"""Signal that the active skill is complete and should be deactivated.
|
||||
|
||||
Restores config overrides and tool availability to pre-skill state.
|
||||
The agent loop continues after this (unlike the finish tool).
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = "finish_skill"
|
||||
description: ClassVar[str] = (
|
||||
"Call this when the active skill's task is complete. "
|
||||
"Deactivates the skill and restores normal config. "
|
||||
"The conversation continues after this."
|
||||
)
|
||||
params_model: ClassVar[type[BaseModel]] = FinishSkillParams
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_root: Path,
|
||||
config: AppConfig,
|
||||
skill_runner: SkillRunner | None = None,
|
||||
) -> None:
|
||||
super().__init__(workspace_root, config)
|
||||
self._runner = skill_runner
|
||||
|
||||
def set_skill_runner(self, runner: SkillRunner) -> None:
|
||||
"""Late-bind the SkillRunner (avoids circular init dependencies)."""
|
||||
self._runner = runner
|
||||
|
||||
def execute(self, *, tool_call_id: str, **kwargs: Any) -> ToolResult:
|
||||
summary: str = kwargs.get("summary", "Skill complete.")
|
||||
|
||||
if self._runner is None or not self._runner.is_active:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.ERROR,
|
||||
error="No skill is currently active.",
|
||||
)
|
||||
|
||||
skill_name = self._runner.active_skill_name
|
||||
self._runner.deactivate(summary=summary)
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.SUCCESS,
|
||||
output=f"Skill '{skill_name}' completed: {summary}",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user