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>
159 lines
5.3 KiB
Python
159 lines
5.3 KiB
Python
"""Skill tools — load and finish skills during agent operation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
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.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."""
|
|
|
|
name: str = Field(description="Name of the skill to load")
|
|
|
|
|
|
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"
|
|
description: ClassVar[str] = (
|
|
"Load a skill's full instructions by name. "
|
|
"Use when a skill is relevant to the current task."
|
|
)
|
|
params_model: ClassVar[type[BaseModel]] = LoadSkillParams
|
|
|
|
def __init__(
|
|
self,
|
|
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"]
|
|
|
|
# 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,
|
|
tool_name=self.name,
|
|
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}",
|
|
)
|