Skip to content
Closed
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
12 changes: 10 additions & 2 deletions src/agentpool/delegation/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from anyenv import ProcessManager
import anyio
from upathtools import UPath
from upathtools import UPath, to_upath

from agentpool.common_types import NodeName, SupportsStructuredOutput
from agentpool.delegation.message_flow_tracker import MessageFlowTracker
Expand Down Expand Up @@ -101,10 +101,13 @@ def __init__( # noqa: PLR0915
match manifest:
case None:
self.manifest = AgentsManifest()
self._config_file_path: UPath | None = None
case str() | os.PathLike() | UPath():
self._config_file_path = to_upath(manifest)
self.manifest = AgentsManifest.from_file(manifest)
case AgentsManifest():
self.manifest = manifest
self._config_file_path = None
case _:
raise ValueError(f"Invalid config type: {type(manifest)}")
registry.configure_observability(self.manifest.observability)
Expand All @@ -124,7 +127,12 @@ def __init__( # noqa: PLR0915
self.connection_registry = ConnectionRegistry()
servers = self.manifest.get_mcp_servers()
self.mcp = MCPManager(name="pool_mcp", servers=servers, owner="pool")
self.skills = SkillsManager(name="pool_skills", owner="pool")
self.skills = SkillsManager(
name="pool_skills",
owner="pool",
config=self.manifest.skills,
config_file_path=self._config_file_path,
)
self._tasks = TaskRegistry()
self.prompt_manager = PromptManager(self.manifest.prompts)
# Main agent name: explicit param > manifest.default_agent > None (will use first)
Expand Down
17 changes: 17 additions & 0 deletions src/agentpool/models/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from agentpool_config.observability import ObservabilityConfig
from agentpool_config.output_types import StructuredResponseConfig
from agentpool_config.pool_server import MCPPoolServerConfig
from agentpool_config.skills import SkillsConfig
from agentpool_config.storage import StorageConfig
from agentpool_config.system_prompts import PromptLibraryConfig
from agentpool_config.task import Job
Expand Down Expand Up @@ -329,6 +330,22 @@ class AgentsManifest(Schema):
Docs: https://phil65.github.io/agentpool/YAML%20Configuration/prompt_configuration/
"""

skills: SkillsConfig = Field(default_factory=SkillsConfig)
"""Custom skill discovery paths configuration.

Defines where to search for custom skills. Skills are discovered from
configured directories following "first path wins" semantics.

Example:
```yaml
skills:
paths:
- ./my-skills
- s3://bucket/skills
include_default: true
```
"""

commands: dict[str, CommandConfig | str] = Field(
default_factory=dict,
examples=[
Expand Down
44 changes: 40 additions & 4 deletions src/agentpool/skills/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@

from agentpool.log import get_logger
from agentpool.skills.registry import SkillsRegistry
from agentpool_config.skills import SkillsConfig # noqa: TC001


if TYPE_CHECKING:
from fsspec import AbstractFileSystem
from upathtools import JoinablePathLike
from upathtools import JoinablePathLike, UPath

from agentpool.skills.skill import Skill

Expand All @@ -33,27 +34,33 @@ def __init__(
name: str = "skills",
owner: str | None = None,
skills_dirs: list[JoinablePathLike] | None = None,
config: SkillsConfig | None = None,
config_file_path: UPath | None = None,
) -> None:
"""Initialize the skills manager.

Args:
name: Name for this manager
owner: Owner of this manager
skills_dirs: Directories to search for skills
config: Optional skills configuration from manifest
config_file_path: Optional path to configuration file for resolving relative paths
"""
self.name = name
self.owner = owner
self.registry = SkillsRegistry(skills_dirs)
self._initialized = False
self._config = config
self._config_file_path = config_file_path

def __repr__(self) -> str:
skill_count = len(self.registry.list_items()) if self._initialized else "?"
return f"SkillsManager(name={self.name!r}, skills={skill_count})"

async def __aenter__(self) -> Self:
"""Initialize the skills manager and discover skills."""
"""Initialize to skills manager and discover skills."""
try:
await self.registry.discover_skills()
await self.discover_skills(self._config, self._config_file_path)
self._initialized = True
count = len(self.registry.list_items())
logger.info("Skills manager initialized", name=self.name, skill_count=count)
Expand Down Expand Up @@ -101,9 +108,38 @@ async def add_skills_directory(
await self.registry.register_skills_from_path(upath)
logger.info("Added skills directory", path=str(path))

async def discover_skills(
self,
config: SkillsConfig | None = None,
config_file_path: UPath | None = None,
) -> None:
"""Discover skills from configured paths.

Args:
config: Optional skills configuration.
config_file_path: Optional path to the configuration file for resolving relative paths.
"""
from agentpool_config.skills import DEFAULT_SKILLS_PATHS

default_paths = [p.expanduser() for p in DEFAULT_SKILLS_PATHS]
if config:
paths = config.get_effective_paths(config_file_path)
else:
paths = self.registry.skills_dirs

for path in reversed(paths):
upath = to_upath(path).expanduser()
if not upath.exists():
if any(upath == dp for dp in default_paths):
logger.debug("Default skills directory not found", path=upath)
else:
logger.warning("Custom skills directory not found", path=upath)
continue
await self.registry.register_skills_from_path(upath, replace=True)

async def refresh(self) -> None:
"""Force rediscovery of all skills."""
await self.registry.discover_skills()
await self.discover_skills()
skill_count = len(self.registry.list_items())
logger.info("Skills refreshed", name=self.name, skill_count=skill_count)

Expand Down
6 changes: 4 additions & 2 deletions src/agentpool/skills/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async def register_skills_from_path(
self,
skills_dir: JoinablePathLike | AbstractFileSystem,
base_path: str | None = None,
replace: bool = True,
**storage_options: Any,
) -> None:
"""Register skills from a given path.
Expand All @@ -63,6 +64,7 @@ async def register_skills_from_path(
skills_dir: Path to the directory containing skills, or filesystem instance.
base_path: When skills_dir is a filesystem, the path within that filesystem
to look for skills. Defaults to root_marker if not specified.
replace: Whether to replace existing skills with same name.
storage_options: Additional options to pass to the filesystem.
"""
if isinstance(skills_dir, AbstractFileSystem):
Expand All @@ -80,7 +82,7 @@ async def register_skills_from_path(
# List entries in skills directory
entries = await fs._ls(search_path, detail=True)
except FileNotFoundError:
logger.warning("Skills directory not found", path=search_path)
logger.debug("Skills directory not found", path=search_path)
return
# Filter for directories that might contain skills
skill_dirs = [
Expand Down Expand Up @@ -111,7 +113,7 @@ async def register_skills_from_path(

try:
skill = self._parse_skill(skill_dir_path)
self.register(skill.name, skill, replace=True)
self.register(skill.name, skill, replace=replace)
except Exception as e: # noqa: BLE001
# Log but don't fail discovery for one bad skill
print(f"Warning: Failed to parse skill at {skill_dir_path}: {e}")
Expand Down
3 changes: 3 additions & 0 deletions src/agentpool_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
PromptHookConfig,
)
from agentpool_config.toolsets import ToolsetConfig
from agentpool_config.skills import SkillsConfig, DEFAULT_SKILLS_PATHS
from agentpool_config.resolution import (
ConfigLayer,
ConfigSource,
Expand Down Expand Up @@ -64,6 +65,7 @@
Field(discriminator="type"),
]
__all__ = [
"DEFAULT_SKILLS_PATHS",
"AnyToolConfig",
"BaseEventHandlerConfig",
"BaseHookConfig",
Expand All @@ -84,6 +86,7 @@
"ResolvedConfig",
"SSEMCPServerConfig",
"SessionQuery",
"SkillsConfig",
"StdioMCPServerConfig",
"StdoutEventHandlerConfig",
"StreamableHTTPMCPServerConfig",
Expand Down
100 changes: 90 additions & 10 deletions src/agentpool_config/skills.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,97 @@
"""Skills configuration."""

from dataclasses import dataclass
from __future__ import annotations

from pydantic import ConfigDict, Field
from schemez import Schema
from upathtools import UPath

@dataclass
class Skill:
"""Skill configuration."""

url: str
name: str
DEFAULT_SKILLS_PATHS = [
UPath("~/.claude/skills/"),
UPath(".claude/skills/"),
]


dev_browser = Skill(
url="https://github.com/SawyerHood/dev-browser/tree/main/skills/dev-browser",
name="dev-browser",
)
class SkillsConfig(Schema):
"""Configuration for custom skill discovery paths.

Skills are discovered from configured directories, allowing
users to add custom skills from local paths. The discovery
follows "first path wins" semantics - earlier paths in the list
take precedence over later ones.

Default paths (when include_default=True):
- ~/.claude/skills/ (user home directory)
- .claude/skills/ (relative to current directory)
"""

model_config = ConfigDict(
json_schema_extra={
"x-icon": "octicon:mortar-board-16",
"x-doc-title": "Skills Configuration",
}
)

paths: list[UPath] = Field(
default_factory=list,
title="Custom skill paths",
examples=[["/path/to/skills", "./my-skills", "s3://bucket/skills"]],
)
"""List of custom paths to search for skills.

Paths can be:
- Absolute: /home/user/skills
- Relative: ./my-skills (resolved against config file location or CWD)
- Remote: s3://bucket/skills, github://org/repo/skills

Earlier paths take precedence over later ones ("first path wins").
"""

include_default: bool = Field(
default=True,
title="Include default paths",
examples=[True, False],
)
"""Whether to include default skill paths in discovery.

Default paths are appended after custom paths:
- ~/.claude/skills/
- .claude/skills/

Set to False to disable default paths entirely.
"""

def get_effective_paths(self, config_file_path: UPath | None = None) -> list[UPath]:
"""Get the effective list of paths for skill discovery.

Resolves relative paths against the config file location (if provided)
or current working directory, then appends default paths if enabled.

Args:
config_file_path: Path to the YAML configuration file.
Relative paths in self.paths are resolved against this file's
parent directory. If None, relative paths are resolved against
the current working directory.

Returns:
List of UPath objects for skill discovery, ordered by priority
(custom paths first, then default paths if enabled).
"""
result: list[UPath] = []

# Resolve custom paths
base_path = config_file_path.parent if config_file_path is not None else UPath.cwd()

for path in self.paths:
if path.is_absolute():
result.append(path)
else:
# Resolve relative paths against base path and normalize
result.append((base_path / path).resolve())

# Append default paths if enabled
if self.include_default:
result.extend(DEFAULT_SKILLS_PATHS)

return result
1 change: 1 addition & 0 deletions tests/test_config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for configuration models."""
Loading