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:
40
.sneakycode/skills/brainstorm/prompt.md
Normal file
40
.sneakycode/skills/brainstorm/prompt.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Brainstorm Skill
|
||||||
|
|
||||||
|
You are in **brainstorming mode**. Your goal is creative ideation — generating multiple approaches, exploring trade-offs, and helping the user think through possibilities before committing to an implementation.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Clarify the goal**: Make sure you understand what the user wants to achieve. Ask clarifying questions if needed.
|
||||||
|
2. **Divergent thinking**: Generate at least 3 distinct approaches. Push beyond the obvious — include creative or unconventional options.
|
||||||
|
3. **Evaluate trade-offs**: For each approach, identify:
|
||||||
|
- Pros and cons
|
||||||
|
- Complexity and effort estimate (low / medium / high)
|
||||||
|
- Risk factors
|
||||||
|
- What it enables or prevents in the future
|
||||||
|
4. **Synthesize**: Recommend your top pick with reasoning, but present all options fairly.
|
||||||
|
5. **Refine**: Ask the user which direction appeals to them and iterate.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Read relevant code first to ground your suggestions in reality (the explore skill has already run if chained).
|
||||||
|
- Don't just list options — explain *why* each one is interesting or viable.
|
||||||
|
- Be bold. Brainstorming is the place for ambitious ideas.
|
||||||
|
- If the user's initial framing seems limiting, gently challenge it.
|
||||||
|
- Avoid implementation details at this stage — focus on approach and design.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Present options as numbered approaches with clear headings:
|
||||||
|
|
||||||
|
### Approach 1: [Name]
|
||||||
|
[Description, pros, cons, complexity]
|
||||||
|
|
||||||
|
### Approach 2: [Name]
|
||||||
|
[Description, pros, cons, complexity]
|
||||||
|
|
||||||
|
### Approach 3: [Name]
|
||||||
|
[Description, pros, cons, complexity]
|
||||||
|
|
||||||
|
**Recommendation**: [Your pick and why]
|
||||||
|
|
||||||
|
When brainstorming is complete and the user has chosen a direction, call `finish_skill` summarizing the chosen approach.
|
||||||
9
.sneakycode/skills/brainstorm/skill.yaml
Normal file
9
.sneakycode/skills/brainstorm/skill.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: brainstorm
|
||||||
|
description: Creative ideation — divergent thinking, option generation, structured exploration
|
||||||
|
version: "1.0"
|
||||||
|
triggers: ["/brainstorm", "/bs"]
|
||||||
|
config_overrides:
|
||||||
|
temperature: 1.2
|
||||||
|
tools_disable: [write_file, make_dir, delete_file, str_replace, patch_apply, run_command]
|
||||||
|
chain: [explore]
|
||||||
|
prompts: [prompt.md]
|
||||||
31
.sneakycode/skills/explore/prompt.md
Normal file
31
.sneakycode/skills/explore/prompt.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Explore Skill
|
||||||
|
|
||||||
|
You are in **exploration mode**. Your goal is to deeply understand the codebase or a specific area of it. Do NOT make any changes — only read, search, and analyze.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. **Start broad**: Use `list_dir` and `find_files` to understand the project structure
|
||||||
|
2. **Trace paths**: Follow imports, function calls, and data flow through the code
|
||||||
|
3. **Map relationships**: Identify which files depend on which, and how components interact
|
||||||
|
4. **Read carefully**: Use `read_file` to examine key files in detail
|
||||||
|
5. **Search patterns**: Use `grep_files` to find usage patterns, implementations, and references
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Produce a structured summary with:
|
||||||
|
|
||||||
|
- **Architecture overview**: High-level description of the system's structure
|
||||||
|
- **Key components**: List of important files/classes and their responsibilities
|
||||||
|
- **Data flow**: How data moves through the system (requests, transformations, storage)
|
||||||
|
- **Dependencies**: Internal and external dependency map
|
||||||
|
- **Patterns**: Design patterns, conventions, and idioms used in the codebase
|
||||||
|
- **Observations**: Anything notable — potential issues, tech debt, clever solutions
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Be thorough but focused. If the user specified an area, concentrate there.
|
||||||
|
- Don't guess — read the actual code before making claims.
|
||||||
|
- Quote specific file paths and line numbers when referencing code.
|
||||||
|
- If you find something unexpected or concerning, flag it clearly.
|
||||||
|
|
||||||
|
When you have completed your exploration, call `finish_skill` with a brief summary of your findings.
|
||||||
9
.sneakycode/skills/explore/skill.yaml
Normal file
9
.sneakycode/skills/explore/skill.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: explore
|
||||||
|
description: Deep codebase exploration — traces paths, maps architecture, summarizes findings
|
||||||
|
version: "1.0"
|
||||||
|
triggers: ["/explore", "/ex"]
|
||||||
|
config_overrides:
|
||||||
|
temperature: 0.3
|
||||||
|
tools_disable: [write_file, make_dir, delete_file, str_replace, patch_apply, run_command]
|
||||||
|
chain: []
|
||||||
|
prompts: [prompt.md]
|
||||||
50
.sneakycode/skills/plan/prompt.md
Normal file
50
.sneakycode/skills/plan/prompt.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Plan Skill
|
||||||
|
|
||||||
|
You are in **planning mode**. Your goal is to break down a task into a clear, actionable implementation plan. The explore skill has already run (if chained), so you have codebase context.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Define scope**: Clearly state what the plan covers and what it does not.
|
||||||
|
2. **Decompose**: Break the task into discrete, ordered steps. Each step should be:
|
||||||
|
- Small enough to implement in one focused session
|
||||||
|
- Clear enough that someone unfamiliar could follow it
|
||||||
|
- Testable — you can verify the step was done correctly
|
||||||
|
3. **Identify dependencies**: Note which steps depend on others and the critical path.
|
||||||
|
4. **Map to files**: For each step, list the specific files to create or modify.
|
||||||
|
5. **Flag risks**: Identify anything that could go wrong, require decisions, or block progress.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
```
|
||||||
|
# Implementation Plan: [Title]
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
[What this covers and what it doesn't]
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 1: [Title]
|
||||||
|
- **Files**: [files to create/modify]
|
||||||
|
- **Description**: [what to do]
|
||||||
|
- **Depends on**: [prior steps, if any]
|
||||||
|
- **Verification**: [how to confirm it's done]
|
||||||
|
|
||||||
|
### Step 2: [Title]
|
||||||
|
...
|
||||||
|
|
||||||
|
## Risks & Open Questions
|
||||||
|
- [Risk or question]
|
||||||
|
|
||||||
|
## Build Order
|
||||||
|
[Recommended sequence, considering dependencies]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Be specific — name exact files, functions, and modules.
|
||||||
|
- Keep steps granular. "Implement the backend" is too vague. "Add the /api/users endpoint with GET and POST handlers" is good.
|
||||||
|
- Consider both happy path and error cases in your plan.
|
||||||
|
- If you need to make assumptions, state them explicitly.
|
||||||
|
- Use `run_command` if you need to check project state (e.g., installed packages, running services).
|
||||||
|
|
||||||
|
When the plan is complete and the user has approved it, call `finish_skill` with a one-line summary.
|
||||||
9
.sneakycode/skills/plan/skill.yaml
Normal file
9
.sneakycode/skills/plan/skill.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: plan
|
||||||
|
description: Break down tasks, create roadmaps, plan implementations
|
||||||
|
version: "1.0"
|
||||||
|
triggers: ["/plan"]
|
||||||
|
config_overrides:
|
||||||
|
temperature: 0.5
|
||||||
|
tools_disable: [write_file, make_dir, delete_file, str_replace, patch_apply]
|
||||||
|
chain: [explore]
|
||||||
|
prompts: [prompt.md]
|
||||||
47
.sneakycode/skills/write-document/prompt.md
Normal file
47
.sneakycode/skills/write-document/prompt.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Write Document Skill
|
||||||
|
|
||||||
|
You are in **document writing mode**. Your goal is to draft, edit, or improve written documents — READMEs, technical specs, changelogs, guides, or any prose content.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Understand the Document
|
||||||
|
- What type of document? (README, spec, changelog, tutorial, etc.)
|
||||||
|
- Who is the audience? (developers, users, stakeholders)
|
||||||
|
- What is the desired tone? (formal, casual, technical)
|
||||||
|
- Are there existing documents to reference or update?
|
||||||
|
|
||||||
|
### 2. Outline
|
||||||
|
Before writing, propose a structure:
|
||||||
|
- List the main sections
|
||||||
|
- Note what each section should cover
|
||||||
|
- Get user approval on the outline before drafting
|
||||||
|
|
||||||
|
### 3. Draft
|
||||||
|
Write the full document based on the approved outline:
|
||||||
|
- Use clear, concise language
|
||||||
|
- Follow Markdown formatting conventions
|
||||||
|
- Include code examples where appropriate
|
||||||
|
- Be specific — avoid vague statements
|
||||||
|
|
||||||
|
### 4. Revise
|
||||||
|
After the initial draft:
|
||||||
|
- Check for consistency in tone and terminology
|
||||||
|
- Verify technical accuracy by reading referenced code
|
||||||
|
- Ensure all sections from the outline are covered
|
||||||
|
- Trim unnecessary content
|
||||||
|
|
||||||
|
## Document Templates
|
||||||
|
|
||||||
|
**README**: Project name, description, installation, usage, configuration, contributing, license
|
||||||
|
**Technical Spec**: Context, goals, non-goals, design, alternatives considered, implementation plan
|
||||||
|
**Changelog**: Version, date, categories (Added, Changed, Fixed, Removed)
|
||||||
|
**Guide/Tutorial**: Prerequisites, step-by-step instructions, examples, troubleshooting
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Read existing project docs and code to ensure accuracy.
|
||||||
|
- Match the existing documentation style if updating.
|
||||||
|
- Prefer concrete examples over abstract descriptions.
|
||||||
|
- Use the `write_file` tool to save the document when the user approves.
|
||||||
|
|
||||||
|
When the document is complete and saved, call `finish_skill` with a summary of what was written.
|
||||||
8
.sneakycode/skills/write-document/skill.yaml
Normal file
8
.sneakycode/skills/write-document/skill.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: write-document
|
||||||
|
description: Draft and edit documents — READMEs, specs, changelogs, prose
|
||||||
|
version: "1.0"
|
||||||
|
triggers: ["/write-doc", "/doc"]
|
||||||
|
config_overrides:
|
||||||
|
temperature: 0.7
|
||||||
|
chain: []
|
||||||
|
prompts: [prompt.md]
|
||||||
@@ -19,6 +19,7 @@ from app.utils.logging import get_logger
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.services.debug_log import DebugLogger
|
from app.services.debug_log import DebugLogger
|
||||||
|
from app.services.skill_runner import SkillRunner
|
||||||
from app.services.skills import SkillsManager
|
from app.services.skills import SkillsManager
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -45,6 +46,7 @@ class AgentLoop:
|
|||||||
display: DisplayAdapter | None = None,
|
display: DisplayAdapter | None = None,
|
||||||
debug_logger: DebugLogger | None = None,
|
debug_logger: DebugLogger | None = None,
|
||||||
skills_manager: SkillsManager | None = None,
|
skills_manager: SkillsManager | None = None,
|
||||||
|
skill_runner: SkillRunner | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
self._ctx = ctx
|
self._ctx = ctx
|
||||||
@@ -55,6 +57,7 @@ class AgentLoop:
|
|||||||
self._display = display
|
self._display = display
|
||||||
self._debug = debug_logger
|
self._debug = debug_logger
|
||||||
self._skills = skills_manager
|
self._skills = skills_manager
|
||||||
|
self._skill_runner = skill_runner
|
||||||
self._tools_schema = registry.get_openai_tools_schema()
|
self._tools_schema = registry.get_openai_tools_schema()
|
||||||
self._system_prompt = self._build_system_prompt()
|
self._system_prompt = self._build_system_prompt()
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
@@ -81,6 +84,11 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
if self._skills:
|
if self._skills:
|
||||||
prompt += self._skills.get_system_prompt_snippet()
|
prompt += self._skills.get_system_prompt_snippet()
|
||||||
|
if self._skill_runner and self._skill_runner.is_active:
|
||||||
|
prompt += (
|
||||||
|
f"\n\nCurrently active skill: {self._skill_runner.active_skill_name}. "
|
||||||
|
"When the skill's objective is complete, call the `finish_skill` tool."
|
||||||
|
)
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
def _get_messages_with_system_prompt(self) -> list[Message]:
|
def _get_messages_with_system_prompt(self) -> list[Message]:
|
||||||
@@ -199,7 +207,12 @@ class AgentLoop:
|
|||||||
name=result.tool_name,
|
name=result.tool_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if finish tool was called
|
# Rebuild tools schema and system prompt if skill state may have changed
|
||||||
|
if any(r.tool_name in ("load_skill", "finish_skill") for r in results):
|
||||||
|
self._tools_schema = self._registry.get_openai_tools_schema()
|
||||||
|
self._system_prompt = self._build_system_prompt()
|
||||||
|
|
||||||
|
# Check if finish tool was called (finish_skill does NOT break the loop)
|
||||||
if any(r.tool_name == "finish" for r in results):
|
if any(r.tool_name == "finish" for r in results):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
|||||||
39
app/models/skill.py
Normal file
39
app/models/skill.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Pydantic models for structured skill packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SkillConfigOverrides(BaseModel):
|
||||||
|
"""Scoped config overrides applied while a skill is active."""
|
||||||
|
|
||||||
|
temperature: float | None = Field(default=None, description="Override sampling temperature")
|
||||||
|
max_tokens: int | None = Field(default=None, description="Override max tokens")
|
||||||
|
tools_enable: list[str] | None = Field(
|
||||||
|
default=None, description="Whitelist — only these tools available when set"
|
||||||
|
)
|
||||||
|
tools_disable: list[str] | None = Field(
|
||||||
|
default=None, description="Blacklist — disable specific tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillManifest(BaseModel):
|
||||||
|
"""Parsed skill.yaml manifest for a skill package directory."""
|
||||||
|
|
||||||
|
name: str = Field(description="Unique skill identifier")
|
||||||
|
description: str = Field(description="Human-readable skill description")
|
||||||
|
version: str = Field(default="1.0", description="Skill version")
|
||||||
|
triggers: list[str] = Field(
|
||||||
|
default_factory=list, description="Slash commands that activate this skill"
|
||||||
|
)
|
||||||
|
config_overrides: SkillConfigOverrides = Field(
|
||||||
|
default_factory=SkillConfigOverrides, description="Scoped config overrides"
|
||||||
|
)
|
||||||
|
chain: list[str] = Field(
|
||||||
|
default_factory=list, description="Skill names to run first (dependencies)"
|
||||||
|
)
|
||||||
|
prompts: list[str] = Field(
|
||||||
|
default_factory=lambda: ["prompt.md"],
|
||||||
|
description="Markdown prompt files to load, in order",
|
||||||
|
)
|
||||||
234
app/services/skill_runner.py
Normal file
234
app/services/skill_runner.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""SkillRunner — orchestrates skill activation, chaining, config scoping, and deactivation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.agent.context import SessionContext
|
||||||
|
from app.models.config import AppConfig
|
||||||
|
from app.models.skill import SkillManifest
|
||||||
|
from app.services.skills import Skill, SkillsManager
|
||||||
|
from app.tools.registry import ToolRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillChainError(Exception):
|
||||||
|
"""Raised when skill chain resolution fails (e.g., cycle detected)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SkillSnapshot:
|
||||||
|
"""Captured state before skill activation, for restoration on deactivate."""
|
||||||
|
|
||||||
|
temperature: float
|
||||||
|
max_tokens: int
|
||||||
|
disabled_tools: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRunner:
|
||||||
|
"""Manages skill lifecycle: activation, chaining, config overrides, deactivation.
|
||||||
|
|
||||||
|
Only one skill can be active at a time. Activating a new skill while one
|
||||||
|
is active will first deactivate the current skill.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
skills_manager: SkillsManager,
|
||||||
|
config: AppConfig,
|
||||||
|
ctx: SessionContext,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._skills = skills_manager
|
||||||
|
self._config = config
|
||||||
|
self._ctx = ctx
|
||||||
|
self._registry = registry
|
||||||
|
self._active_skill: Skill | None = None
|
||||||
|
self._snapshot: _SkillSnapshot | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Whether a skill is currently active."""
|
||||||
|
return self._active_skill is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_skill_name(self) -> str | None:
|
||||||
|
"""Name of the currently active skill, or None."""
|
||||||
|
return self._active_skill.name if self._active_skill else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_skill(self) -> Skill | None:
|
||||||
|
"""The currently active skill, or None."""
|
||||||
|
return self._active_skill
|
||||||
|
|
||||||
|
def activate(self, skill_name: str) -> str | None:
|
||||||
|
"""Activate a skill by name.
|
||||||
|
|
||||||
|
Resolves chain dependencies (depth-first), applies config overrides,
|
||||||
|
injects prompt content into conversation context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_name: Name of the skill to activate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The concatenated prompt content injected, or None on failure.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SkillChainError: If chain resolution detects a cycle.
|
||||||
|
"""
|
||||||
|
skill = self._skills.get_skill(skill_name)
|
||||||
|
if skill is None:
|
||||||
|
logger.warning("Cannot activate unknown skill: %s", skill_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Deactivate current skill if one is active
|
||||||
|
if self._active_skill is not None:
|
||||||
|
self.deactivate()
|
||||||
|
|
||||||
|
# Resolve chain dependencies
|
||||||
|
chain = self._resolve_chain(skill, set())
|
||||||
|
|
||||||
|
# Snapshot current config for restoration
|
||||||
|
self._snapshot = _SkillSnapshot(
|
||||||
|
temperature=self._config.llm.temperature,
|
||||||
|
max_tokens=self._config.llm.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect and inject chain skill prompts first
|
||||||
|
all_prompts: list[str] = []
|
||||||
|
for chained_skill in chain:
|
||||||
|
content = self._skills.load_skill(chained_skill.name)
|
||||||
|
if content:
|
||||||
|
all_prompts.append(f"[Chained skill: {chained_skill.name}]\n{content}")
|
||||||
|
|
||||||
|
# Load the target skill's prompts
|
||||||
|
content = self._skills.load_skill(skill.name)
|
||||||
|
if content:
|
||||||
|
all_prompts.append(content)
|
||||||
|
|
||||||
|
# Apply config overrides from the target skill
|
||||||
|
if skill.manifest:
|
||||||
|
self._apply_overrides(skill.manifest)
|
||||||
|
|
||||||
|
# Inject prompts into context
|
||||||
|
full_prompt = "\n\n".join(all_prompts) if all_prompts else None
|
||||||
|
if full_prompt:
|
||||||
|
self._ctx.add_message(
|
||||||
|
"system",
|
||||||
|
f"[Skill activated: {skill.name}]\n{full_prompt}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._active_skill = skill
|
||||||
|
logger.info("Skill activated: %s", skill.name)
|
||||||
|
return full_prompt
|
||||||
|
|
||||||
|
def activate_by_trigger(self, trigger: str) -> str | None:
|
||||||
|
"""Activate a skill by its /command trigger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trigger: The trigger string (with or without leading /).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The concatenated prompt content, or None if no skill matches.
|
||||||
|
"""
|
||||||
|
skill = self._skills.get_skill_by_trigger(trigger)
|
||||||
|
if skill is None:
|
||||||
|
return None
|
||||||
|
return self.activate(skill.name)
|
||||||
|
|
||||||
|
def deactivate(self, summary: str | None = None) -> None:
|
||||||
|
"""Deactivate the current skill, restoring config and tool state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
summary: Optional summary message to inject into context.
|
||||||
|
"""
|
||||||
|
if self._active_skill is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
skill_name = self._active_skill.name
|
||||||
|
|
||||||
|
# Restore config
|
||||||
|
if self._snapshot is not None:
|
||||||
|
self._config.llm.temperature = self._snapshot.temperature
|
||||||
|
self._config.llm.max_tokens = self._snapshot.max_tokens
|
||||||
|
self._registry.restore_filter(self._snapshot.disabled_tools)
|
||||||
|
self._snapshot = None
|
||||||
|
|
||||||
|
if summary:
|
||||||
|
self._ctx.add_message(
|
||||||
|
"system",
|
||||||
|
f"[Skill completed: {skill_name}] {summary}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._active_skill = None
|
||||||
|
logger.info("Skill deactivated: %s", skill_name)
|
||||||
|
|
||||||
|
def _resolve_chain(
|
||||||
|
self, skill: Skill, in_progress: set[str], completed: set[str] | None = None,
|
||||||
|
) -> list[Skill]:
|
||||||
|
"""Depth-first resolution of skill chain dependencies.
|
||||||
|
|
||||||
|
Uses separate in_progress (current path) and completed sets to correctly
|
||||||
|
handle diamond dependencies without false cycle detection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill: The skill whose chain to resolve.
|
||||||
|
in_progress: Skills on the current recursion path (for cycle detection).
|
||||||
|
completed: Skills already fully resolved (skip duplicates).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ordered list of chained skills to activate before the target.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SkillChainError: If a cycle is detected.
|
||||||
|
"""
|
||||||
|
if completed is None:
|
||||||
|
completed = set()
|
||||||
|
|
||||||
|
if skill.manifest is None or not skill.manifest.chain:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: list[Skill] = []
|
||||||
|
for dep_name in skill.manifest.chain:
|
||||||
|
if dep_name in completed:
|
||||||
|
continue # Already resolved via another branch (diamond dep)
|
||||||
|
|
||||||
|
if dep_name in in_progress:
|
||||||
|
raise SkillChainError(
|
||||||
|
f"Cycle detected in skill chain: {dep_name} already in progress "
|
||||||
|
f"(path: {' -> '.join(in_progress)} -> {dep_name})"
|
||||||
|
)
|
||||||
|
|
||||||
|
dep_skill = self._skills.get_skill(dep_name)
|
||||||
|
if dep_skill is None:
|
||||||
|
logger.warning("Chained skill not found: %s (required by %s)", dep_name, skill.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
in_progress.add(dep_name)
|
||||||
|
result.extend(self._resolve_chain(dep_skill, in_progress, completed))
|
||||||
|
in_progress.discard(dep_name)
|
||||||
|
completed.add(dep_name)
|
||||||
|
result.append(dep_skill)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _apply_overrides(self, manifest: SkillManifest) -> None:
|
||||||
|
"""Apply config overrides from a skill manifest."""
|
||||||
|
overrides = manifest.config_overrides
|
||||||
|
|
||||||
|
if overrides.temperature is not None:
|
||||||
|
self._config.llm.temperature = overrides.temperature
|
||||||
|
|
||||||
|
if overrides.max_tokens is not None:
|
||||||
|
self._config.llm.max_tokens = overrides.max_tokens
|
||||||
|
|
||||||
|
if overrides.tools_enable is not None or overrides.tools_disable is not None:
|
||||||
|
previous = self._registry.apply_filter(
|
||||||
|
enable=overrides.tools_enable,
|
||||||
|
disable=overrides.tools_disable,
|
||||||
|
)
|
||||||
|
# Store for restoration
|
||||||
|
if self._snapshot:
|
||||||
|
self._snapshot.disabled_tools = previous
|
||||||
@@ -1,46 +1,92 @@
|
|||||||
"""Skills manager — scans for and loads skill markdown files."""
|
"""Skills manager — scans for and loads skill packages and legacy markdown files."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
import yaml
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from app.models.config import SkillsConfig
|
from app.models.config import SkillsConfig
|
||||||
|
from app.models.skill import SkillManifest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Skill(BaseModel):
|
class Skill(BaseModel):
|
||||||
"""Metadata for a discovered skill file."""
|
"""Metadata for a discovered skill (package or legacy flat file)."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
path: Path
|
path: Path
|
||||||
|
manifest: SkillManifest | None = None
|
||||||
|
|
||||||
|
|
||||||
class SkillsManager:
|
class SkillsManager:
|
||||||
"""Discovers, indexes, and loads skill files from configured directories."""
|
"""Discovers, indexes, and loads skill files from configured directories.
|
||||||
|
|
||||||
|
Supports both:
|
||||||
|
- Directory-based packages (contain skill.yaml + prompt .md files)
|
||||||
|
- Legacy flat .md files (backwards compatible)
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, config: SkillsConfig, workspace_root: Path) -> None:
|
def __init__(self, config: SkillsConfig, workspace_root: Path) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
self._workspace = workspace_root
|
self._workspace = workspace_root
|
||||||
self._skills: dict[str, Skill] = {}
|
self._skills: dict[str, Skill] = {}
|
||||||
|
self._trigger_map: dict[str, str] = {} # trigger -> skill name
|
||||||
self._scan()
|
self._scan()
|
||||||
|
|
||||||
def _scan(self) -> None:
|
def _scan(self) -> None:
|
||||||
"""Scan configured directories for .md skill files."""
|
"""Scan configured directories for skill packages and legacy .md files."""
|
||||||
for skill_dir in self._config.directories:
|
for skill_dir in self._config.directories:
|
||||||
resolved = (self._workspace / skill_dir) if not skill_dir.is_absolute() else skill_dir
|
resolved = (self._workspace / skill_dir) if not skill_dir.is_absolute() else skill_dir
|
||||||
if not resolved.is_dir():
|
if not resolved.is_dir():
|
||||||
logger.debug("Skills directory does not exist: %s", resolved)
|
logger.debug("Skills directory does not exist: %s", resolved)
|
||||||
continue
|
continue
|
||||||
for md in sorted(resolved.glob("*.md")):
|
|
||||||
name = md.stem
|
for entry in sorted(resolved.iterdir()):
|
||||||
desc = self._extract_description(md)
|
if entry.is_dir():
|
||||||
self._skills[name] = Skill(name=name, description=desc, path=md)
|
self._scan_package(entry)
|
||||||
logger.debug("Discovered skill: %s (%s)", name, desc)
|
elif entry.suffix == ".md":
|
||||||
|
self._scan_legacy(entry)
|
||||||
|
|
||||||
|
def _scan_package(self, pkg_dir: Path) -> None:
|
||||||
|
"""Scan a directory-based skill package containing skill.yaml."""
|
||||||
|
manifest_path = pkg_dir / "skill.yaml"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
logger.debug("Skipping directory without skill.yaml: %s", pkg_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(manifest_path.read_text())
|
||||||
|
manifest = SkillManifest(**raw)
|
||||||
|
except (yaml.YAMLError, ValidationError, TypeError) as e:
|
||||||
|
logger.warning("Failed to parse skill manifest %s: %s", manifest_path, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
skill = Skill(
|
||||||
|
name=manifest.name,
|
||||||
|
description=manifest.description,
|
||||||
|
path=pkg_dir,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
self._skills[manifest.name] = skill
|
||||||
|
|
||||||
|
# Register triggers
|
||||||
|
for trigger in manifest.triggers:
|
||||||
|
normalized = trigger.lstrip("/").lower()
|
||||||
|
self._trigger_map[normalized] = manifest.name
|
||||||
|
|
||||||
|
logger.debug("Discovered skill package: %s (%s)", manifest.name, manifest.description)
|
||||||
|
|
||||||
|
def _scan_legacy(self, md_path: Path) -> None:
|
||||||
|
"""Scan a legacy flat .md skill file."""
|
||||||
|
name = md_path.stem
|
||||||
|
desc = self._extract_description(md_path)
|
||||||
|
self._skills[name] = Skill(name=name, description=desc, path=md_path)
|
||||||
|
logger.debug("Discovered legacy skill: %s (%s)", name, desc)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_description(path: Path) -> str:
|
def _extract_description(path: Path) -> str:
|
||||||
@@ -55,10 +101,51 @@ class SkillsManager:
|
|||||||
"""Return all discovered skills."""
|
"""Return all discovered skills."""
|
||||||
return list(self._skills.values())
|
return list(self._skills.values())
|
||||||
|
|
||||||
|
def get_skill(self, name: str) -> Skill | None:
|
||||||
|
"""Look up a skill by name."""
|
||||||
|
return self._skills.get(name)
|
||||||
|
|
||||||
|
def get_skill_by_trigger(self, trigger: str) -> Skill | None:
|
||||||
|
"""Look up a skill by /command trigger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trigger: The trigger string (with or without leading /).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The matching Skill, or None.
|
||||||
|
"""
|
||||||
|
normalized = trigger.lstrip("/").lower()
|
||||||
|
skill_name = self._trigger_map.get(normalized)
|
||||||
|
if skill_name:
|
||||||
|
return self._skills.get(skill_name)
|
||||||
|
return None
|
||||||
|
|
||||||
def load_skill(self, name: str) -> str | None:
|
def load_skill(self, name: str) -> str | None:
|
||||||
"""Load the full content of a skill by name. Returns None if not found."""
|
"""Load the full content of a skill by name.
|
||||||
|
|
||||||
|
For package skills, concatenates all prompt .md files.
|
||||||
|
For legacy skills, returns the .md file content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Concatenated prompt content, or None if not found.
|
||||||
|
"""
|
||||||
skill = self._skills.get(name)
|
skill = self._skills.get(name)
|
||||||
return skill.path.read_text() if skill else None
|
if skill is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if skill.manifest is not None:
|
||||||
|
# Package skill: load prompt files
|
||||||
|
parts: list[str] = []
|
||||||
|
for prompt_file in skill.manifest.prompts:
|
||||||
|
prompt_path = skill.path / prompt_file
|
||||||
|
if prompt_path.exists():
|
||||||
|
parts.append(prompt_path.read_text())
|
||||||
|
else:
|
||||||
|
logger.warning("Prompt file not found: %s", prompt_path)
|
||||||
|
return "\n\n".join(parts) if parts else None
|
||||||
|
else:
|
||||||
|
# Legacy flat file
|
||||||
|
return skill.path.read_text()
|
||||||
|
|
||||||
def get_system_prompt_snippet(self) -> str:
|
def get_system_prompt_snippet(self) -> str:
|
||||||
"""Generate a snippet for the system prompt listing available skills."""
|
"""Generate a snippet for the system prompt listing available skills."""
|
||||||
@@ -66,6 +153,10 @@ class SkillsManager:
|
|||||||
return ""
|
return ""
|
||||||
lines = ["\nAvailable skills (invoke with /skill-name):"]
|
lines = ["\nAvailable skills (invoke with /skill-name):"]
|
||||||
for s in self._skills.values():
|
for s in self._skills.values():
|
||||||
lines.append(f" - /{s.name}: {s.description}")
|
if s.manifest and s.manifest.triggers:
|
||||||
|
trigger_str = ", ".join(s.manifest.triggers)
|
||||||
|
lines.append(f" - {trigger_str}: {s.description}")
|
||||||
|
else:
|
||||||
|
lines.append(f" - /{s.name}: {s.description}")
|
||||||
lines.append("To use a skill's full instructions, call the load_skill tool.")
|
lines.append("To use a skill's full instructions, call the load_skill tool.")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ToolRegistry:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._tools: dict[str, BaseTool] = {}
|
self._tools: dict[str, BaseTool] = {}
|
||||||
|
self._disabled: set[str] = set()
|
||||||
|
|
||||||
def register(self, tool: BaseTool) -> None:
|
def register(self, tool: BaseTool) -> None:
|
||||||
"""Register a tool instance. Raises ValueError on duplicate name."""
|
"""Register a tool instance. Raises ValueError on duplicate name."""
|
||||||
@@ -29,24 +30,74 @@ class ToolRegistry:
|
|||||||
logger.debug("Registered tool: %s", tool.name)
|
logger.debug("Registered tool: %s", tool.name)
|
||||||
|
|
||||||
def get(self, name: str) -> BaseTool | None:
|
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)
|
return self._tools.get(name)
|
||||||
|
|
||||||
def get_all(self) -> dict[str, BaseTool]:
|
def get_all(self) -> dict[str, BaseTool]:
|
||||||
"""Return all registered tools."""
|
"""Return all registered tools (excluding disabled)."""
|
||||||
return dict(self._tools)
|
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]]:
|
def get_openai_tools_schema(self) -> list[dict[str, Any]]:
|
||||||
"""Return OpenAI function-calling schemas for all registered tools."""
|
"""Return OpenAI function-calling schemas for all active tools."""
|
||||||
return [tool.get_openai_schema() for tool in self._tools.values()]
|
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(
|
def create_default_registry(
|
||||||
workspace_root: Path,
|
workspace_root: Path,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
skills_manager: SkillsManager | None = None,
|
skills_manager: SkillsManager | None = None,
|
||||||
|
skill_runner: object | None = None,
|
||||||
) -> ToolRegistry:
|
) -> 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
|
# Read tools
|
||||||
from app.tools.filesystem import ListDirTool, ReadFileTool
|
from app.tools.filesystem import ListDirTool, ReadFileTool
|
||||||
|
|
||||||
@@ -92,8 +143,11 @@ def create_default_registry(
|
|||||||
|
|
||||||
# Skills (conditional)
|
# Skills (conditional)
|
||||||
if skills_manager is not None:
|
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
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, ClassVar
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.config import AppConfig
|
from app.models.config import AppConfig
|
||||||
from app.models.tool_call import ToolResult, ToolResultStatus
|
from app.models.tool_call import ToolResult, ToolResultStatus
|
||||||
from app.services.skills import SkillsManager
|
|
||||||
from app.tools.base import BaseTool
|
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):
|
class LoadSkillParams(BaseModel):
|
||||||
"""Parameters for the load_skill tool."""
|
"""Parameters for the load_skill tool."""
|
||||||
@@ -23,6 +26,8 @@ class LoadSkillTool(BaseTool):
|
|||||||
"""Load a skill's full instructions by name.
|
"""Load a skill's full instructions by name.
|
||||||
|
|
||||||
Use when a skill is relevant to the current task.
|
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"
|
name: ClassVar[str] = "load_skill"
|
||||||
@@ -37,14 +42,22 @@ class LoadSkillTool(BaseTool):
|
|||||||
workspace_root: Path,
|
workspace_root: Path,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
skills_manager: SkillsManager,
|
skills_manager: SkillsManager,
|
||||||
|
skill_runner: SkillRunner | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(workspace_root, config)
|
super().__init__(workspace_root, config)
|
||||||
self._skills = skills_manager
|
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:
|
def execute(self, *, tool_call_id: str, **kwargs: Any) -> ToolResult:
|
||||||
skill_name: str = kwargs["name"]
|
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()]
|
available = [s.name for s in self._skills.list_skills()]
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
@@ -52,9 +65,94 @@ class LoadSkillTool(BaseTool):
|
|||||||
status=ToolResultStatus.ERROR,
|
status=ToolResultStatus.ERROR,
|
||||||
error=f"Unknown skill '{skill_name}'. Available: {available}",
|
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(
|
return ToolResult(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
tool_name=self.name,
|
tool_name=self.name,
|
||||||
status=ToolResultStatus.SUCCESS,
|
status=ToolResultStatus.SUCCESS,
|
||||||
output=content,
|
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}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rich.panel import Panel
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.widgets import Header, RichLog
|
from textual.widgets import Header, Input, RichLog
|
||||||
from textual import work
|
from textual import work
|
||||||
|
|
||||||
from app.agent.context import SessionContext
|
from app.agent.context import SessionContext
|
||||||
@@ -58,6 +58,7 @@ class SneakyCodeApp(App):
|
|||||||
self._permissions: PermissionsService | None = None
|
self._permissions: PermissionsService | None = None
|
||||||
self._debug_logger = None
|
self._debug_logger = None
|
||||||
self._skills_manager = None
|
self._skills_manager = None
|
||||||
|
self._skill_runner = None
|
||||||
self._current_worker: Worker | None = None
|
self._current_worker: Worker | None = None
|
||||||
self._cancel_count = 0
|
self._cancel_count = 0
|
||||||
self.sub_title = config.llm.model
|
self.sub_title = config.llm.model
|
||||||
@@ -95,12 +96,31 @@ class SneakyCodeApp(App):
|
|||||||
self._config.skills, self._config.agent.workspace_root
|
self._config.skills, self._config.agent.workspace_root
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create tool registry (SkillRunner wired after registry exists)
|
||||||
self._tool_registry = create_default_registry(
|
self._tool_registry = create_default_registry(
|
||||||
self._config.agent.workspace_root,
|
self._config.agent.workspace_root,
|
||||||
self._config,
|
self._config,
|
||||||
skills_manager=self._skills_manager,
|
skills_manager=self._skills_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create SkillRunner and late-bind it to skill tools
|
||||||
|
if self._skills_manager is not None and self._tool_registry is not None:
|
||||||
|
from app.services.skill_runner import SkillRunner
|
||||||
|
|
||||||
|
self._skill_runner = SkillRunner(
|
||||||
|
self._skills_manager,
|
||||||
|
self._config,
|
||||||
|
self._ctx,
|
||||||
|
self._tool_registry,
|
||||||
|
)
|
||||||
|
# Late-bind runner to skill tools already in the registry
|
||||||
|
load_tool = self._tool_registry.get("load_skill")
|
||||||
|
if load_tool and hasattr(load_tool, "set_skill_runner"):
|
||||||
|
load_tool.set_skill_runner(self._skill_runner)
|
||||||
|
finish_tool = self._tool_registry.get("finish_skill")
|
||||||
|
if finish_tool and hasattr(finish_tool, "set_skill_runner"):
|
||||||
|
finish_tool.set_skill_runner(self._skill_runner)
|
||||||
|
|
||||||
# Set up permission prompt callback
|
# Set up permission prompt callback
|
||||||
async def permission_prompt(tool_name: str, description: str) -> bool:
|
async def permission_prompt(tool_name: str, description: str) -> bool:
|
||||||
return await self._show_permission_modal(tool_name, description)
|
return await self._show_permission_modal(tool_name, description)
|
||||||
@@ -152,7 +172,24 @@ class SneakyCodeApp(App):
|
|||||||
async def _handle_slash_command(self, command: str, log: RichLog) -> None:
|
async def _handle_slash_command(self, command: str, log: RichLog) -> None:
|
||||||
"""Process slash commands."""
|
"""Process slash commands."""
|
||||||
cmd = command.lower()
|
cmd = command.lower()
|
||||||
if cmd == "/quit":
|
if cmd == "/help":
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
table = Table(title="SneakyCode Commands", show_lines=False)
|
||||||
|
table.add_column("Command", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Description")
|
||||||
|
table.add_row("/help", "Show this help message")
|
||||||
|
table.add_row("/quit, /exit, /bye", "Save session and exit")
|
||||||
|
table.add_row("/clear", "Clear conversation history")
|
||||||
|
table.add_row("/history", "Show conversation history")
|
||||||
|
table.add_row("/save", "Manually save session")
|
||||||
|
table.add_row("/session", "Show session info (messages, tokens, start time)")
|
||||||
|
table.add_row("/models", "List available Ollama models")
|
||||||
|
table.add_row("/models <name>", "Switch to a different model")
|
||||||
|
table.add_row("/skills", "List available skills")
|
||||||
|
table.add_row("/<skill>", "Load a skill by name")
|
||||||
|
log.write(table)
|
||||||
|
elif cmd in ("/quit", "/exit", "/bye"):
|
||||||
self._save_session()
|
self._save_session()
|
||||||
self.exit()
|
self.exit()
|
||||||
elif cmd == "/clear":
|
elif cmd == "/clear":
|
||||||
@@ -221,7 +258,24 @@ class SneakyCodeApp(App):
|
|||||||
else:
|
else:
|
||||||
log.write(Text("Skills system is disabled", style="yellow"))
|
log.write(Text("Skills system is disabled", style="yellow"))
|
||||||
else:
|
else:
|
||||||
# Try as skill invocation
|
# Try as skill trigger (package skill via SkillRunner)
|
||||||
|
if self._skill_runner and self._skills_manager:
|
||||||
|
skill = self._skills_manager.get_skill_by_trigger(cmd.lstrip("/"))
|
||||||
|
if skill is not None:
|
||||||
|
content = self._skill_runner.activate(skill.name)
|
||||||
|
status_bar = self.query_one(StatusBar)
|
||||||
|
status_bar.set_active_skill(skill.name)
|
||||||
|
log.write(Text(f"Skill activated: {skill.name}", style="bold green"))
|
||||||
|
# Run an agent turn so the LLM sees the skill context
|
||||||
|
self._cancel_count = 0
|
||||||
|
self._current_worker = self.run_worker(
|
||||||
|
self._run_agent_turn(f"[Skill activated: {skill.name}]"),
|
||||||
|
name="agent-turn",
|
||||||
|
exclusive=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try as legacy skill invocation
|
||||||
skill_name = cmd.lstrip("/")
|
skill_name = cmd.lstrip("/")
|
||||||
if self._skills_manager:
|
if self._skills_manager:
|
||||||
content = self._skills_manager.load_skill(skill_name)
|
content = self._skills_manager.load_skill(skill_name)
|
||||||
@@ -230,7 +284,7 @@ class SneakyCodeApp(App):
|
|||||||
self._ctx.add_message("system", f"[Skill: {skill_name}]\n{content}")
|
self._ctx.add_message("system", f"[Skill: {skill_name}]\n{content}")
|
||||||
log.write(Text(f"Loaded skill: {skill_name}", style="bold green"))
|
log.write(Text(f"Loaded skill: {skill_name}", style="bold green"))
|
||||||
return
|
return
|
||||||
log.write(Text(f"⚠ Unknown command: {command}", style="yellow"))
|
log.write(Text(f"Unknown command: {command}", style="yellow"))
|
||||||
|
|
||||||
async def _run_agent_turn(self, user_input: str) -> None:
|
async def _run_agent_turn(self, user_input: str) -> None:
|
||||||
"""Run a single agent turn (called as a worker)."""
|
"""Run a single agent turn (called as a worker)."""
|
||||||
@@ -270,12 +324,19 @@ class SneakyCodeApp(App):
|
|||||||
self._tool_registry, self._permissions, display,
|
self._tool_registry, self._permissions, display,
|
||||||
debug_logger=self._debug_logger,
|
debug_logger=self._debug_logger,
|
||||||
skills_manager=self._skills_manager,
|
skills_manager=self._skills_manager,
|
||||||
|
skill_runner=self._skill_runner,
|
||||||
)
|
)
|
||||||
|
|
||||||
await agent.run_turn(user_input)
|
await agent.run_turn(user_input)
|
||||||
|
|
||||||
status_bar.stop_streaming()
|
status_bar.stop_streaming()
|
||||||
|
|
||||||
|
# Update skill indicator (skill may have been deactivated via finish_skill)
|
||||||
|
if self._skill_runner and not self._skill_runner.is_active:
|
||||||
|
status_bar.set_active_skill(None)
|
||||||
|
elif self._skill_runner and self._skill_runner.is_active:
|
||||||
|
status_bar.set_active_skill(self._skill_runner.active_skill_name)
|
||||||
|
|
||||||
# Auto-save
|
# Auto-save
|
||||||
if self._config.session.auto_save:
|
if self._config.session.auto_save:
|
||||||
self._save_session()
|
self._save_session()
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class StatusBar(Static):
|
|||||||
self._spinner_frame: int = 0
|
self._spinner_frame: int = 0
|
||||||
self._spinner_timer: Timer | None = None
|
self._spinner_timer: Timer | None = None
|
||||||
self._stream_tokens: int = 0
|
self._stream_tokens: int = 0
|
||||||
|
self._active_skill: str | None = None
|
||||||
|
|
||||||
def update_tokens(self, tokens: int, budget: int) -> None:
|
def update_tokens(self, tokens: int, budget: int) -> None:
|
||||||
"""Update the token usage display."""
|
"""Update the token usage display."""
|
||||||
@@ -184,9 +185,16 @@ class StatusBar(Static):
|
|||||||
self._spinner_frame = (self._spinner_frame + 1) % len(self._SPINNER)
|
self._spinner_frame = (self._spinner_frame + 1) % len(self._SPINNER)
|
||||||
self._refresh_display()
|
self._refresh_display()
|
||||||
|
|
||||||
|
def set_active_skill(self, skill_name: str | None) -> None:
|
||||||
|
"""Set or clear the active skill indicator."""
|
||||||
|
self._active_skill = skill_name
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
def _refresh_display(self) -> None:
|
def _refresh_display(self) -> None:
|
||||||
"""Rebuild the status bar text."""
|
"""Rebuild the status bar text."""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
if self._active_skill:
|
||||||
|
parts.append(f"[Skill: {self._active_skill}]")
|
||||||
if self._streaming:
|
if self._streaming:
|
||||||
spinner = self._SPINNER[self._spinner_frame]
|
spinner = self._SPINNER[self._spinner_frame]
|
||||||
parts.append(f"{spinner} Thinking")
|
parts.append(f"{spinner} Thinking")
|
||||||
|
|||||||
Reference in New Issue
Block a user