diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py
index 13d7bade00..40ef7690f4 100644
--- a/python/packages/core/agent_framework/__init__.py
+++ b/python/packages/core/agent_framework/__init__.py
@@ -111,11 +111,18 @@
)
from ._settings import SecretString, load_settings
from ._skills import (
+ FileSkill,
+ FileSkillScript,
+ InlineSkill,
+ InlineSkillResource,
+ InlineSkillScript,
Skill,
SkillResource,
SkillScript,
SkillScriptRunner,
SkillsProvider,
+ SkillsProviderBuilder,
+ SkillsSource,
)
from ._telemetry import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -333,6 +340,8 @@
"FanOutEdgeGroup",
"FileCheckpointStorage",
"FileHistoryProvider",
+ "FileSkill",
+ "FileSkillScript",
"FinalT",
"FinishReason",
"FinishReasonLiteral",
@@ -351,6 +360,9 @@
"InMemoryCheckpointStorage",
"InMemoryHistoryProvider",
"InProcRunnerContext",
+ "InlineSkill",
+ "InlineSkillResource",
+ "InlineSkillScript",
"LocalEvaluator",
"MCPStdioTool",
"MCPStreamableHTTPTool",
@@ -379,6 +391,8 @@
"SkillScript",
"SkillScriptRunner",
"SkillsProvider",
+ "SkillsProviderBuilder",
+ "SkillsSource",
"SlidingWindowStrategy",
"StepWrapper",
"SubWorkflowRequestMessage",
diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py
index d371291b21..baa85264ca 100644
--- a/python/packages/core/agent_framework/_skills.py
+++ b/python/packages/core/agent_framework/_skills.py
@@ -2,21 +2,35 @@
"""Agent Skills provider, models, and discovery utilities.
-Defines :class:`SkillResource` and :class:`Skill`, the core data model classes
-for the agent skills system, along with :class:`SkillsProvider` which implements
-the progressive-disclosure pattern from the
-`Agent Skills specification `_:
+Defines the core data model classes for the agent skills system:
+
+- **Skills:** :class:`Skill` (abstract base), :class:`InlineSkill` (code-defined),
+ and :class:`FileSkill` (filesystem-backed).
+- **Resources:** :class:`SkillResource` (abstract base), :class:`InlineSkillResource`
+ (static content or callable).
+- **Scripts:** :class:`SkillScript` (abstract base), :class:`InlineSkillScript`
+ (in-process callable), and :class:`FileSkillScript` (file-path-backed).
+- **Sources:** :class:`SkillsSource` (abstract base for custom skill origins).
+- **Runner:** :class:`SkillScriptRunner` (protocol for executing file-based scripts).
+- **Provider:** :class:`SkillsProvider` and :class:`SkillsProviderBuilder` which
+ implement the progressive-disclosure pattern from the
+ `Agent Skills specification `_:
1. **Advertise** — skill names and descriptions are injected into the system prompt.
2. **Load** — the full SKILL.md body is returned via the ``load_skill`` tool.
3. **Read resources** — supplementary content is returned on demand via
the ``read_skill_resource`` tool.
-Skills can originate from two sources:
+Skills can come from different sources:
- **File-based** — discovered by scanning configured directories for ``SKILL.md`` files.
-- **Code-defined** — created as :class:`Skill` instances in Python code,
+ Represented as :class:`FileSkill` instances.
+- **Code-defined** — created as :class:`InlineSkill` instances in Python code,
with optional callable resources attached via the ``@skill.resource`` decorator.
+- **Custom sources** — any :class:`SkillsSource` implementation that provides
+ skills from arbitrary origins (REST APIs, databases, etc.).
+
+Multiple sources can be composed via the :class:`SkillsProviderBuilder`.
**Security:** file-based skill metadata is XML-escaped before prompt injection, and
file-based resource reads are guarded against path traversal and symlink escape.
@@ -30,10 +44,13 @@
import logging
import os
import re
+from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from html import escape as xml_escape
from pathlib import Path, PurePosixPath
-from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable
+from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, TypeVar, runtime_checkable
+
+import anyio
from ._feature_stage import ExperimentalFeature, experimental
from ._sessions import ContextProvider
@@ -49,12 +66,55 @@
@experimental(feature_id=ExperimentalFeature.SKILLS)
-class SkillResource:
- """A named piece of supplementary content attached to a skill.
+class SkillResource(ABC):
+ """Abstract base class for supplementary content attached to a skill.
+
+ A resource provides data that an agent can retrieve on demand.
+ Concrete implementations handle either static/callable content
+ or file-backed content read from disk.
+
+ Attributes:
+ name: Resource identifier.
+ description: Optional human-readable summary, or ``None``.
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str | None = None,
+ ) -> None:
+ """Initialize a SkillResource.
+
+ Args:
+ name: Identifier for this resource (e.g. ``"reference"``, ``"get-schema"``).
+ description: Optional human-readable summary shown when advertising the resource.
+ """
+ if not name or not name.strip():
+ raise ValueError("Resource name cannot be empty.")
+
+ self.name = name
+ self.description = description
+
+ @abstractmethod
+ async def read(self, **kwargs: Any) -> Any:
+ """Read the resource content.
+
+ Args:
+ **kwargs: Runtime keyword arguments forwarded to resource
+ functions that accept ``**kwargs``.
+
+ Returns:
+ The resource content (any type).
+ """
+
+
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class InlineSkillResource(SkillResource):
+ """A code-defined skill resource backed by static content or a callable.
- A resource provides data that an agent can retrieve on demand. It holds
- either a static ``content`` string or a ``function`` that produces content
- dynamically (sync or async). Exactly one must be provided.
+ Holds either a static ``content`` string or a ``function`` that produces
+ content dynamically (sync or async). Exactly one must be provided.
Attributes:
name: Resource identifier.
@@ -67,13 +127,13 @@ class SkillResource:
.. code-block:: python
- SkillResource(name="reference", content="Static docs here...")
+ InlineSkillResource(name="reference", content="Static docs here...")
Callable resource:
.. code-block:: python
- SkillResource(name="schema", function=get_schema_func)
+ InlineSkillResource(name="schema", function=get_schema_func)
"""
def __init__(
@@ -84,7 +144,7 @@ def __init__(
content: str | None = None,
function: Callable[..., Any] | None = None,
) -> None:
- """Initialize a SkillResource.
+ """Initialize an InlineSkillResource.
Args:
name: Identifier for this resource (e.g. ``"reference"``, ``"get-schema"``).
@@ -94,15 +154,13 @@ def __init__(
May return any type; the value is passed through as-is.
Mutually exclusive with *content*.
"""
- if not name or not name.strip():
- raise ValueError("Resource name cannot be empty.")
+ super().__init__(name=name, description=description)
+
if content is None and function is None:
raise ValueError(f"Resource '{name}' must have either content or function.")
if content is not None and function is not None:
raise ValueError(f"Resource '{name}' must have either content or function, not both.")
- self.name = name
- self.description = description
self.content = content
self.function = function
@@ -113,40 +171,102 @@ def __init__(
sig = inspect.signature(function)
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
+ async def read(self, **kwargs: Any) -> Any:
+ """Read the resource content.
-@experimental(feature_id=ExperimentalFeature.SKILLS)
-class SkillScript:
- """An executable script attached to a skill.
+ Returns static ``content`` directly. For callable resources,
+ invokes the function (awaiting if async) and returns the result.
+
+ Args:
+ **kwargs: Runtime keyword arguments forwarded to resource
+ functions that accept ``**kwargs``.
+
+ Returns:
+ The resource content (any type).
+ """
+ if self.content is not None:
+ return self.content
+
+ if self.function is not None:
+ if inspect.iscoroutinefunction(self.function):
+ return await self.function(**kwargs) if self._accepts_kwargs else await self.function()
+ return self.function(**kwargs) if self._accepts_kwargs else self.function()
- A script represents executable code that an agent can run. It holds
- either an inline ``function`` callable (code-defined scripts) or
- a ``path`` to a script file on disk (file-based scripts).
- Exactly one must be provided.
+ raise ValueError(f"Resource '{self.name}' has no content or function.")
- When ``function`` is set the script is treated as **code-based**
- and the function is invoked directly in-process. When ``path`` is
- set the script is treated as **file-based** and delegated to the
- configured :class:`SkillScriptRunner`.
+
+class _FileSkillResource(SkillResource):
+ """A file-path-backed skill resource that reads content from disk.
+
+ Stores a pre-resolved absolute file path and reads content directly,
+ consistent with the .NET ``AgentFileSkillResource`` and the sibling
+ :class:`FileSkillScript`.
Attributes:
- name: Script identifier.
+ name: Resource identifier (relative path within the skill directory).
description: Optional human-readable summary, or ``None``.
- function: Callable that implements the script, or ``None``.
- path: Relative path to the script file from the skill directory, or
- ``None`` for code-defined scripts.
+ full_path: Absolute path to the resource file.
+ """
- Examples:
- Code-defined script:
+ def __init__(
+ self,
+ *,
+ name: str,
+ full_path: str,
+ description: str | None = None,
+ ) -> None:
+ """Initialize a _FileSkillResource.
- .. code-block:: python
+ Args:
+ name: Relative path of the resource within the skill directory.
+ full_path: Absolute path to the resource file.
+ description: Optional human-readable summary.
- SkillScript(name="analyze", function=analyze_data, description="Run analysis")
+ Raises:
+ ValueError: If ``full_path`` is empty.
+ """
+ super().__init__(name=name, description=description)
- File-based script (discovered from disk):
+ if not full_path or not full_path.strip():
+ raise ValueError("full_path cannot be empty.")
- .. code-block:: python
+ self._full_path = full_path
- SkillScript(name="process.py", path="scripts/process.py")
+ @property
+ def full_path(self) -> str:
+ """Absolute path to the resource file."""
+ return self._full_path
+
+ async def read(self, **kwargs: Any) -> Any:
+ """Read the resource content from disk.
+
+ Args:
+ **kwargs: Unused.
+
+ Returns:
+ The UTF-8 text content of the resource file.
+
+ Raises:
+ ValueError: If the resource file does not exist.
+ """
+ if not await anyio.Path(self._full_path).is_file():
+ raise ValueError(f"Resource file '{self.name}' not found at '{self._full_path}'.")
+
+ logger.info("Reading resource '%s' from '%s'", self.name, self._full_path)
+ return await anyio.Path(self._full_path).read_text(encoding="utf-8")
+
+
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class SkillScript(ABC):
+ """Abstract base class for executable scripts attached to a skill.
+
+ A script represents executable code that an agent can run. Concrete
+ implementations handle either code-defined scripts backed by a callable
+ or file-path-backed scripts requiring an external runner.
+
+ Attributes:
+ name: Script identifier.
+ description: Optional human-readable summary, or ``None``.
"""
def __init__(
@@ -154,97 +274,324 @@ def __init__(
*,
name: str,
description: str | None = None,
- function: Callable[..., Any] | None = None,
- path: str | None = None,
) -> None:
"""Initialize a SkillScript.
Args:
name: Identifier for this script (e.g. ``"analyze"``, ``"process.py"``).
description: Optional human-readable summary.
- function: Callable (sync or async) that implements the script.
- Set for code-defined scripts; ``None`` for file-based scripts.
- Mutually exclusive with *path*.
- path: Relative path to the script file from the skill directory.
- Set automatically for file-based scripts discovered from disk;
- ``None`` for code-defined scripts.
- Mutually exclusive with *function*.
"""
if not name or not name.strip():
raise ValueError("Script name cannot be empty.")
- if function is None and path is None:
- raise ValueError(f"Script '{name}' must have either function or path.")
- if function is not None and path is not None:
- raise ValueError(f"Script '{name}' must have either function or path, not both.")
self.name = name
self.description = description
+
+ @property
+ def parameters_schema(self) -> dict[str, Any] | None:
+ """JSON Schema describing the script's parameters, or ``None``."""
+ return None
+
+ @abstractmethod
+ async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+ """Run this script.
+
+ Args:
+ skill: The skill that owns this script.
+ args: Optional keyword arguments for the script, provided by the
+ agent/LLM.
+ **kwargs: Runtime keyword arguments forwarded only to script
+ functions that accept ``**kwargs``.
+
+ Returns:
+ The script execution result.
+ """
+
+
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class InlineSkillScript(SkillScript):
+ """A code-defined skill script backed by a callable.
+
+ The callable is invoked directly in-process when the script is run.
+ Parameters schema is lazily generated from the callable's signature.
+
+ Attributes:
+ name: Script identifier.
+ description: Optional human-readable summary, or ``None``.
+ function: Callable that implements the script.
+
+ Examples:
+ .. code-block:: python
+
+ InlineSkillScript(name="analyze", function=analyze_data, description="Run analysis")
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str | None = None,
+ function: Callable[..., Any],
+ ) -> None:
+ """Initialize an InlineSkillScript.
+
+ Args:
+ name: Identifier for this script (e.g. ``"analyze"``).
+ description: Optional human-readable summary.
+ function: Callable (sync or async) that implements the script.
+ """
+ super().__init__(name=name, description=description)
+
self.function = function
- self.path = path
self._parameters_schema: dict[str, Any] | None = None
self._parameters_schema_resolved: bool = False
# Precompute whether the function accepts **kwargs to avoid
# repeated inspect.signature() calls on every invocation.
- self._accepts_kwargs: bool = False
- if function is not None:
- sig = inspect.signature(function)
- self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
+ sig = inspect.signature(function)
+ self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
@property
def parameters_schema(self) -> dict[str, Any] | None:
"""JSON Schema describing the script's parameters.
Lazily generated from the callable's signature on first access.
- Returns ``None`` for file-based scripts or functions with no
- introspectable parameters.
+ Returns ``None`` for functions with no introspectable parameters.
"""
- if not self._parameters_schema_resolved and self.function is not None:
+ if not self._parameters_schema_resolved:
tool = FunctionTool(name=self.function.__name__, func=self.function)
schema = tool.parameters()
self._parameters_schema = schema if schema and schema.get("properties") else None
self._parameters_schema_resolved = True
return self._parameters_schema
+ async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+ """Run the script by invoking the callable in-process.
+
+ Args:
+ skill: The skill that owns this script.
+ args: Optional keyword arguments for the script, provided by the
+ agent/LLM.
+ **kwargs: Runtime keyword arguments forwarded only to script
+ functions that accept ``**kwargs``.
+
+ Returns:
+ The script execution result.
+ """
+ if self._accepts_kwargs: # noqa: SIM108
+ result = self.function(**(args or {}), **kwargs)
+ else:
+ result = self.function(**(args or {}))
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+
@experimental(feature_id=ExperimentalFeature.SKILLS)
-class Skill:
- """A skill definition with optional resources.
+class FileSkillScript(SkillScript):
+ """A file-path-backed skill script requiring an external runner.
- A skill bundles a set of instructions (``content``) with metadata and
- zero or more :class:`SkillResource` and :class:`SkillScript` instances.
- Resources and scripts can be supplied at construction time or added later
- via the :meth:`resource` and :meth:`script` decorators.
+ Represents a script file on disk that is delegated to a configured
+ :class:`SkillScriptRunner` for execution.
Attributes:
- name: Skill name (lowercase letters, numbers, hyphens only).
- description: Human-readable description of the skill.
- content: The skill instructions body.
- resources: Mutable list of :class:`SkillResource` instances.
- scripts: Mutable list of :class:`SkillScript` instances.
- path: Absolute path to the skill directory on disk, or ``None``
- for code-defined skills.
+ name: Script identifier.
+ description: Optional human-readable summary, or ``None``.
+ full_path: Absolute path to the script file.
Examples:
- Direct construction:
-
.. code-block:: python
- skill = Skill(
- name="my-skill",
- description="A skill example",
- content="Use this skill for ...",
- resources=[SkillResource(name="ref", content="...")],
+ FileSkillScript(name="process.py", full_path="/skills/my-skill/scripts/process.py")
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str | None = None,
+ full_path: str,
+ runner: SkillScriptRunner | None = None,
+ ) -> None:
+ """Initialize a FileSkillScript.
+
+ Args:
+ name: Identifier for this script (e.g. ``"process.py"``).
+ description: Optional human-readable summary.
+ full_path: Absolute path to the script file.
+ runner: Strategy for running file-based scripts. Required for
+ execution; an error is raised from :meth:`run` if not provided.
+
+ Raises:
+ ValueError: If ``full_path`` is empty or not an absolute path.
+ """
+ super().__init__(name=name, description=description)
+
+ if not full_path or not full_path.strip():
+ raise ValueError("full_path cannot be empty.")
+ if not os.path.isabs(full_path):
+ raise ValueError(f"full_path must be an absolute path, got: '{full_path}'")
+
+ self._full_path = full_path
+ self._runner = runner
+
+ @property
+ def full_path(self) -> str:
+ """Absolute path to the script file."""
+ return self._full_path
+
+ async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+ """Run the script by delegating to the configured runner.
+
+ Args:
+ skill: The skill that owns this script. Must be a
+ :class:`FileSkill`.
+ args: Optional keyword arguments for the script.
+ **kwargs: Additional runtime keyword arguments (unused).
+
+ Returns:
+ The script execution result.
+
+ Raises:
+ TypeError: If ``skill`` is not a :class:`FileSkill`.
+ ValueError: If no runner was provided.
+ """
+ if not isinstance(skill, FileSkill):
+ raise TypeError(
+ f"File-based script '{self.name}' requires a FileSkill "
+ f"but received '{type(skill).__name__}'."
+ )
+ if self._runner is None:
+ raise ValueError(
+ f"Script '{self.name}' requires a runner. "
+ "Provide a script_runner for file-based scripts."
)
+ result = self._runner(skill, self, args)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+
+
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class Skill(ABC):
+ """Abstract base class for all agent skills.
+
+ A skill represents a domain-specific capability with instructions,
+ resources, and scripts. Concrete implementations include
+ :class:`FileSkill` (filesystem-backed) and :class:`InlineSkill`
+ (code-defined).
+
+ Skill metadata follows the
+ `Agent Skills specification `_.
+ """
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Skill name (lowercase letters, numbers, hyphens only)."""
+ ...
+
+ @property
+ @abstractmethod
+ def description(self) -> str:
+ """Human-readable description of the skill."""
+ ...
+
+ @property
+ @abstractmethod
+ def content(self) -> str:
+ """The full skill content.
+
+ For file-based skills this is the raw SKILL.md file content,
+ optionally augmented with a synthesized scripts block when scripts
+ are present. For code-defined skills this is a synthesized XML
+ document containing name, description, and body (instructions,
+ resources, scripts).
+ """
+ ...
+
+ @property
+ def resources(self) -> list[SkillResource]:
+ """Resources associated with this skill.
+
+ The default implementation returns an empty list.
+ Override this property in derived classes to provide skill-specific
+ resources.
+ """
+ return []
+
+ @property
+ def scripts(self) -> list[SkillScript]:
+ """Scripts associated with this skill.
+
+ The default implementation returns an empty list.
+ Override this property in derived classes to provide skill-specific
+ scripts.
+ """
+ return []
+
+
+def _validate_skill_name(name: str) -> None:
+ """Validate a skill name against specification rules.
+
+ Args:
+ name: The skill name to validate.
+
+ Raises:
+ ValueError: If the name is empty, too long, or does not match
+ the required pattern.
+ """
+ if not name or not name.strip():
+ raise ValueError("Skill name cannot be empty.")
+ if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
+ raise ValueError(
+ f"Invalid skill name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
+ "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
+ "or contain consecutive hyphens."
+ )
+
+
+def _validate_skill_description(name: str, description: str) -> None:
+ """Validate a skill description against specification rules.
+
+ Args:
+ name: The skill name (used in error messages).
+ description: The description to validate.
+
+ Raises:
+ ValueError: If the description is empty or too long.
+ """
+ if not description or not description.strip():
+ raise ValueError("Skill description cannot be empty.")
+ if len(description) > MAX_DESCRIPTION_LENGTH:
+ raise ValueError(
+ f"Skill '{name}' has an invalid description: "
+ f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
+ )
+
+
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class InlineSkill(Skill):
+ """A skill defined entirely in code with resources and scripts.
+
+ All resources and scripts should be configured before the skill is
+ registered with a :class:`SkillsProvider` or
+ :class:`SkillsProviderBuilder`.
+
+ Attributes:
+ name: Skill name (lowercase letters, numbers, hyphens only).
+ description: Human-readable description of the skill.
+ instructions: The skill instructions text.
- With dynamic resources:
+ Examples:
+ With the decorator:
.. code-block:: python
- skill = Skill(
+ skill = InlineSkill(
name="db-skill",
description="Database operations",
- content="Use this skill for DB tasks.",
+ instructions="Use this skill for DB tasks.",
)
@@ -258,56 +605,117 @@ def __init__(
*,
name: str,
description: str,
- content: str,
- resources: list[SkillResource] | None = None,
- scripts: list[SkillScript] | None = None,
- path: str | None = None,
+ instructions: str,
+ resources: Sequence[SkillResource] | None = None,
+ scripts: Sequence[SkillScript] | None = None,
) -> None:
- """Initialize a Skill.
+ """Initialize an InlineSkill.
Args:
name: Skill name (lowercase letters, numbers, hyphens only).
description: Human-readable description of the skill (≤1024 chars).
- content: The skill instructions body.
+ instructions: The skill instructions text.
resources: Pre-built resources to attach to this skill.
scripts: Pre-built scripts to attach to this skill.
- path: Absolute path to the skill directory on disk. Set automatically
- for file-based skills; leave as ``None`` for code-defined skills.
"""
- if not name or not name.strip():
- raise ValueError("Skill name cannot be empty.")
- if not description or not description.strip():
- raise ValueError("Skill description cannot be empty.")
+ _validate_skill_name(name)
+ _validate_skill_description(name, description)
- self.name = name
- self.description = description
- self.content = content
- self.resources: list[SkillResource] = resources if resources is not None else []
- self.scripts: list[SkillScript] = scripts if scripts is not None else []
- self.path = path
+ self._name = name
+ self._description = description
+ self.instructions = instructions
+ self._resources: list[SkillResource] = list(resources) if resources is not None else []
+ self._scripts: list[SkillScript] = list(scripts) if scripts is not None else []
+ self._cached_content: str | None = None
- def resource(
- self,
- func: Callable[..., Any] | None = None,
- *,
- name: str | None = None,
- description: str | None = None,
- ) -> Any:
- """Decorator that registers a callable as a resource on this skill.
+ @property
+ def name(self) -> str:
+ """Skill name (lowercase letters, numbers, hyphens only)."""
+ return self._name
- Supports bare usage (``@skill.resource``) and parameterized usage
- (``@skill.resource(name="custom", description="...")``). The
- decorated function is returned unchanged; a new
- :class:`SkillResource` is appended to :attr:`resources`.
+ @property
+ def description(self) -> str:
+ """Human-readable description of the skill."""
+ return self._description
- Args:
- func: The function being decorated. Populated automatically when
- the decorator is applied without parentheses.
+ @property
+ def content(self) -> str:
+ """Synthesized XML content with name, description, instructions, resources, and scripts.
- Keyword Args:
- name: Resource name override. Defaults to ``func.__name__``.
- description: Resource description override. Defaults to the
- function's docstring (via :func:`inspect.getdoc`).
+ The result is cached after the first access. Adding resources or
+ scripts after the first access will not be reflected.
+ """
+ if self._cached_content is not None:
+ return self._cached_content
+
+ result = (
+ f"{xml_escape(self._name)}\n"
+ f"{xml_escape(self._description)}\n"
+ "\n"
+ "\n"
+ f"{self.instructions}\n"
+ ""
+ )
+
+ if self._resources:
+ resource_lines = "\n".join(self._create_resource_element(r) for r in self._resources)
+ result += f"\n\n\n{resource_lines}\n"
+
+ if self._scripts:
+ script_lines = "\n".join(_create_script_element(s) for s in self._scripts)
+ result += f"\n\n\n{script_lines}\n"
+
+ self._cached_content = result
+ return result
+
+ @property
+ def resources(self) -> list[SkillResource]:
+ """Mutable list of :class:`SkillResource` instances."""
+ return self._resources
+
+ @property
+ def scripts(self) -> list[SkillScript]:
+ """Mutable list of :class:`SkillScript` instances."""
+ return self._scripts
+
+ @staticmethod
+ def _create_resource_element(resource: SkillResource) -> str:
+ """Create a self-closing ```` XML element from an :class:`SkillResource`.
+
+ Args:
+ resource: The resource to create the element from.
+
+ Returns:
+ A single indented XML element string with ``name`` and optional
+ ``description`` attributes.
+ """
+ attrs = f'name="{xml_escape(resource.name, quote=True)}"'
+ if resource.description:
+ attrs += f' description="{xml_escape(resource.description, quote=True)}"'
+ return f" "
+
+ def resource(
+ self,
+ func: Callable[..., Any] | None = None,
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ ) -> Any:
+ """Decorator that registers a callable as a resource on this skill.
+
+ Supports bare usage (``@skill.resource``) and parameterized usage
+ (``@skill.resource(name="custom", description="...")``). The
+ decorated function is returned unchanged; a new
+ :class:`SkillResource` is appended to :attr:`resources`.
+
+ Args:
+ func: The function being decorated. Populated automatically when
+ the decorator is applied without parentheses.
+
+ Keyword Args:
+ name: Resource name override. Defaults to ``func.__name__``.
+ description: Resource description override. Defaults to the
+ function's docstring (via :func:`inspect.getdoc`).
Returns:
The original function unchanged, or a secondary decorator when
@@ -334,8 +742,8 @@ async def get_data() -> Any:
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
resource_name = name or f.__name__
resource_description = description or (inspect.getdoc(f) or None)
- self.resources.append(
- SkillResource(
+ self._resources.append(
+ InlineSkillResource(
name=resource_name,
description=resource_description,
function=f,
@@ -396,8 +804,8 @@ async def fetch_data(url: str) -> str:
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
script_name = name or f.__name__
script_description = description or (inspect.getdoc(f) or None)
- self.scripts.append(
- SkillScript(
+ self._scripts.append(
+ InlineSkillScript(
name=script_name,
description=script_description,
function=f,
@@ -410,6 +818,72 @@ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
return decorator(func)
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class FileSkill(Skill):
+ """A :class:`Skill` discovered from a filesystem directory backed by a SKILL.md file.
+
+ Attributes:
+ name: Skill name (lowercase letters, numbers, hyphens only).
+ description: Human-readable description of the skill.
+ path: Absolute path to the directory containing this skill.
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str,
+ content: str,
+ path: str,
+ resources: Sequence[SkillResource] | None = None,
+ scripts: Sequence[SkillScript] | None = None,
+ ) -> None:
+ """Initialize a FileSkill.
+
+ Args:
+ name: Skill name (lowercase letters, numbers, hyphens only).
+ description: Human-readable description of the skill (≤1024 chars).
+ content: The full raw SKILL.md file content including YAML frontmatter.
+ path: Absolute path to the skill directory on disk.
+ resources: Resources discovered for this skill.
+ scripts: Scripts discovered for this skill.
+ """
+ _validate_skill_name(name)
+ _validate_skill_description(name, description)
+
+ self._name = name
+ self._description = description
+ self._content = content
+ self.path = path
+ self._resources: list[SkillResource] = list(resources) if resources is not None else []
+ self._scripts: list[SkillScript] = list(scripts) if scripts is not None else []
+
+ @property
+ def name(self) -> str:
+ """Skill name (lowercase letters, numbers, hyphens only)."""
+ return self._name
+
+ @property
+ def description(self) -> str:
+ """Human-readable description of the skill."""
+ return self._description
+
+ @property
+ def content(self) -> str:
+ """The skill content provided at construction time."""
+ return self._content
+
+ @property
+ def resources(self) -> list[SkillResource]:
+ """Resources discovered for this skill."""
+ return self._resources
+
+ @property
+ def scripts(self) -> list[SkillScript]:
+ """Scripts discovered for this skill."""
+ return self._scripts
+
+
# endregion
# region Script Runners
@@ -432,7 +906,7 @@ class SkillScriptRunner(Protocol):
satisfies this protocol.
"""
- def __call__(self, skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> Any:
+ def __call__(self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> Any:
"""Run a skill script.
The :class:`SkillsProvider` resolves skill and script names
@@ -440,8 +914,8 @@ def __call__(self, skill: Skill, script: SkillScript, args: dict[str, Any] | Non
resolved objects.
Args:
- skill: The skill that owns the script.
- script: The script to run.
+ skill: The file-based skill that owns the script.
+ script: The file-based script to run.
args: Optional keyword arguments for the script.
Returns:
@@ -502,14 +976,18 @@ def __call__(self, skill: Skill, script: SkillScript, args: dict[str, Any] | Non
When a task aligns with a skill's domain, follow these steps in exact order:
- Use `load_skill` to retrieve the skill's instructions.
- Follow the provided guidance.
-- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
- (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
+{resource_instructions}
{runner_instructions}
Only load what is needed, when it is needed."""
+RESOURCE_INSTRUCTIONS: Final[str] = (
+ "- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed\n"
+ ' (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).\n'
+)
+
SCRIPT_RUNNER_INSTRUCTIONS: Final[str] = (
- "\n- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
- "\n- Pass script arguments inside `args` as a JSON object"
+ "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed.\n"
+ "- Pass script arguments inside `args` as a JSON object"
' (e.g. `args: {"length": 24}`), not as top-level tool parameters.\n'
)
@@ -517,13 +995,16 @@ def __call__(self, skill: Skill, script: SkillScript, args: dict[str, Any] | Non
# region SkillsProvider
+_TSkillsProvider = TypeVar("_TSkillsProvider", bound="SkillsProvider")
+
@experimental(feature_id=ExperimentalFeature.SKILLS)
class SkillsProvider(ContextProvider):
"""Context provider that advertises skills and exposes skill tools.
- Supports both **file-based** skills (discovered from ``SKILL.md`` files)
- and **code-defined** skills (passed as :class:`Skill` instances).
+ Accepts a :class:`SkillsSource`, a single :class:`Skill`, or a
+ sequence of :class:`Skill` instances. For file-based skills, use
+ :meth:`from_paths` or the :class:`SkillsProviderBuilder`.
Follows the progressive-disclosure pattern from the
`Agent Skills specification `_:
@@ -539,30 +1020,32 @@ class SkillsProvider(ContextProvider):
symlink escape. Only use skills from trusted sources.
Examples:
- File-based only:
+ File-based factory (recommended):
.. code-block:: python
- provider = SkillsProvider(skill_paths="./skills")
+ provider = SkillsProvider.from_paths("./skills", script_runner=my_runner)
- Code-defined only:
+ Code-defined skills:
.. code-block:: python
- my_skill = Skill(
+ my_skill = InlineSkill(
name="my-skill",
description="Example skill",
- content="Use this skill for ...",
+ instructions="Use this skill for ...",
)
- provider = SkillsProvider(skills=[my_skill])
+ provider = SkillsProvider([my_skill])
- Combined:
+ Builder pattern:
.. code-block:: python
- provider = SkillsProvider(
- skill_paths="./skills",
- skills=[my_skill],
+ provider = (
+ SkillsProviderBuilder()
+ .add_file_skills("./skills", script_runner=my_runner)
+ .add_skill(my_code_skill)
+ .build()
)
Attributes:
@@ -573,42 +1056,109 @@ class SkillsProvider(ContextProvider):
def __init__(
self,
- skill_paths: str | Path | Sequence[str | Path] | None = None,
+ source: SkillsSource | Sequence[Skill] | Skill,
*,
- skills: Sequence[Skill] | None = None,
- script_runner: SkillScriptRunner | None = None,
instruction_template: str | None = None,
- resource_extensions: tuple[str, ...] | None = None,
- script_extensions: tuple[str, ...] | None = None,
require_script_approval: bool = False,
+ disable_caching: bool = False,
source_id: str | None = None,
) -> None:
"""Initialize a SkillsProvider.
+ Accepts a :class:`SkillsSource`, a single :class:`Skill`, or a
+ sequence of :class:`Skill` instances. When skills are passed
+ directly, they are automatically deduplicated.
+
+ For file-based skills, use :meth:`from_paths` or the
+ :class:`SkillsProviderBuilder`.
+
Args:
- skill_paths: One or more directory paths to search for file-based
- skills. Each path may point to an individual skill folder
- (containing ``SKILL.md``) or to a parent that contains skill
- subdirectories.
+ source: A :class:`SkillsSource`, a single :class:`Skill`,
+ or a sequence of :class:`Skill` instances.
Keyword Args:
- skills: Code-defined :class:`Skill` instances to register.
- script_runner: Strategy for running **file-based** skill
- scripts. The provider resolves skill and script names, then
- calls the runner directly. This parameter only
- affects scripts discovered from disk (via *skill_paths*);
- code-defined scripts (registered with ``@skill.script``) are
- always executed in-process and ignore this setting.
- When ``None``, file-based scripts are not executable.
instruction_template: Custom system-prompt template for
- advertising skills. Must contain a ``{skills}`` placeholder for the
- generated skills list. Uses a built-in template when ``None``.
+ advertising skills. Must contain a ``{skills}`` placeholder for the
+ generated skills list. If the provider includes file-based script
+ execution instructions, the template must also contain
+ ``{runner_instructions}``. If the provider includes resource-reading
+ instructions, the template must also contain
+ ``{resource_instructions}``. Omitting any placeholder required by
+ the resolved skills configuration can raise :class:`ValueError` at
+ runtime. Uses a built-in template when ``None``.
+ require_script_approval: When ``True``, skill script execution
+ requires explicit user approval before running. Instead of
+ executing immediately, the agent pauses and returns a
+ ``function_approval_request`` via ``result.user_input_requests``.
+ The application should present the request to the user, then
+ call ``request.to_function_approval_response(approved=True)``
+ (or ``False`` to reject) and pass the response back with
+ ``agent.run(approval_response, session=session)``.
+ Rejected scripts are not executed and the agent is informed
+ the user declined. Defaults to ``False``. See
+ ``samples/02-agents/skills/script_approval/script_approval.py``
+ for the full approval loop pattern.
+ disable_caching: When ``True``, rebuilds tools and instructions
+ from the source on every invocation instead of caching
+ after the first build. Defaults to ``False``.
+ source_id: Unique identifier for this provider instance.
+ """
+ super().__init__(source_id or self.DEFAULT_SOURCE_ID)
+
+ if isinstance(source, (str, Path)):
+ raise TypeError(
+ f"SkillsProvider does not accept path strings directly. "
+ f"Use SkillsProvider.from_paths({source!r}) for file-based skills."
+ )
+
+ if isinstance(source, Skill):
+ source = _DeduplicatingSkillsSource(_InMemorySkillsSource([source]))
+ elif isinstance(source, SkillsSource):
+ pass
+ else:
+ source = _DeduplicatingSkillsSource(_InMemorySkillsSource(list(source)))
+
+ self._source = source
+ self._instruction_template = instruction_template
+ self._require_script_approval = require_script_approval
+ self._disable_caching = disable_caching
+
+ # Lazy-initialized via _get_or_create_context / _create_context
+ self._cached_context: tuple[Sequence[Skill], str | None, list[FunctionTool]] | None = None
+
+ @classmethod
+ def from_paths(
+ cls: type[_TSkillsProvider],
+ skill_paths: str | Path | Sequence[str | Path],
+ *,
+ script_runner: SkillScriptRunner | None = None,
+ resource_extensions: tuple[str, ...] | None = None,
+ script_extensions: tuple[str, ...] | None = None,
+ instruction_template: str | None = None,
+ require_script_approval: bool = False,
+ disable_caching: bool = False,
+ source_id: str | None = None,
+ ) -> _TSkillsProvider:
+ """Create a provider from one or more file-based skill directories.
+
+ Discovers skills from ``SKILL.md`` files in the given directories,
+ deduplicates them, and creates the provider.
+
+ Args:
+ skill_paths: One or more directory paths to search for
+ file-based skills.
+
+ Keyword Args:
+ script_runner: Strategy for running file-based skill scripts.
+ When ``None``, file-based scripts are not executable.
resource_extensions: File extensions recognized as discoverable
- resources. Defaults to ``DEFAULT_RESOURCE_EXTENSIONS``
- (``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``).
+ resources. Defaults to
+ ``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``.
script_extensions: File extensions recognized as discoverable
- scripts. Defaults to ``DEFAULT_SCRIPT_EXTENSIONS``
- (``(".py",)``).
+ scripts. Defaults to ``(".py",)``.
+ instruction_template: Custom system-prompt template for
+ advertising skills. Must contain a ``{skills}`` placeholder.
+ Uses a built-in template when ``None``.
require_script_approval: When ``True``, skill script execution
requires explicit user approval before running. Instead of
executing immediately, the agent pauses and returns a
@@ -621,42 +1171,172 @@ def __init__(
the user declined. Defaults to ``False``. See
``samples/02-agents/skills/script_approval/script_approval.py``
for the full approval loop pattern.
+ disable_caching: When ``True``, rebuilds tools and instructions
+ from the source on every invocation instead of caching
+ after the first build.
source_id: Unique identifier for this provider instance.
+
+ Returns:
+ A configured :class:`SkillsProvider`.
"""
- super().__init__(source_id or self.DEFAULT_SOURCE_ID)
+ source = _DeduplicatingSkillsSource(
+ _FileSkillsSource(
+ skill_paths,
+ script_runner=script_runner,
+ resource_extensions=resource_extensions,
+ script_extensions=script_extensions,
+ )
+ )
+ return cls(
+ source,
+ instruction_template=instruction_template,
+ require_script_approval=require_script_approval,
+ disable_caching=disable_caching,
+ source_id=source_id,
+ )
+
+ @staticmethod
+ def _create_instructions(
+ prompt_template: str | None,
+ skills: Sequence[Skill],
+ include_script_runner_instructions: bool = False,
+ include_resource_instructions: bool = False,
+ ) -> str | None:
+ """Create the system-prompt text that advertises available skills.
+
+ Generates an XML list of ```` elements (sorted by name) and
+ inserts it into *prompt_template* at the ``{skills}`` placeholder.
+ When *include_script_runner_instructions* is ``True``, executor-provided
+ instructions are inserted at the ``{runner_instructions}`` placeholder.
+ When *include_resource_instructions* is ``True``, resource-reading
+ instructions are inserted at the ``{resource_instructions}`` placeholder.
+
+ Args:
+ prompt_template: Custom template string with ``{skills}`` and
+ optional ``{runner_instructions}`` and ``{resource_instructions}``
+ placeholders, or ``None`` to use the built-in default.
+ skills: Registered skills.
+ include_script_runner_instructions: When ``True``, include
+ script-runner instructions in the generated prompt.
+ Defaults to ``False``.
+ include_resource_instructions: When ``True``, include
+ resource-reading instructions in the generated prompt.
+ Defaults to ``False``.
+
+ Returns:
+ The formatted instruction string, or ``None`` when *skills* is empty.
- self._skills = _load_skills(
- skill_paths,
- skills,
- resource_extensions or DEFAULT_RESOURCE_EXTENSIONS,
- script_extensions or DEFAULT_SCRIPT_EXTENSIONS,
+ Raises:
+ ValueError: If *prompt_template* is not a valid format string
+ (e.g. missing ``{skills}`` placeholder).
+ """
+ runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
+ resource_instructions = RESOURCE_INSTRUCTIONS if include_resource_instructions else None
+ template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
+
+ if prompt_template is not None:
+ # Validate that the custom template contains a valid {skills} placeholder
+ try:
+ result = prompt_template.format(
+ skills="__PROBE__",
+ runner_instructions="__EXEC_PROBE__",
+ resource_instructions="__RES_PROBE__",
+ )
+ except (KeyError, IndexError, ValueError) as exc:
+ raise ValueError(
+ "The provided instruction_template is not a valid format string. "
+ "It must contain a '{skills}' placeholder and escape any literal" # noqa: RUF027
+ " '{' or '}' "
+ "by doubling them ('{{' or '}}')."
+ ) from exc
+ if "__PROBE__" not in result:
+ raise ValueError(
+ "The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
+ )
+ if runner_instructions and "__EXEC_PROBE__" not in result:
+ raise ValueError(
+ "The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
+ "when a script runner is configured."
+ )
+ if resource_instructions and "__RES_PROBE__" not in result:
+ raise ValueError(
+ "The provided instruction_template must contain a '{resource_instructions}' placeholder " # noqa: RUF027
+ "when skills have resources."
+ )
+ template = prompt_template
+
+ if not skills:
+ return None
+
+ lines: list[str] = []
+ # Sort by name for deterministic output
+ for skill in sorted(skills, key=lambda s: s.name):
+ lines.append(" ")
+ lines.append(f" {xml_escape(skill.name)}")
+ lines.append(f" {xml_escape(skill.description)}")
+ lines.append(" ")
+
+ return template.format(
+ skills="\n".join(lines),
+ runner_instructions=runner_instructions or "",
+ resource_instructions=resource_instructions or "",
)
- # File-based skills (skill.path set) have scripts discovered from disk
- has_file_scripts = any(s.scripts for s in self._skills.values() if s.path is not None)
+ async def _create_context(self) -> tuple[Sequence[Skill], str | None, list[FunctionTool]]:
+ """Build skills, instructions, and tools from the source.
- # Code-defined skills (skill.path is None) have scripts with callable functions
- has_code_scripts = any(s.scripts for s in self._skills.values() if s.path is None)
+ Always performs a fresh build by querying the source and
+ constructing the instruction prompt and tool definitions.
- if has_file_scripts and script_runner is None:
- raise ValueError(
- "File-based skills with scripts were provided but no 'script_runner' was provided. "
- "Pass a SkillScriptRunner callable to SkillsProvider."
- )
+ Returns:
+ A tuple of ``(skills, instructions, tools)``.
+ """
+ skills = await self._source.get_skills()
- self._script_runner = script_runner
+ if not skills:
+ return skills, None, []
- self._instructions = _create_instructions(
- prompt_template=instruction_template,
- skills=self._skills,
- include_script_runner_instructions=has_file_scripts or has_code_scripts,
+ has_scripts = any(s.scripts for s in skills)
+ has_resources = any(s.resources for s in skills)
+
+ instructions = self._create_instructions(
+ prompt_template=self._instruction_template,
+ skills=skills,
+ include_script_runner_instructions=has_scripts,
+ include_resource_instructions=has_resources,
)
- self._tools = self._create_tools(
- include_script_runner_tool=has_file_scripts or has_code_scripts,
- require_script_approval=require_script_approval,
+ tools = self._create_tools(
+ skills=skills,
+ include_script_runner_tool=has_scripts,
+ include_resource_tool=has_resources,
+ require_script_approval=self._require_script_approval,
)
+ return skills, instructions, tools
+
+ async def _get_or_create_context(self) -> tuple[Sequence[Skill], str | None, list[FunctionTool]]:
+ """Return the cached context, building it on first call.
+
+ On the first call, delegates to :meth:`_create_context` and caches
+ the result. Subsequent calls return the cached result immediately.
+ If the first build fails, the cache is reset so the next call
+ retries.
+
+ Returns:
+ A tuple of ``(skills, instructions, tools)``.
+ """
+ if self._cached_context is not None:
+ return self._cached_context
+
+ try:
+ result = await self._create_context()
+ self._cached_context = result
+ return result
+ except Exception:
+ self._cached_context = None
+ raise
+
async def before_run(
self,
*,
@@ -667,7 +1347,9 @@ async def before_run(
) -> None:
"""Inject skill instructions and tools into the session context.
- Called by the framework before the agent runs. When at least one
+ Called by the framework before the agent runs. On the first call,
+ loads skills from the configured source asynchronously and builds
+ the instruction prompt and tool definitions. When at least one
skill is registered, appends the skill-list system prompt and the
``load_skill`` / ``read_skill_resource`` tools to *context*.
@@ -682,25 +1364,37 @@ async def before_run(
context: Session context to extend with instructions and tools.
state: Mutable per-run state dictionary (unused by this provider).
"""
- if not self._skills:
+ if self._disable_caching:
+ skills, instructions, tools = await self._create_context()
+ else:
+ skills, instructions, tools = await self._get_or_create_context()
+
+ if not skills:
return
- context.extend_instructions(self.source_id, self._instructions) # type: ignore[arg-type]
- context.extend_tools(self.source_id, self._tools)
+ context.extend_instructions(self.source_id, instructions) # type: ignore[arg-type]
+ context.extend_tools(self.source_id, tools)
def _create_tools(
self,
+ skills: Sequence[Skill],
include_script_runner_tool: bool,
+ include_resource_tool: bool,
require_script_approval: bool = False,
) -> list[FunctionTool]:
- """Create the ``load_skill`` and ``read_skill_resource`` tool definitions.
+ """Create the tool definitions for skill interaction.
- When *include_script_runner_tool* is ``True``, also creates
- ``run_skill_script``.
+ Always includes ``load_skill``. Conditionally includes
+ ``read_skill_resource`` (when *include_resource_tool* is ``True``)
+ and ``run_skill_script`` (when *include_script_runner_tool* is
+ ``True``).
Args:
+ skills: The skills to bind to tool handlers.
include_script_runner_tool: Whether to include the
``run_skill_script`` tool in the returned list.
+ include_resource_tool: Whether to include the
+ ``read_skill_resource`` tool in the returned list.
require_script_approval: When ``True``, the
``run_skill_script`` tool pauses for user approval
before each invocation.
@@ -712,7 +1406,7 @@ def _create_tools(
FunctionTool(
name="load_skill",
description="Loads the full instructions for a specific skill.",
- func=self._load_skill,
+ func=lambda skill_name: self._load_skill(skills, skill_name), # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType]
input_model={
"type": "object",
"properties": {
@@ -721,30 +1415,46 @@ def _create_tools(
"required": ["skill_name"],
},
),
- FunctionTool(
- name="read_skill_resource",
- description="Reads a resource associated with a skill, such as references, assets, or dynamic data.",
- func=self._read_skill_resource,
- input_model={
- "type": "object",
- "properties": {
- "skill_name": {"type": "string", "description": "The name of the skill."},
- "resource_name": {
- "type": "string",
- "description": "The name of the resource.",
+ ]
+
+ if include_resource_tool:
+
+ async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
+ return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
+
+ tools.append(
+ FunctionTool(
+ name="read_skill_resource",
+ description=(
+ "Reads a resource associated with a skill, such as references, assets, or dynamic data."
+ ),
+ func=_read_resource,
+ input_model={
+ "type": "object",
+ "properties": {
+ "skill_name": {"type": "string", "description": "The name of the skill."},
+ "resource_name": {
+ "type": "string",
+ "description": "The name of the resource.",
+ },
},
+ "required": ["skill_name", "resource_name"],
},
- "required": ["skill_name", "resource_name"],
- },
- ),
- ]
+ )
+ )
if include_script_runner_tool:
+
+ async def _run_script(
+ skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any
+ ) -> Any:
+ return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
+
tools.append(
FunctionTool(
name="run_skill_script",
description="Runs a script associated with a skill.",
- func=self._run_skill_script,
+ func=_run_script,
approval_mode="always_require" if require_script_approval else "never_require",
input_model={
"type": "object",
@@ -778,63 +1488,52 @@ def _create_tools(
return tools
- def _load_skill(self, skill_name: str) -> str:
- """Return the full instructions for the named skill.
+ @staticmethod
+ def _find_skill(skills: Sequence[Skill], name: str) -> Skill | None:
+ """Find a skill by name (case-insensitive linear scan)."""
+ name_lower = name.lower()
+ return next((s for s in skills if s.name.lower() == name_lower), None)
+
+ def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
+ """Return the full content for the named skill.
- For file-based skills the raw ``SKILL.md`` content is returned as-is.
- For code-defined skills the content is wrapped in XML metadata and,
- when resources exist, an ```` element is appended.
+ Delegates to the skill's :attr:`~Skill.content` property, which
+ handles format differences between file-based and code-defined skills.
Args:
+ skills: The skills to look up the skill from.
skill_name: The name of the skill to load.
Returns:
- The skill instructions text, or a user-facing error message if
+ The skill content text, or a user-facing error message if
*skill_name* is empty or not found.
"""
if not skill_name or not skill_name.strip():
return "Error: Skill name cannot be empty."
- skill = self._skills.get(skill_name)
+ skill = self._find_skill(skills, skill_name)
if skill is None:
return f"Error: Skill '{skill_name}' not found."
logger.info("Loading skill: %s", skill_name)
- # File-based skills return raw content directly
- if skill.path:
- return skill.content
-
- # Code-defined skills: wrap in XML metadata
- content = (
- f"{xml_escape(skill.name)}\n"
- f"{xml_escape(skill.description)}\n"
- "\n"
- "\n"
- f"{skill.content}\n"
- ""
- )
-
- if skill.resources:
- resource_lines = "\n".join(_create_resource_element(r) for r in skill.resources)
- content += f"\n\n\n{resource_lines}\n"
-
- if skill.scripts:
- script_lines = "\n".join(_create_script_element(s) for s in skill.scripts)
- content += f"\n\n\n{script_lines}\n"
-
- return content
+ return skill.content
async def _run_skill_script(
- self, skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any
+ self,
+ skills: Sequence[Skill],
+ skill_name: str,
+ script_name: str,
+ args: dict[str, Any] | None = None,
+ **kwargs: Any,
) -> Any:
"""Run a named script from a skill.
- For code-defined scripts (those with a ``function`` and no ``path``),
- the function is invoked directly in-process. For file-based scripts
- the configured :class:`SkillScriptRunner` is used.
+ Resolves the skill and script by name, then delegates execution
+ to :meth:`SkillScript.run`.
Args:
+ skills: The skills to look up the skill from.
skill_name: The name of the owning skill.
script_name: The script name to look up (case-insensitive).
args: Optional keyword arguments for the script, provided by the
@@ -854,7 +1553,7 @@ async def _run_skill_script(
if not script_name or not script_name.strip():
return "Error: Script name cannot be empty."
- skill = self._skills.get(skill_name)
+ skill = self._find_skill(skills, skill_name)
if not skill:
return f"Error: Skill '{skill_name}' not found."
@@ -862,36 +1561,15 @@ async def _run_skill_script(
if not script:
return f"Error: Script '{script_name}' not found in skill '{skill_name}'."
- # Code-defined scripts: run the function directly
- if script.function is not None:
- try:
- if script._accepts_kwargs: # pyright: ignore[reportPrivateUsage]
- result = script.function(**(args or {}), **kwargs)
- else:
- result = script.function(**(args or {}))
- if inspect.isawaitable(result):
- result = await result
- return result
- except Exception:
- logger.exception("Error running code-defined script '%s' in skill '%s'", script_name, skill_name)
- return f"Error: Failed to run script '{script_name}' in skill '{skill_name}'."
-
- # File-based scripts: delegate to the runner
- if self._script_runner is None:
- return (
- f"Error: Script '{script_name}' in skill '{skill_name}' requires a runner. "
- "Provide a script_runner for file-based scripts."
- )
try:
- result = self._script_runner(skill, script, args)
- if inspect.isawaitable(result):
- result = await result
- return result
+ return await script.run(skill, args, **kwargs)
except Exception:
- logger.exception("Error running file-based script '%s' in skill '%s'", script_name, skill_name)
+ logger.exception("Error running script '%s' in skill '%s'", script_name, skill_name)
return f"Error: Failed to run script '{script_name}' in skill '{skill_name}'."
- async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwargs: Any) -> Any:
+ async def _read_skill_resource(
+ self, skills: Sequence[Skill], skill_name: str, resource_name: str, **kwargs: Any
+ ) -> Any:
"""Read a named resource from a skill.
Resolves the resource by case-insensitive name lookup. Static
@@ -899,6 +1577,7 @@ async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwar
(awaited if async).
Args:
+ skills: The skills to look up the skill from.
skill_name: The name of the owning skill.
resource_name: The resource name to look up (case-insensitive).
**kwargs: Runtime keyword arguments forwarded to resource functions
@@ -915,7 +1594,7 @@ async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwar
if not resource_name or not resource_name.strip():
return "Error: Resource name cannot be empty."
- skill = self._skills.get(skill_name)
+ skill = self._find_skill(skills, skill_name)
if skill is None:
return f"Error: Skill '{skill_name}' not found."
@@ -927,632 +1606,999 @@ async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwar
else:
return f"Error: Resource '{resource_name}' not found in skill '{skill_name}'."
- if resource.content is not None:
- return resource.content
-
- if resource.function is not None:
- try:
- if inspect.iscoroutinefunction(resource.function):
- result = (
- await resource.function(**kwargs) if resource._accepts_kwargs else await resource.function() # pyright: ignore[reportPrivateUsage]
- )
- else:
- result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function() # pyright: ignore[reportPrivateUsage]
- return result
- except Exception:
- logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
- return f"Error: Failed to read resource '{resource_name}' from skill '{skill_name}'."
-
- return f"Error: Resource '{resource.name}' has no content or function."
+ try:
+ return await resource.read(**kwargs)
+ except Exception:
+ logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
+ return f"Error: Failed to read resource '{resource_name}' from skill '{skill_name}'."
# endregion
-# region Module-level helper functions
-
-
-def _normalize_resource_path(path: str) -> str:
- """Normalize a relative resource path to a canonical forward-slash form.
-
- Converts backslashes to forward slashes and strips leading ``./``
- prefixes so that ``./refs/doc.md`` and ``refs/doc.md`` resolve
- identically.
-
- Args:
- path: The relative path to normalize.
-
- Returns:
- A clean forward-slash-separated path string.
- """
- return PurePosixPath(path.replace("\\", "/")).as_posix()
+# endregion
-def _is_path_within_directory(path: str, directory: str) -> bool:
- """Return whether *path* resides under *directory*.
+def _create_script_element(script: SkillScript) -> str:
+ """Create an XML ``"
+ return f" "
- Only segments below *directory* are inspected; the directory itself
- and anything above it are not checked.
- **Precondition:** *path* must be a descendant of *directory*.
- Call :func:`_is_path_within_directory` first to verify containment.
+# endregion
- Args:
- path: Absolute path to inspect.
- directory: Root directory; segments above it are not checked.
+# region Skill Sources
- Returns:
- ``True`` if any intermediate segment below *directory* is a symlink.
- Raises:
- ValueError: If *path* is not relative to *directory*.
- """
- dir_path = Path(directory)
- try:
- relative = Path(path).relative_to(dir_path)
- except ValueError as exc:
- raise ValueError(f"path {path!r} does not start with directory {directory!r}") from exc
-
- current = dir_path
- for part in relative.parts:
- current = current / part
- if current.is_symlink():
- return True
- return False
-
-
-def _discover_resource_files(
- skill_dir_path: str,
- extensions: tuple[str, ...] = DEFAULT_RESOURCE_EXTENSIONS,
-) -> list[str]:
- """Scan a skill directory for resource files matching *extensions*.
-
- Recursively walks *skill_dir_path* and collects files whose extension
- is in *extensions*, excluding ``SKILL.md`` itself. Each candidate is
- validated against path-traversal and symlink-escape checks; unsafe
- files are skipped with a warning.
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class SkillsSource(ABC):
+ """Abstract base class for skill sources.
- Args:
- skill_dir_path: Absolute path to the skill directory to scan.
- extensions: Tuple of allowed file extensions (e.g. ``(".md", ".json")``).
+ A skill source discovers and returns :class:`Skill` instances from a
+ particular origin. The framework calls :meth:`get_skills` to obtain
+ the available skills; implementations decide *where* and *how* skills
+ are discovered (filesystem, memory, network, etc.).
- Returns:
- Relative resource paths (forward-slash-separated) for every
- discovered file that passes security checks.
+ Subclass this to create custom skill sources.
"""
- skill_dir = Path(skill_dir_path).absolute()
- root_directory_path = str(skill_dir)
- resources: list[str] = []
- normalized_extensions = {e.lower() for e in extensions}
- for resource_file in skill_dir.rglob("*"):
- if not resource_file.is_file():
- continue
+ @abstractmethod
+ async def get_skills(self) -> list[Skill]:
+ """Discover and return all skills from this source.
- if resource_file.name.upper() == SKILL_FILE_NAME.upper():
- continue
-
- if resource_file.suffix.lower() not in normalized_extensions:
- continue
+ Returns:
+ A list of :class:`Skill` instances discovered by this source.
+ """
+ ...
- resource_full_path = str(Path(os.path.normpath(resource_file)).absolute())
- if not _is_path_within_directory(resource_full_path, root_directory_path):
- logger.warning(
- "Skipping resource '%s': resolves outside skill directory '%s'",
- resource_file,
- skill_dir_path,
- )
- continue
+class _FileSkillsSource(SkillsSource):
+ """Skill source that discovers skills from filesystem ``SKILL.md`` files.
- if _has_symlink_in_path(resource_full_path, root_directory_path):
- logger.warning(
- "Skipping resource '%s': symlink detected in path under skill directory '%s'",
- resource_file,
- skill_dir_path,
- )
- continue
+ Recursively scans the configured *skill_paths* directories for
+ ``SKILL.md`` files (up to 2 levels deep), parses their YAML frontmatter,
+ and discovers associated resource and script files from subdirectories.
- rel_path = resource_file.relative_to(skill_dir)
- resources.append(_normalize_resource_path(str(rel_path)))
+ Security: file-based metadata is XML-escaped before prompt injection,
+ and resource reads are guarded against path traversal and symlink escape.
+ Only use skills from trusted sources.
- return resources
+ Examples:
+ Basic usage:
+ .. code-block:: python
-def _discover_script_files(
- skill_dir_path: str,
- extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
-) -> list[str]:
- """Scan a skill directory for script files matching *extensions*.
+ source = _FileSkillsSource(skill_paths="./skills")
+ skills = await source.get_skills()
- Recursively walks *skill_dir_path* and collects files whose extension
- is in *extensions*. Each candidate is validated against path-traversal
- and symlink-escape checks; unsafe files are skipped with a warning.
+ With a script runner and custom extensions:
- Args:
- skill_dir_path: Absolute path to the skill directory to scan.
- extensions: Tuple of allowed script extensions (e.g. ``(".py",)``).
+ .. code-block:: python
- Returns:
- Relative script paths (forward-slash-separated) for every
- discovered file that passes security checks.
+ source = _FileSkillsSource(
+ skill_paths=["./skills", "./more-skills"],
+ script_runner=my_runner,
+ script_extensions=(".py", ".sh"),
+ )
"""
- skill_dir = Path(skill_dir_path).absolute()
- root_directory_path = str(skill_dir)
- scripts: list[str] = []
- normalized_extensions = {e.lower() for e in extensions}
-
- for script_file in skill_dir.rglob("*"):
- if not script_file.is_file():
- continue
-
- if script_file.suffix.lower() not in normalized_extensions:
- continue
- script_full_path = str(Path(os.path.normpath(script_file)).absolute())
+ def __init__(
+ self,
+ skill_paths: str | Path | Sequence[str | Path],
+ *,
+ script_runner: SkillScriptRunner | None = None,
+ resource_extensions: tuple[str, ...] | None = None,
+ script_extensions: tuple[str, ...] | None = None,
+ ) -> None:
+ """Initialize a _FileSkillsSource.
- if not _is_path_within_directory(script_full_path, root_directory_path):
- logger.warning(
- "Skipping script '%s': resolves outside skill directory '%s'",
- script_file,
- skill_dir_path,
- )
- continue
+ Args:
+ skill_paths: One or more directory paths to search for file-based
+ skills. Each path may point to an individual skill folder
+ (containing ``SKILL.md``) or to a parent that contains skill
+ subdirectories.
+
+ Keyword Args:
+ script_runner: Strategy for running file-based skill scripts.
+ When ``None``, discovered scripts are included but not
+ executable (the provider will raise an error if execution
+ is attempted without a runner).
+ resource_extensions: File extensions recognized as discoverable
+ resources. Defaults to
+ ``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``.
+ script_extensions: File extensions recognized as discoverable
+ scripts. Defaults to ``(".py",)``.
+ """
+ if isinstance(skill_paths, (str, Path)):
+ self._skill_paths: list[str] = [str(skill_paths)]
+ else:
+ self._skill_paths = [str(p) for p in skill_paths]
+
+ self._script_runner = script_runner
+ self._resource_extensions = resource_extensions or DEFAULT_RESOURCE_EXTENSIONS
+ self._script_extensions = script_extensions or DEFAULT_SCRIPT_EXTENSIONS
+
+ async def get_skills(self) -> list[Skill]:
+ """Discover and return all file-based skills from configured paths.
- if _has_symlink_in_path(script_full_path, root_directory_path):
- logger.warning(
- "Skipping script '%s': symlink detected in path under skill directory '%s'",
- script_file,
- skill_dir_path,
+ Scans directories for ``SKILL.md`` files, parses their frontmatter,
+ discovers resource and script files, and returns populated
+ :class:`Skill` instances.
+
+ Returns:
+ A list of discovered file-based skills.
+ """
+ skills: dict[str, FileSkill] = {}
+
+ discovered = _FileSkillsSource._discover_skill_directories(self._skill_paths)
+ logger.info("Discovered %d potential skills", len(discovered))
+
+ for skill_path in discovered:
+ parsed = _FileSkillsSource._read_and_parse_skill_file(skill_path)
+ if parsed is None:
+ continue
+
+ name, description, content = parsed
+
+ if name in skills:
+ logger.warning(
+ "Duplicate skill name '%s': skill from '%s' skipped in favor of existing skill",
+ name,
+ skill_path,
+ )
+ continue
+
+ file_skill = FileSkill(
+ name=name,
+ description=description,
+ content=content,
+ path=skill_path,
)
- continue
- rel_path = script_file.relative_to(skill_dir)
- scripts.append(_normalize_resource_path(str(rel_path)))
+ # Discover and attach file-based resources
+ for rn in _FileSkillsSource._discover_resource_files(skill_path, self._resource_extensions):
+ resource_full_path = _FileSkillsSource._get_validated_resource_path(skill_path, rn)
+ file_skill.resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
- return scripts
+ # Discover and attach file-based scripts as SkillScript instances
+ for sn in _FileSkillsSource._discover_script_files(skill_path, self._script_extensions):
+ script_full_path = os.path.normpath(os.path.join(skill_path, sn)) # noqa: ASYNC240
+ file_skill.scripts.append(
+ FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner)
+ )
+ skills[file_skill.name] = file_skill
+ logger.info("Loaded skill: %s", file_skill.name)
-def _validate_skill_metadata(
- name: str | None,
- description: str | None,
- source: str,
-) -> str | None:
- """Validate a skill's name and description against naming rules.
+ logger.info("Successfully loaded %d skills", len(skills))
+ return list(skills.values())
- Enforces length limits, character-set restrictions, and non-emptiness
- for both file-based and code-defined skills.
+ @staticmethod
+ def _normalize_resource_path(path: str) -> str:
+ """Normalize a relative resource path to a canonical forward-slash form.
- Args:
- name: Skill name to validate.
- description: Skill description to validate.
- source: Human-readable label for diagnostics (e.g. a file path
- or ``"code skill"``).
+ Converts backslashes to forward slashes and strips leading ``./``
+ prefixes so that ``./refs/doc.md`` and ``refs/doc.md`` resolve
+ identically.
- Returns:
- A diagnostic error string if validation fails, or ``None`` if valid.
- """
- if not name or not name.strip():
- return f"Skill from '{source}' is missing a name."
+ Args:
+ path: The relative path to normalize.
- if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
- return (
- f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
- "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
- "or contain consecutive hyphens."
- )
+ Returns:
+ A clean forward-slash-separated path string.
+ """
+ return PurePosixPath(path.replace("\\", "/")).as_posix()
- if not description or not description.strip():
- return f"Skill '{name}' from '{source}' is missing a description."
+ @staticmethod
+ def _is_path_within_directory(path: str, directory: str) -> bool:
+ """Return whether *path* resides under *directory*.
- if len(description) > MAX_DESCRIPTION_LENGTH:
- return (
- f"Skill '{name}' from '{source}' has an invalid description: "
- f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
- )
+ Comparison uses :meth:`pathlib.Path.is_relative_to`, which respects
+ per-platform case-sensitivity rules.
- return None
+ Args:
+ path: Absolute path to check.
+ directory: Directory that must be an ancestor of *path*.
+ Returns:
+ ``True`` if *path* is a descendant of *directory*.
+ """
+ try:
+ return Path(path).is_relative_to(directory)
+ except (ValueError, OSError):
+ return False
-def _extract_frontmatter(
- content: str,
- skill_file_path: str,
-) -> tuple[str, str] | None:
- """Extract and validate YAML frontmatter from a SKILL.md file.
+ @staticmethod
+ def _has_symlink_in_path(path: str, directory: str) -> bool:
+ """Detect symlinks in the portion of *path* below *directory*.
- Parses the ``---``-delimited frontmatter block for ``name`` and
- ``description`` fields.
+ Only segments below *directory* are inspected; the directory itself
+ and anything above it are not checked.
- Args:
- content: Raw text content of the SKILL.md file.
- skill_file_path: Path to the file (used in diagnostic messages only).
+ **Precondition:** *path* must be a descendant of *directory*.
+ Call :meth:`_is_path_within_directory` first to verify containment.
- Returns:
- A ``(name, description)`` tuple on success, or ``None`` if the
- frontmatter is missing, malformed, or fails validation.
- """
- match = FRONTMATTER_RE.search(content)
- if not match:
- logger.error("SKILL.md at '%s' does not contain valid YAML frontmatter delimited by '---'", skill_file_path)
- return None
+ Args:
+ path: Absolute path to inspect.
+ directory: Root directory; segments above it are not checked.
- yaml_content = match.group(1).strip()
- name: str | None = None
- description: str | None = None
+ Returns:
+ ``True`` if any intermediate segment below *directory* is a symlink.
- for kv_match in YAML_KV_RE.finditer(yaml_content):
- key = kv_match.group(1)
- value = kv_match.group(2) if kv_match.group(2) is not None else kv_match.group(3)
+ Raises:
+ ValueError: If *path* is not relative to *directory*.
+ """
+ dir_path = Path(directory)
+ try:
+ relative = Path(path).relative_to(dir_path)
+ except ValueError as exc:
+ raise ValueError(f"path {path!r} does not start with directory {directory!r}") from exc
+
+ current = dir_path
+ for part in relative.parts:
+ current = current / part
+ if current.is_symlink():
+ return True
+ return False
- if key.lower() == "name":
- name = value
- elif key.lower() == "description":
- description = value
+ @staticmethod
+ def _discover_resource_files(
+ skill_dir_path: str,
+ extensions: tuple[str, ...] = DEFAULT_RESOURCE_EXTENSIONS,
+ ) -> list[str]:
+ """Scan a skill directory for resource files matching *extensions*.
- error = _validate_skill_metadata(name, description, skill_file_path)
- if error:
- logger.error(error)
- return None
+ Recursively walks *skill_dir_path* and collects files whose extension
+ is in *extensions*, excluding ``SKILL.md`` itself. Each candidate is
+ validated against path-traversal and symlink-escape checks; unsafe
+ files are skipped with a warning.
- # name and description are guaranteed non-None after validation
- return name, description # type: ignore[return-value]
+ Args:
+ skill_dir_path: Absolute path to the skill directory to scan.
+ extensions: Tuple of allowed file extensions (e.g. ``(".md", ".json")``).
+ Returns:
+ Relative resource paths (forward-slash-separated) for every
+ discovered file that passes security checks.
+ """
+ skill_dir = Path(skill_dir_path).absolute()
+ root_directory_path = str(skill_dir)
+ resources: list[str] = []
+ normalized_extensions = {e.lower() for e in extensions}
-def _read_and_parse_skill_file(
- skill_dir_path: str,
-) -> tuple[str, str, str] | None:
- """Read and parse the SKILL.md file in *skill_dir_path*.
+ for resource_file in skill_dir.rglob("*"):
+ if not resource_file.is_file():
+ continue
- Args:
- skill_dir_path: Absolute path to the directory containing ``SKILL.md``.
+ if resource_file.name.upper() == SKILL_FILE_NAME.upper():
+ continue
- Returns:
- A ``(name, description, content)`` tuple where *content* is the
- full raw file text, or ``None`` if the file cannot be read or
- its frontmatter is invalid.
- """
- skill_file = Path(skill_dir_path) / SKILL_FILE_NAME
+ if resource_file.suffix.lower() not in normalized_extensions:
+ continue
- try:
- content = skill_file.read_text(encoding="utf-8")
- except OSError:
- logger.error("Failed to read SKILL.md at '%s'", skill_file)
- return None
+ resource_full_path = str(Path(os.path.normpath(resource_file)).absolute())
- result = _extract_frontmatter(content, str(skill_file))
- if result is None:
- return None
+ if not _FileSkillsSource._is_path_within_directory(resource_full_path, root_directory_path):
+ logger.warning(
+ "Skipping resource '%s': resolves outside skill directory '%s'",
+ resource_file,
+ skill_dir_path,
+ )
+ continue
- name, description = result
+ if _FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path):
+ logger.warning(
+ "Skipping resource '%s': symlink detected in path under skill directory '%s'",
+ resource_file,
+ skill_dir_path,
+ )
+ continue
- dir_name = Path(skill_dir_path).name
- if name != dir_name:
- logger.error(
- "SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.",
- skill_file,
- name,
- dir_name,
- )
- return None
+ rel_path = resource_file.relative_to(skill_dir)
+ resources.append(_FileSkillsSource._normalize_resource_path(str(rel_path)))
- return name, description, content
+ return resources
+ @staticmethod
+ def _discover_script_files(
+ skill_dir_path: str,
+ extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
+ ) -> list[str]:
+ """Scan a skill directory for script files matching *extensions*.
-def _discover_skill_directories(skill_paths: Sequence[str]) -> list[str]:
- """Return absolute paths of all directories that contain a ``SKILL.md`` file.
+ Recursively walks *skill_dir_path* and collects files whose extension
+ is in *extensions*. Each candidate is validated against path-traversal
+ and symlink-escape checks; unsafe files are skipped with a warning.
- Recursively searches each root path up to :data:`MAX_SEARCH_DEPTH`.
+ Args:
+ skill_dir_path: Absolute path to the skill directory to scan.
+ extensions: Tuple of allowed script extensions (e.g. ``(".py",)``).
- Args:
- skill_paths: Root directory paths to search.
+ Returns:
+ Relative script paths (forward-slash-separated) for every
+ discovered file that passes security checks.
+ """
+ skill_dir = Path(skill_dir_path).absolute()
+ root_directory_path = str(skill_dir)
+ scripts: list[str] = []
+ normalized_extensions = {e.lower() for e in extensions}
- Returns:
- Absolute paths to directories containing ``SKILL.md``.
- """
- discovered: list[str] = []
+ for script_file in skill_dir.rglob("*"):
+ if not script_file.is_file():
+ continue
- def _search(directory: str, current_depth: int) -> None:
- dir_path = Path(directory)
- if (dir_path / SKILL_FILE_NAME).is_file():
- discovered.append(str(dir_path.absolute()))
+ if script_file.suffix.lower() not in normalized_extensions:
+ continue
- if current_depth >= MAX_SEARCH_DEPTH:
- return
+ script_full_path = str(Path(os.path.normpath(script_file)).absolute())
- try:
- entries = list(dir_path.iterdir())
- except OSError:
- return
+ if not _FileSkillsSource._is_path_within_directory(script_full_path, root_directory_path):
+ logger.warning(
+ "Skipping script '%s': resolves outside skill directory '%s'",
+ script_file,
+ skill_dir_path,
+ )
+ continue
- for entry in entries:
- if entry.is_dir():
- _search(str(entry), current_depth + 1)
+ if _FileSkillsSource._has_symlink_in_path(script_full_path, root_directory_path):
+ logger.warning(
+ "Skipping script '%s': symlink detected in path under skill directory '%s'",
+ script_file,
+ skill_dir_path,
+ )
+ continue
- for root_dir in skill_paths:
- if not root_dir or not root_dir.strip() or not Path(root_dir).is_dir():
- continue
- _search(root_dir, current_depth=0)
+ rel_path = script_file.relative_to(skill_dir)
+ scripts.append(_FileSkillsSource._normalize_resource_path(str(rel_path)))
- return discovered
+ return scripts
+ @staticmethod
+ def _get_validated_resource_path(skill_dir: str, resource_name: str) -> str:
+ """Resolve and validate a resource file path within a skill directory.
-def _read_file_skill_resource(skill: Skill, resource_name: str) -> str:
- """Read a file-based resource from disk with security guards.
+ Normalizes *resource_name*, resolves it against *skill_dir*, and
+ validates that the result stays within the skill directory and does
+ not traverse any symlinks.
- Validates that the resolved path stays within the skill directory and
- does not traverse any symlinks before reading.
+ Args:
+ skill_dir: Absolute path to the owning skill directory.
+ resource_name: Relative path of the resource within the skill directory.
- Args:
- skill: The owning skill (must have a non-``None`` :attr:`~Skill.path`).
- resource_name: Relative path of the resource within the skill directory.
+ Returns:
+ The validated absolute path to the resource file.
- Returns:
- The UTF-8 text content of the resource file.
+ Raises:
+ ValueError: If *skill_dir* is not an absolute path, the resolved path
+ escapes the skill directory, the file does not exist, or a symlink
+ is detected in the path.
+ """
+ if not os.path.isabs(skill_dir):
+ raise ValueError(f"skill_dir must be an absolute path, got: '{skill_dir}'")
- Raises:
- ValueError: If the resolved path escapes the skill directory,
- the file does not exist, or a symlink is detected in the path.
- """
- resource_name = _normalize_resource_path(resource_name)
+ resource_name = _FileSkillsSource._normalize_resource_path(resource_name)
- if not skill.path:
- raise ValueError(f"Skill '{skill.name}' has no path set; cannot read file-based resources.")
+ resource_full_path = os.path.normpath(Path(skill_dir) / resource_name)
+ root_directory_path = os.path.normpath(skill_dir)
- resource_full_path = os.path.normpath(Path(skill.path) / resource_name)
- root_directory_path = os.path.normpath(skill.path)
+ if not _FileSkillsSource._is_path_within_directory(resource_full_path, root_directory_path):
+ raise ValueError(f"Resource file '{resource_name}' references a path outside the skill directory.")
- if not _is_path_within_directory(resource_full_path, root_directory_path):
- raise ValueError(f"Resource file '{resource_name}' references a path outside the skill directory.")
+ if not Path(resource_full_path).is_file():
+ raise ValueError(f"Resource file '{resource_name}' not found in skill directory '{skill_dir}'.")
- if not Path(resource_full_path).is_file():
- raise ValueError(f"Resource file '{resource_name}' not found in skill '{skill.name}'.")
+ if _FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path):
+ raise ValueError(
+ f"Resource file '{resource_name}' "
+ "has a symlink in its path; symlinks are not allowed."
+ )
- if _has_symlink_in_path(resource_full_path, root_directory_path):
- raise ValueError(
- f"Resource file '{resource_name}' in skill '{skill.name}' "
- "has a symlink in its path; symlinks are not allowed."
- )
+ return resource_full_path
- logger.info("Reading resource '%s' from skill '%s'", resource_name, skill.name)
- return Path(resource_full_path).read_text(encoding="utf-8")
+ @staticmethod
+ def _validate_skill_metadata(
+ name: str | None,
+ description: str | None,
+ source: str,
+ ) -> str | None:
+ """Validate a skill's name and description against naming rules.
+ Enforces length limits, character-set restrictions, and non-emptiness
+ for both file-based and code-defined skills.
-def _discover_file_skills(
- skill_paths: str | Path | Sequence[str | Path] | None,
- resource_extensions: tuple[str, ...] = DEFAULT_RESOURCE_EXTENSIONS,
- script_extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
-) -> dict[str, Skill]:
- """Discover, parse, and load all file-based skills from the given paths.
+ Args:
+ name: Skill name to validate.
+ description: Skill description to validate.
+ source: Human-readable label for diagnostics (e.g. a file path
+ or ``"code skill"``).
- Each discovered ``SKILL.md`` is parsed for metadata, and resource files
- in the same directory are wrapped in lazy-read closures that perform
- security checks (path traversal, symlink escape) at read time.
+ Returns:
+ A diagnostic error string if validation fails, or ``None`` if valid.
+ """
+ if not name or not name.strip():
+ return f"Skill from '{source}' is missing a name."
- Args:
- skill_paths: Directory path(s) to scan, or ``None`` to skip.
- resource_extensions: File extensions recognized as resources.
- script_extensions: File extensions recognized as scripts.
+ if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
+ return (
+ f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
+ "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
+ "or contain consecutive hyphens."
+ )
- Returns:
- A dict mapping skill name → :class:`Skill`.
- """
- if skill_paths is None:
- return {}
+ if not description or not description.strip():
+ return f"Skill '{name}' from '{source}' is missing a description."
+
+ if len(description) > MAX_DESCRIPTION_LENGTH:
+ return (
+ f"Skill '{name}' from '{source}' has an invalid description: "
+ f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
+ )
+
+ return None
- resolved_paths: list[str] = (
- [str(skill_paths)] if isinstance(skill_paths, (str, Path)) else [str(p) for p in skill_paths]
- )
+ @staticmethod
+ def _extract_frontmatter(
+ content: str,
+ skill_file_path: str,
+ ) -> tuple[str, str] | None:
+ """Extract and validate YAML frontmatter from a SKILL.md file.
+
+ Parses the ``---``-delimited frontmatter block for ``name`` and
+ ``description`` fields.
+
+ Args:
+ content: Raw text content of the SKILL.md file.
+ skill_file_path: Path to the file (used in diagnostic messages only).
- skills: dict[str, Skill] = {}
+ Returns:
+ A ``(name, description)`` tuple on success, or ``None`` if the
+ frontmatter is missing, malformed, or fails validation.
+ """
+ match = FRONTMATTER_RE.search(content)
+ if not match:
+ logger.error("SKILL.md at '%s' does not contain valid YAML frontmatter delimited by '---'", skill_file_path)
+ return None
+
+ yaml_content = match.group(1).strip()
+ name: str | None = None
+ description: str | None = None
+
+ for kv_match in YAML_KV_RE.finditer(yaml_content):
+ key = kv_match.group(1)
+ value = kv_match.group(2) if kv_match.group(2) is not None else kv_match.group(3)
+
+ if key.lower() == "name":
+ name = value
+ elif key.lower() == "description":
+ description = value
+
+ error = _FileSkillsSource._validate_skill_metadata(name, description, skill_file_path)
+ if error:
+ logger.error(error)
+ return None
+
+ # name and description are guaranteed non-None after validation
+ return name, description # type: ignore[return-value]
+
+ @staticmethod
+ def _read_and_parse_skill_file(
+ skill_dir_path: str,
+ ) -> tuple[str, str, str] | None:
+ """Read and parse the SKILL.md file in *skill_dir_path*.
+
+ Args:
+ skill_dir_path: Absolute path to the directory containing ``SKILL.md``.
+
+ Returns:
+ A ``(name, description, content)`` tuple where *content* is the
+ full raw file text, or ``None`` if the file cannot be read or
+ its frontmatter is invalid.
+ """
+ skill_file = Path(skill_dir_path) / SKILL_FILE_NAME
- discovered = _discover_skill_directories(resolved_paths)
- logger.info("Discovered %d potential skills", len(discovered))
+ try:
+ content = skill_file.read_text(encoding="utf-8")
+ except OSError:
+ logger.error("Failed to read SKILL.md at '%s'", skill_file)
+ return None
- for skill_path in discovered:
- parsed = _read_and_parse_skill_file(skill_path)
- if parsed is None:
- continue
+ result = _FileSkillsSource._extract_frontmatter(content, str(skill_file))
+ if result is None:
+ return None
- name, description, content = parsed
+ name, description = result
- if name in skills:
- logger.warning(
- "Duplicate skill name '%s': skill from '%s' skipped in favor of existing skill",
+ dir_name = Path(skill_dir_path).name
+ if name != dir_name:
+ logger.error(
+ "SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.",
+ skill_file,
name,
- skill_path,
+ dir_name,
)
- continue
+ return None
- file_skill = Skill(
- name=name,
- description=description,
- content=content,
- path=skill_path,
- )
+ return name, description, content
- # Discover and attach file-based resources as SkillResource closures
- for rn in _discover_resource_files(skill_path, resource_extensions):
- reader = (lambda s, r: lambda: _read_file_skill_resource(s, r))(file_skill, rn)
- file_skill.resources.append(SkillResource(name=rn, function=reader))
+ @staticmethod
+ def _discover_skill_directories(skill_paths: Sequence[str]) -> list[str]:
+ """Return absolute paths of all directories that contain a ``SKILL.md`` file.
- # Discover and attach file-based scripts as SkillScript instances
- for sn in _discover_script_files(skill_path, script_extensions):
- file_skill.scripts.append(SkillScript(name=sn, path=sn))
+ Recursively searches each root path up to :data:`MAX_SEARCH_DEPTH`.
- skills[file_skill.name] = file_skill
- logger.info("Loaded skill: %s", file_skill.name)
+ Args:
+ skill_paths: Root directory paths to search.
- logger.info("Successfully loaded %d skills", len(skills))
- return skills
+ Returns:
+ Absolute paths to directories containing ``SKILL.md``.
+ """
+ discovered: list[str] = []
+ def _search(directory: str, current_depth: int) -> None:
+ dir_path = Path(directory)
+ if (dir_path / SKILL_FILE_NAME).is_file():
+ discovered.append(str(dir_path.absolute()))
-def _load_skills(
- skill_paths: str | Path | Sequence[str | Path] | None,
- skills: Sequence[Skill] | None,
- resource_extensions: tuple[str, ...],
- script_extensions: tuple[str, ...],
-) -> dict[str, Skill]:
- """Discover and merge skills from file paths and code-defined skills.
+ if current_depth >= MAX_SEARCH_DEPTH:
+ return
- File-based skills are discovered first. Code-defined skills are then
- merged in; if a code-defined skill has the same name as an existing
- file-based skill, the code-defined one is skipped with a warning.
+ try:
+ entries = list(dir_path.iterdir())
+ except OSError:
+ return
- Args:
- skill_paths: Directory path(s) to scan for ``SKILL.md`` files, or ``None``.
- skills: Code-defined :class:`Skill` instances, or ``None``.
- resource_extensions: File extensions recognized as discoverable resources.
- script_extensions: File extensions recognized as discoverable scripts.
+ for entry in entries:
+ if entry.is_dir():
+ _search(str(entry), current_depth + 1)
- Returns:
- A dict mapping skill name → :class:`Skill`.
+ for root_dir in skill_paths:
+ if not root_dir or not root_dir.strip() or not Path(root_dir).is_dir():
+ continue
+ _search(root_dir, current_depth=0)
+
+ return discovered
+
+
+class _InMemorySkillsSource(SkillsSource):
+ """Skill source that holds pre-built :class:`Skill` instances in memory.
+
+ Accepts any :class:`Skill` instances (e.g. :class:`InlineSkill`,
+ :class:`FileSkill`). Skills are assumed to be valid (validated at
+ construction time by the concrete class).
+
+ Examples:
+ .. code-block:: python
+
+ skill = InlineSkill(
+ name="my-skill",
+ description="Example skill",
+ instructions="Instructions here...",
+ )
+ source = _InMemorySkillsSource([skill])
+ skills = await source.get_skills()
"""
- result = _discover_file_skills(skill_paths, resource_extensions, script_extensions)
- if skills:
- for code_skill in skills:
- error = _validate_skill_metadata(code_skill.name, code_skill.description, "code skill")
- if error:
- logger.warning(error)
- continue
- if code_skill.name in result:
+ def __init__(self, skills: Sequence[Skill]) -> None:
+ """Initialize an _InMemorySkillsSource.
+
+ Args:
+ skills: :class:`Skill` instances to serve from this source.
+ """
+ self._skills = list(skills)
+
+ async def get_skills(self) -> list[Skill]:
+ """Return the stored skills.
+
+ Returns:
+ A list of :class:`Skill` instances.
+ """
+ return self._skills
+
+
+class _DelegatingSkillsSource(SkillsSource, ABC):
+ """Abstract decorator base that wraps an inner skill source.
+
+ Subclass this to implement cross-cutting concerns (filtering, caching,
+ deduplication, etc.) as composable decorators over any
+ :class:`SkillsSource`.
+
+ Attributes:
+ inner_source: The wrapped source that this decorator delegates to.
+ """
+
+ def __init__(self, inner_source: SkillsSource) -> None:
+ """Initialize a _DelegatingSkillsSource.
+
+ Args:
+ inner_source: The source to wrap and delegate to.
+ """
+ self._inner_source = inner_source
+
+ @property
+ def inner_source(self) -> SkillsSource:
+ """The wrapped inner skill source."""
+ return self._inner_source
+
+ async def get_skills(self) -> list[Skill]:
+ """Delegate to the inner source.
+
+ Subclasses should override this to intercept the results.
+
+ Returns:
+ Skills from the inner source.
+ """
+ return await self._inner_source.get_skills()
+
+
+class _DeduplicatingSkillsSource(_DelegatingSkillsSource):
+ """Decorator that deduplicates skills by name (case-insensitive).
+
+ When multiple skills share the same name (ignoring case), only the
+ first occurrence is kept and later duplicates are skipped with a
+ warning log.
+
+ This is useful when composing multiple sources, where the same skill
+ name might appear in more than one source.
+
+ Examples:
+ .. code-block:: python
+
+ deduped = _DeduplicatingSkillsSource(inner_source)
+ skills = await deduped.get_skills()
+ """
+
+ def __init__(self, inner_source: SkillsSource) -> None:
+ """Initialize a _DeduplicatingSkillsSource.
+
+ Args:
+ inner_source: The source whose results will be deduplicated.
+ """
+ super().__init__(inner_source)
+
+ async def get_skills(self) -> list[Skill]:
+ """Return deduplicated skills (first-one-wins by name).
+
+ Returns:
+ A list of :class:`Skill` instances with duplicate names removed.
+ """
+ skills = await self._inner_source.get_skills()
+ seen: dict[str, Skill] = {}
+ result: list[Skill] = []
+
+ for skill in skills:
+ key = skill.name.lower()
+ if key in seen:
logger.warning(
- "Duplicate skill name '%s': code skill skipped in favor of existing skill",
- code_skill.name,
+ "Duplicate skill name '%s': skill skipped in favor of existing skill '%s'",
+ skill.name,
+ seen[key].name,
)
continue
- result[code_skill.name] = code_skill
- logger.info("Registered code skill: %s", code_skill.name)
+ seen[key] = skill
+ result.append(skill)
- return result
+ return result
-def _create_resource_element(resource: SkillResource) -> str:
- """Create a self-closing ```` XML element from an :class:`SkillResource`.
+class _FilteringSkillsSource(_DelegatingSkillsSource):
+ """Decorator that filters skills from an inner source by predicate.
- Args:
- resource: The resource to create the element from.
+ Only skills for which *predicate* returns ``True`` are included in the
+ result. The predicate receives each :class:`Skill` and should return
+ a boolean.
- Returns:
- A single indented XML element string with ``name`` and optional
- ``description`` attributes.
+ Examples:
+ .. code-block:: python
+
+ filtered = _FilteringSkillsSource(
+ inner_source=my_source,
+ predicate=lambda s: s.name != "internal",
+ )
+ skills = await filtered.get_skills()
"""
- attrs = f'name="{xml_escape(resource.name, quote=True)}"'
- if resource.description:
- attrs += f' description="{xml_escape(resource.description, quote=True)}"'
- return f" "
+ def __init__(
+ self,
+ inner_source: SkillsSource,
+ predicate: Callable[[Skill], bool],
+ ) -> None:
+ """Initialize a _FilteringSkillsSource.
-def _create_script_element(script: SkillScript) -> str:
- """Create an XML ``"
- return f" "
+class _AggregatingSkillsSource(SkillsSource):
+ """Skill source that composes multiple sources into one."""
-def _create_instructions(
- prompt_template: str | None,
- skills: dict[str, Skill],
- include_script_runner_instructions: bool = False,
-) -> str | None:
- """Create the system-prompt text that advertises available skills.
+ def __init__(self, sources: Sequence[SkillsSource]) -> None:
+ self._sources = list(sources)
- Generates an XML list of ```` elements (sorted by name) and
- inserts it into *prompt_template* at the ``{skills}`` placeholder.
- When *include_script_runner_instructions* is ``True``, executor-provided
- instructions are inserted at the ``{runner_instructions}`` placeholder.
+ async def get_skills(self) -> list[Skill]:
+ result: list[Skill] = []
+ for source in self._sources:
+ skills = await source.get_skills()
+ result.extend(skills)
+ return result
- Args:
- prompt_template: Custom template string with ``{skills}`` and
- optional ``{runner_instructions}`` placeholders,
- or ``None`` to use the built-in default.
- skills: Registered skills keyed by name.
- include_script_runner_instructions: When ``True``, include
- script-runner instructions in the generated prompt.
- Defaults to ``False``.
- Returns:
- The formatted instruction string, or ``None`` when *skills* is empty.
+# endregion
- Raises:
- ValueError: If *prompt_template* is not a valid format string
- (e.g. missing ``{skills}`` placeholder).
- """
- runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
- template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
+# region SkillsProviderBuilder
- if prompt_template is not None:
- # Validate that the custom template contains a valid {skills} placeholder
- try:
- result = prompt_template.format(skills="__PROBE__", runner_instructions="__EXEC_PROBE__")
- except (KeyError, IndexError, ValueError) as exc:
- raise ValueError(
- "The provided instruction_template is not a valid format string. "
- "It must contain a '{skills}' placeholder and escape any literal" # noqa: RUF027
- " '{' or '}' "
- "by doubling them ('{{' or '}}')."
- ) from exc
- if "__PROBE__" not in result:
- raise ValueError(
- "The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
+
+@experimental(feature_id=ExperimentalFeature.SKILLS)
+class SkillsProviderBuilder:
+ """Fluent builder for composing a :class:`SkillsProvider`.
+
+ Provides a step-by-step API for registering skill sources, configuring
+ script runners, filtering, and building the final provider. Sources
+ are automatically aggregated and deduplicated.
+
+ Examples:
+ File-based skills:
+
+ .. code-block:: python
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_file_skills("./skills", script_runner=my_runner)
+ .build()
)
- if runner_instructions and "__EXEC_PROBE__" not in result:
- raise ValueError(
- "The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
- "when a script runner is configured."
+
+ Mixed skills with filtering:
+
+ .. code-block:: python
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_file_skills("./skills")
+ .add_skill(my_code_skill)
+ .with_file_script_runner(my_runner)
+ .with_filter(lambda s: s.name != "internal")
+ .build()
)
- template = prompt_template
- if not skills:
- return None
+ Custom sources:
- lines: list[str] = []
- # Sort by name for deterministic output
- for skill in sorted(skills.values(), key=lambda s: s.name):
- lines.append(" ")
- lines.append(f" {xml_escape(skill.name)}")
- lines.append(f" {xml_escape(skill.description)}")
- lines.append(" ")
-
- return template.format(
- skills="\n".join(lines),
- runner_instructions=runner_instructions or "",
- )
+ .. code-block:: python
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_source(my_custom_source)
+ .add_file_skills("./skills")
+ .with_script_approval()
+ .build()
+ )
+ """
+
+ def __init__(self) -> None:
+ """Initialize a SkillsProviderBuilder."""
+ self._source_factories: list[Callable[[], SkillsSource]] = []
+ self._skills: list[Skill] = []
+ self._script_runner: SkillScriptRunner | None = None
+ self._instruction_template: str | None = None
+ self._require_script_approval: bool = False
+ self._disable_caching: bool = False
+ self._filter_predicate: Callable[[Skill], bool] | None = None
+
+ def add_file_skills(
+ self,
+ skill_paths: str | Path | Sequence[str | Path],
+ *,
+ script_runner: SkillScriptRunner | None = None,
+ resource_extensions: tuple[str, ...] | None = None,
+ script_extensions: tuple[str, ...] | None = None,
+ ) -> SkillsProviderBuilder:
+ """Register file-based skills from one or more directories.
+
+ Args:
+ skill_paths: Directory paths to scan for ``SKILL.md`` files.
+
+ Keyword Args:
+ script_runner: Per-source script runner. Overrides the
+ builder-level runner set via :meth:`with_file_script_runner`
+ for this source only.
+ resource_extensions: File extensions recognized as discoverable
+ resources. Defaults to
+ ``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``.
+ script_extensions: File extensions recognized as discoverable
+ scripts. Defaults to ``(".py",)``.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ def _factory(
+ sp: str | Path | Sequence[str | Path] = skill_paths,
+ sr: SkillScriptRunner | None = script_runner,
+ re: tuple[str, ...] | None = resource_extensions,
+ se: tuple[str, ...] | None = script_extensions,
+ ) -> SkillsSource:
+ runner = sr or self._script_runner
+ return _FileSkillsSource(sp, script_runner=runner, resource_extensions=re, script_extensions=se)
+
+ self._source_factories.append(_factory)
+ return self
+
+ def add_skill(self, skill: Skill) -> SkillsProviderBuilder:
+ """Register a single code-defined skill.
+
+ Args:
+ skill: The :class:`Skill` to add.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._skills.append(skill)
+ return self
+
+ def add_skills(self, skills: Sequence[Skill]) -> SkillsProviderBuilder:
+ """Register multiple code-defined skills.
+
+ Args:
+ skills: The :class:`Skill` instances to add.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._skills.extend(skills)
+ return self
+
+ def add_source(self, source: SkillsSource) -> SkillsProviderBuilder:
+ """Register a custom :class:`SkillsSource`.
+
+ Args:
+ source: The source to add.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._source_factories.append(lambda s=source: s) # type: ignore[misc]
+ return self
+
+ def with_file_script_runner(self, runner: SkillScriptRunner) -> SkillsProviderBuilder:
+ """Set the builder-level script runner for file-based skills.
+
+ This runner is used by file sources that do not specify their own
+ runner. Per-source runners (passed to :meth:`add_file_skills`)
+ take precedence over this builder-level setting.
+
+ Args:
+ runner: The script runner callable.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._script_runner = runner
+ return self
+
+ def with_prompt_template(self, template: str) -> SkillsProviderBuilder:
+ """Set a custom system-prompt template for skill advertising.
+
+ The template must contain a ``{skills}`` placeholder and may also
+ include a ``{runner_instructions}`` placeholder. If any configured
+ skill exposes resources, the template must also include a
+ ``{resource_instructions}`` placeholder.
+
+ Args:
+ template: The prompt template string.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._instruction_template = template
+ return self
+
+ def with_script_approval(self, enabled: bool = True) -> SkillsProviderBuilder:
+ """Enable or disable script execution approval.
+
+ When enabled, the ``run_skill_script`` tool requires explicit user
+ approval before each invocation.
+
+ Args:
+ enabled: ``True`` to require approval, ``False`` to disable.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._require_script_approval = enabled
+ return self
+
+ def with_disable_caching(self, disabled: bool = True) -> SkillsProviderBuilder:
+ """Enable or disable caching of skills, instructions, and tools.
+
+ When disabled, the provider rebuilds tools and instructions from the
+ source on every invocation instead of caching after the first build.
+
+ Args:
+ disabled: ``True`` to disable caching, ``False`` to enable.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._disable_caching = disabled
+ return self
+
+ def with_filter(self, predicate: Callable[[Skill], bool]) -> SkillsProviderBuilder:
+ """Set a filter predicate applied to all skills.
+
+ Only skills for which *predicate* returns ``True`` are included
+ in the built provider.
+
+ Args:
+ predicate: A callable that receives a :class:`Skill` and returns
+ ``True`` to keep it.
+
+ Returns:
+ This builder instance for chaining.
+ """
+ self._filter_predicate = predicate
+ return self
+
+ def build(self) -> SkillsProvider:
+ """Build and return a configured :class:`SkillsProvider`.
+
+ Composes all registered sources, applies filtering and deduplication,
+ and creates the provider.
+
+ Returns:
+ A fully configured :class:`SkillsProvider`.
+ """
+ # Resolve source factories
+ sources: list[SkillsSource] = [factory() for factory in self._source_factories]
+
+ # Add in-memory source for code-defined skills
+ if self._skills:
+ sources.append(_InMemorySkillsSource(self._skills))
+
+ if not sources:
+ sources.append(_InMemorySkillsSource([]))
+
+ # Compose: aggregate → filter → dedup
+ source: SkillsSource = sources[0] if len(sources) == 1 else _AggregatingSkillsSource(sources)
+
+ if self._filter_predicate is not None:
+ source = _FilteringSkillsSource(source, self._filter_predicate)
+
+ source = _DeduplicatingSkillsSource(source)
+
+ return SkillsProvider(
+ source,
+ instruction_template=self._instruction_template,
+ require_script_approval=self._require_script_approval,
+ disable_caching=self._disable_caching,
+ )
# endregion
diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py
index 225b12d9a1..82e987fece 100644
--- a/python/packages/core/tests/core/test_skills.py
+++ b/python/packages/core/tests/core/test_skills.py
@@ -5,41 +5,75 @@
from __future__ import annotations
import os
+from collections.abc import Sequence
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock
import pytest
-from agent_framework import SessionContext, Skill, SkillResource, SkillScript, SkillScriptRunner, SkillsProvider
+from agent_framework import (
+ FileSkill,
+ FileSkillScript,
+ InlineSkill,
+ SessionContext,
+ Skill,
+ SkillResource,
+ SkillScript,
+ SkillScriptRunner,
+ SkillsProvider,
+ SkillsProviderBuilder,
+)
from agent_framework._skills import (
DEFAULT_RESOURCE_EXTENSIONS,
DEFAULT_SCRIPT_EXTENSIONS,
- _create_instructions,
- _create_resource_element,
+ InlineSkillResource,
+ InlineSkillScript,
_create_script_element,
- _discover_file_skills,
- _discover_resource_files,
- _discover_script_files,
- _discover_skill_directories,
- _extract_frontmatter,
- _has_symlink_in_path,
- _is_path_within_directory,
- _load_skills,
- _normalize_resource_path,
- _read_and_parse_skill_file,
- _read_file_skill_resource,
- _validate_skill_metadata,
+ _FileSkillResource,
+ _FileSkillsSource,
)
pytestmark = pytest.mark.filterwarnings(r"ignore:\[SKILLS\].*:FutureWarning")
+# Cross-platform absolute path prefix for tests
+_ABS = "C:\\skills" if os.name == "nt" else "/skills"
+
async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
"""No-op script runner for tests that need a SkillScriptRunner."""
return
+async def _init_provider(provider: SkillsProvider) -> SkillsProvider:
+ """Initialize a provider's lazy state for testing.
+
+ Calls the internal ``_get_or_create_context()`` method so that tests can
+ immediately inspect the cached context via ``_cached_context``.
+ """
+ await provider._get_or_create_context() # pyright: ignore[reportPrivateUsage]
+ return provider
+
+
+def _ctx(provider: SkillsProvider) -> tuple[dict[str, Skill], str | None, list[Any]]:
+ """Return the cached context, asserting it was initialized.
+
+ Converts the skills sequence to a dict keyed by name for convenient
+ test assertions.
+ """
+ ctx = provider._cached_context # pyright: ignore[reportPrivateUsage]
+ assert ctx is not None, "_init_provider() must be called before accessing context"
+ skills, instructions, tools = ctx
+ return {s.name: s for s in skills}, instructions, tools
+
+
+def _raw_skills(provider: SkillsProvider) -> Sequence[Skill]:
+ """Return the raw skills sequence from the cached context."""
+ ctx = provider._cached_context # pyright: ignore[reportPrivateUsage]
+ assert ctx is not None, "_init_provider() must be called before accessing context"
+ return ctx[0]
+
+
def _symlinks_supported(tmp: Path) -> bool:
"""Return True if the current platform/environment supports symlinks."""
test_target = tmp / "_symlink_test_target"
@@ -86,12 +120,12 @@ def _write_skill(
return skill_dir
-def _read_and_parse_skill_file_for_test(skill_dir: Path) -> Skill:
+def _read_and_parse_skill_file_for_test(skill_dir: Path) -> FileSkill:
"""Parse a SKILL.md file from the given directory, raising if invalid."""
- result = _read_and_parse_skill_file(str(skill_dir))
+ result = _FileSkillsSource._read_and_parse_skill_file(str(skill_dir))
assert result is not None, f"Failed to parse skill at {skill_dir}"
name, description, content = result
- return Skill(
+ return FileSkill(
name=name,
description=description,
content=content,
@@ -99,6 +133,35 @@ def _read_and_parse_skill_file_for_test(skill_dir: Path) -> Skill:
)
+async def _discover_file_skills_for_test(
+ skill_paths: str | Path | list[str],
+ *,
+ resource_extensions: tuple[str, ...] | None = None,
+ script_extensions: tuple[str, ...] | None = None,
+ script_runner: Any = None,
+) -> dict[str, FileSkill]:
+ """Test helper: discover file skills and return as a dict keyed by name.
+
+ Wraps ``_FileSkillsSource(...).get_skills()`` for easy test migration
+ from the removed ``_FileSkillsSource._discover_file_skills()`` static method.
+ """
+ kwargs: dict[str, Any] = {}
+ if resource_extensions is not None:
+ kwargs["resource_extensions"] = resource_extensions
+ if script_extensions is not None:
+ kwargs["script_extensions"] = script_extensions
+ if script_runner is not None:
+ kwargs["script_runner"] = script_runner
+
+ source = _FileSkillsSource(skill_paths, **kwargs)
+ skills = await source.get_skills()
+ result: dict[str, FileSkill] = {}
+ for s in skills:
+ assert isinstance(s, FileSkill), f"Expected FileSkill, got {type(s).__name__}"
+ result[s.name] = s
+ return result
+
+
# ---------------------------------------------------------------------------
# Tests: module-level helper functions
# ---------------------------------------------------------------------------
@@ -108,16 +171,16 @@ class TestNormalizeResourcePath:
"""Tests for _normalize_resource_path."""
def test_strips_dot_slash_prefix(self) -> None:
- assert _normalize_resource_path("./refs/doc.md") == "refs/doc.md"
+ assert _FileSkillsSource._normalize_resource_path("./refs/doc.md") == "refs/doc.md"
def test_replaces_backslashes(self) -> None:
- assert _normalize_resource_path("refs\\doc.md") == "refs/doc.md"
+ assert _FileSkillsSource._normalize_resource_path("refs\\doc.md") == "refs/doc.md"
def test_strips_dot_slash_and_replaces_backslashes(self) -> None:
- assert _normalize_resource_path(".\\refs\\doc.md") == "refs/doc.md"
+ assert _FileSkillsSource._normalize_resource_path(".\\refs\\doc.md") == "refs/doc.md"
def test_no_change_for_clean_path(self) -> None:
- assert _normalize_resource_path("refs/doc.md") == "refs/doc.md"
+ assert _FileSkillsSource._normalize_resource_path("refs/doc.md") == "refs/doc.md"
class TestDiscoverResourceFiles:
@@ -130,14 +193,14 @@ def test_discovers_md_files(self, tmp_path: Path) -> None:
refs = skill_dir / "refs"
refs.mkdir()
(refs / "FAQ.md").write_text("FAQ content", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert "refs/FAQ.md" in resources
def test_excludes_skill_md(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("content", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert len(resources) == 0
def test_discovers_multiple_extensions(self, tmp_path: Path) -> None:
@@ -146,7 +209,7 @@ def test_discovers_multiple_extensions(self, tmp_path: Path) -> None:
(skill_dir / "data.json").write_text("{}", encoding="utf-8")
(skill_dir / "config.yaml").write_text("key: val", encoding="utf-8")
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert len(resources) == 3
names = set(resources)
assert "data.json" in names
@@ -158,7 +221,7 @@ def test_ignores_unsupported_extensions(self, tmp_path: Path) -> None:
skill_dir.mkdir()
(skill_dir / "image.png").write_bytes(b"\x89PNG")
(skill_dir / "binary.exe").write_bytes(b"\x00")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert len(resources) == 0
def test_custom_extensions(self, tmp_path: Path) -> None:
@@ -166,7 +229,7 @@ def test_custom_extensions(self, tmp_path: Path) -> None:
skill_dir.mkdir()
(skill_dir / "data.json").write_text("{}", encoding="utf-8")
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir), extensions=(".json",))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir), extensions=(".json",))
assert resources == ["data.json"]
def test_discovers_nested_files(self, tmp_path: Path) -> None:
@@ -174,13 +237,13 @@ def test_discovers_nested_files(self, tmp_path: Path) -> None:
sub = skill_dir / "refs" / "deep"
sub.mkdir(parents=True)
(sub / "doc.md").write_text("deep doc", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert "refs/deep/doc.md" in resources
def test_empty_directory(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert resources == []
def test_default_extensions_match_constant(self) -> None:
@@ -198,7 +261,7 @@ class TestTryParseSkillDocument:
def test_valid_skill(self) -> None:
content = "---\nname: test-skill\ndescription: A test skill.\n---\n# Body\nInstructions here."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
name, description = result
assert name == "test-skill"
@@ -206,62 +269,62 @@ def test_valid_skill(self) -> None:
def test_quoted_values(self) -> None:
content = "---\nname: \"test-skill\"\ndescription: 'A test skill.'\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result[0] == "test-skill"
assert result[1] == "A test skill."
def test_utf8_bom(self) -> None:
content = "\ufeff---\nname: test-skill\ndescription: A test skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result[0] == "test-skill"
def test_missing_frontmatter(self) -> None:
content = "# Just a markdown file\nNo frontmatter here."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_missing_name(self) -> None:
content = "---\ndescription: A test skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_missing_description(self) -> None:
content = "---\nname: test-skill\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_invalid_name_uppercase(self) -> None:
content = "---\nname: Test-Skill\ndescription: A test skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_invalid_name_starts_with_hyphen(self) -> None:
content = "---\nname: -test-skill\ndescription: A test skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_invalid_name_ends_with_hyphen(self) -> None:
content = "---\nname: test-skill-\ndescription: A test skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_name_too_long(self) -> None:
long_name = "a" * 65
content = f"---\nname: {long_name}\ndescription: A test skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_description_too_long(self) -> None:
long_desc = "a" * 1025
content = f"---\nname: test-skill\ndescription: {long_desc}\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_extra_metadata_ignored(self) -> None:
content = "---\nname: test-skill\ndescription: A test skill.\nauthor: someone\nversion: 1.0\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result[0] == "test-skill"
@@ -272,65 +335,65 @@ def test_extra_metadata_ignored(self) -> None:
class TestDiscoverAndLoadSkills:
- """Tests for _discover_file_skills."""
+ """Tests for file skill discovery via _FileSkillsSource.get_skills()."""
- def test_discovers_valid_skill(self, tmp_path: Path) -> None:
+ async def test_discovers_valid_skill(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert "my-skill" in skills
assert skills["my-skill"].name == "my-skill"
- def test_discovers_nested_skills(self, tmp_path: Path) -> None:
+ async def test_discovers_nested_skills(self, tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_write_skill(skills_dir, "skill-a")
_write_skill(skills_dir, "skill-b")
- skills = _discover_file_skills([str(skills_dir)])
+ skills = await _discover_file_skills_for_test([str(skills_dir)])
assert len(skills) == 2
assert "skill-a" in skills
assert "skill-b" in skills
- def test_skips_invalid_skill(self, tmp_path: Path) -> None:
+ async def test_skips_invalid_skill(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "bad-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("No frontmatter here.", encoding="utf-8")
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert len(skills) == 0
- def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None:
+ async def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "wrong-dir-name"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert len(skills) == 0
- def test_deduplicates_skill_names(self, tmp_path: Path) -> None:
+ async def test_deduplicates_skill_names(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
_write_skill(dir1, "my-skill", body="First")
_write_skill(dir2, "my-skill", body="Second")
- skills = _discover_file_skills([str(dir1), str(dir2)])
+ skills = await _discover_file_skills_for_test([str(dir1), str(dir2)])
assert len(skills) == 1
assert "First" in skills["my-skill"].content
- def test_empty_directory(self, tmp_path: Path) -> None:
- skills = _discover_file_skills([str(tmp_path)])
+ async def test_empty_directory(self, tmp_path: Path) -> None:
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert len(skills) == 0
- def test_nonexistent_directory(self) -> None:
- skills = _discover_file_skills(["/nonexistent/path"])
+ async def test_nonexistent_directory(self) -> None:
+ skills = await _discover_file_skills_for_test(["/nonexistent/path"])
assert len(skills) == 0
- def test_multiple_paths(self, tmp_path: Path) -> None:
+ async def test_multiple_paths(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
_write_skill(dir1, "skill-a")
_write_skill(dir2, "skill-b")
- skills = _discover_file_skills([str(dir1), str(dir2)])
+ skills = await _discover_file_skills_for_test([str(dir1), str(dir2)])
assert len(skills) == 2
- def test_depth_limit(self, tmp_path: Path) -> None:
+ async def test_depth_limit(self, tmp_path: Path) -> None:
# Depth 0: tmp_path itself
# Depth 1: tmp_path/level1
# Depth 2: tmp_path/level1/level2 (should be found)
@@ -338,21 +401,21 @@ def test_depth_limit(self, tmp_path: Path) -> None:
deep = tmp_path / "level1" / "level2" / "level3"
deep.mkdir(parents=True)
(deep / "SKILL.md").write_text("---\nname: deep-skill\ndescription: Too deep.\n---\nBody.", encoding="utf-8")
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert "deep-skill" not in skills
- def test_skill_with_resources(self, tmp_path: Path) -> None:
+ async def test_skill_with_resources(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="Instructions here.",
resources={"refs/FAQ.md": "FAQ content"},
)
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert "my-skill" in skills
assert [r.name for r in skills["my-skill"].resources] == ["refs/FAQ.md"]
- def test_skill_discovers_all_resource_files(self, tmp_path: Path) -> None:
+ async def test_skill_discovers_all_resource_files(self, tmp_path: Path) -> None:
"""Resources are discovered by filesystem scan, not by markdown links."""
_write_skill(
tmp_path,
@@ -360,7 +423,7 @@ def test_skill_discovers_all_resource_files(self, tmp_path: Path) -> None:
body="No links here.",
resources={"data.json": '{"key": "val"}', "refs/doc.md": "doc content"},
)
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert "my-skill" in skills
resource_names = sorted(r.name for r in skills["my-skill"].resources)
assert "data.json" in resource_names
@@ -373,72 +436,54 @@ def test_skill_discovers_all_resource_files(self, tmp_path: Path) -> None:
class TestReadSkillResource:
- """Tests for _read_file_skill_resource."""
+ """Tests for _FileSkillResource reading."""
- def test_reads_valid_resource(self, tmp_path: Path) -> None:
+ async def test_reads_valid_resource(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content here"},
)
- file_skill = _read_and_parse_skill_file_for_test(tmp_path / "my-skill")
- content = _read_file_skill_resource(file_skill, "refs/FAQ.md")
+ skill_dir = tmp_path / "my-skill"
+ full_path = str(skill_dir / "refs" / "FAQ.md")
+ resource = _FileSkillResource(name="refs/FAQ.md", full_path=full_path)
+ content = await resource.read()
assert content == "FAQ content here"
- def test_normalizes_dot_slash(self, tmp_path: Path) -> None:
- _write_skill(
- tmp_path,
- "my-skill",
- body="See [doc](refs/FAQ.md).",
- resources={"refs/FAQ.md": "FAQ content"},
- )
- file_skill = _read_and_parse_skill_file_for_test(tmp_path / "my-skill")
- content = _read_file_skill_resource(file_skill, "./refs/FAQ.md")
- assert content == "FAQ content"
-
- def test_unregistered_resource_raises(self, tmp_path: Path) -> None:
- _write_skill(tmp_path, "my-skill")
- file_skill = _read_and_parse_skill_file_for_test(tmp_path / "my-skill")
- with pytest.raises(ValueError, match="not found in skill"):
- _read_file_skill_resource(file_skill, "nonexistent.md")
+ async def test_nonexistent_file_raises(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ full_path = str(skill_dir / "nonexistent.md")
+ resource = _FileSkillResource(name="nonexistent.md", full_path=full_path)
+ with pytest.raises(ValueError, match="not found"):
+ await resource.read()
- def test_reads_resource_with_exact_casing(self, tmp_path: Path) -> None:
- """Direct file read uses the given resource name for path resolution."""
+ async def test_reads_resource_with_exact_casing(self, tmp_path: Path) -> None:
+ """Direct file read uses the resolved full path."""
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
- file_skill = _read_and_parse_skill_file_for_test(tmp_path / "my-skill")
- content = _read_file_skill_resource(file_skill, "refs/FAQ.md")
+ skill_dir = tmp_path / "my-skill"
+ full_path = str(skill_dir / "refs" / "FAQ.md")
+ resource = _FileSkillResource(name="refs/FAQ.md", full_path=full_path)
+ content = await resource.read()
assert content == "FAQ content"
- def test_path_traversal_raises(self, tmp_path: Path) -> None:
- skill = Skill(
- name="test",
- description="Test skill",
- content="Body",
- path=str(tmp_path / "skill"),
- )
+ def test_constructor_rejects_empty_full_path(self) -> None:
+ with pytest.raises(ValueError, match="full_path cannot be empty"):
+ _FileSkillResource(name="test.md", full_path="")
+
+ def test_path_traversal_blocked_at_discovery(self, tmp_path: Path) -> None:
+ """Path traversal is blocked by _discover_resource_files, not at read time."""
+ skill_dir = tmp_path / "skill"
+ skill_dir.mkdir()
(tmp_path / "secret.md").write_text("secret", encoding="utf-8")
- with pytest.raises(ValueError, match="outside the skill directory"):
- _read_file_skill_resource(skill, "../secret.md")
-
- def test_similar_prefix_directory_does_not_match(self, tmp_path: Path) -> None:
- """A skill directory named 'skill-a-evil' must not access resources from 'skill-a'."""
- skill = Skill(
- name="test",
- description="Test skill",
- content="Body",
- path=str(tmp_path / "skill-a"),
- )
- evil_dir = tmp_path / "skill-a-evil"
- evil_dir.mkdir()
- (evil_dir / "secret.md").write_text("evil", encoding="utf-8")
- with pytest.raises(ValueError, match="outside the skill directory"):
- _read_file_skill_resource(skill, "../skill-a-evil/secret.md")
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
+ assert not any("secret" in r for r in resources)
# ---------------------------------------------------------------------------
@@ -450,61 +495,61 @@ class TestBuildSkillsInstructionPrompt:
"""Tests for _create_instructions."""
def test_returns_none_for_empty_skills(self) -> None:
- assert _create_instructions(None, {}) is None
+ assert SkillsProvider._create_instructions(None, []) is None
def test_default_prompt_contains_skills(self) -> None:
- skills = {
- "my-skill": Skill(name="my-skill", description="Does stuff.", content="Body"),
- }
- prompt = _create_instructions(None, skills)
+ skills = [
+ InlineSkill(name="my-skill", description="Does stuff.", instructions="Body"),
+ ]
+ prompt = SkillsProvider._create_instructions(None, skills)
assert prompt is not None
assert "my-skill" in prompt
assert "Does stuff." in prompt
assert "load_skill" in prompt
def test_skills_sorted_alphabetically(self) -> None:
- skills = {
- "zebra": Skill(name="zebra", description="Z skill.", content="Body"),
- "alpha": Skill(name="alpha", description="A skill.", content="Body"),
- }
- prompt = _create_instructions(None, skills)
+ skills = [
+ InlineSkill(name="zebra", description="Z skill.", instructions="Body"),
+ InlineSkill(name="alpha", description="A skill.", instructions="Body"),
+ ]
+ prompt = SkillsProvider._create_instructions(None, skills)
assert prompt is not None
alpha_pos = prompt.index("alpha")
zebra_pos = prompt.index("zebra")
assert alpha_pos < zebra_pos
def test_xml_escapes_metadata(self) -> None:
- skills = {
- "my-skill": Skill(name="my-skill", description='Uses & "quotes"', content="Body"),
- }
- prompt = _create_instructions(None, skills)
+ skills = [
+ InlineSkill(name="my-skill", description='Uses & "quotes"', instructions="Body"),
+ ]
+ prompt = SkillsProvider._create_instructions(None, skills)
assert prompt is not None
assert "<tags>" in prompt
assert "&" in prompt
def test_custom_prompt_template(self) -> None:
- skills = {
- "my-skill": Skill(name="my-skill", description="Does stuff.", content="Body"),
- }
+ skills = [
+ InlineSkill(name="my-skill", description="Does stuff.", instructions="Body"),
+ ]
custom = "Custom header:\n{skills}\nCustom footer."
- prompt = _create_instructions(custom, skills)
+ prompt = SkillsProvider._create_instructions(custom, skills)
assert prompt is not None
assert prompt.startswith("Custom header:")
assert prompt.endswith("Custom footer.")
def test_invalid_prompt_template_raises(self) -> None:
- skills = {
- "my-skill": Skill(name="my-skill", description="Does stuff.", content="Body"),
- }
+ skills = [
+ InlineSkill(name="my-skill", description="Does stuff.", instructions="Body"),
+ ]
with pytest.raises(ValueError, match="valid format string"):
- _create_instructions("{invalid}", skills)
+ SkillsProvider._create_instructions("{invalid}", skills)
def test_positional_placeholder_raises(self) -> None:
- skills = {
- "my-skill": Skill(name="my-skill", description="Does stuff.", content="Body"),
- }
+ skills = [
+ InlineSkill(name="my-skill", description="Does stuff.", instructions="Body"),
+ ]
with pytest.raises(ValueError, match="valid format string"):
- _create_instructions("Header {0} footer", skills)
+ SkillsProvider._create_instructions("Header {0} footer", skills)
# ---------------------------------------------------------------------------
@@ -516,29 +561,32 @@ class TestSkillsProvider:
"""Tests for file-based usage of SkillsProvider."""
def test_default_source_id(self, tmp_path: Path) -> None:
- provider = SkillsProvider(str(tmp_path))
+ provider = SkillsProvider.from_paths(str(tmp_path))
assert provider.source_id == "agent_skills"
- def test_custom_source_id(self, tmp_path: Path) -> None:
- provider = SkillsProvider(str(tmp_path), source_id="custom")
+ async def test_custom_source_id(self, tmp_path: Path) -> None:
+ provider = SkillsProvider.from_paths(str(tmp_path), source_id="custom")
assert provider.source_id == "custom"
+ await _init_provider(provider)
- def test_accepts_single_path_string(self, tmp_path: Path) -> None:
+ async def test_accepts_single_path_string(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- provider = SkillsProvider(str(tmp_path))
- assert len(provider._skills) == 1
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 1
- def test_accepts_sequence_of_paths(self, tmp_path: Path) -> None:
+ async def test_accepts_sequence_of_paths(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
_write_skill(dir1, "skill-a")
_write_skill(dir2, "skill-b")
- provider = SkillsProvider([str(dir1), str(dir2)])
- assert len(provider._skills) == 2
+ provider = SkillsProvider.from_paths([str(dir1), str(dir2)])
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 2
async def test_before_run_with_skills(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- provider = SkillsProvider(str(tmp_path))
+ provider = SkillsProvider.from_paths(str(tmp_path))
context = SessionContext(input_messages=[])
await provider.before_run(
@@ -550,12 +598,12 @@ async def test_before_run_with_skills(self, tmp_path: Path) -> None:
assert len(context.instructions) == 1
assert "my-skill" in context.instructions[0]
- assert len(context.tools) == 2
+ assert len(context.tools) == 1
tool_names = {t.name for t in context.tools}
- assert tool_names == {"load_skill", "read_skill_resource"}
+ assert tool_names == {"load_skill"}
async def test_before_run_without_skills(self, tmp_path: Path) -> None:
- provider = SkillsProvider(str(tmp_path))
+ provider = SkillsProvider.from_paths(str(tmp_path))
context = SessionContext(input_messages=[])
await provider.before_run(
@@ -568,31 +616,35 @@ async def test_before_run_without_skills(self, tmp_path: Path) -> None:
assert len(context.instructions) == 0
assert len(context.tools) == 0
- def test_load_skill_returns_body(self, tmp_path: Path) -> None:
+ async def test_load_skill_returns_body(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill", body="Skill body content.")
- provider = SkillsProvider(str(tmp_path))
- result = provider._load_skill("my-skill")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "Skill body content." in result
- def test_load_skill_preserves_file_skill_content(self, tmp_path: Path) -> None:
+ async def test_load_skill_preserves_file_skill_content(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
- provider = SkillsProvider(str(tmp_path))
- result = provider._load_skill("my-skill")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "See [doc](refs/FAQ.md)." in result
- def test_load_skill_unknown_returns_error(self, tmp_path: Path) -> None:
- provider = SkillsProvider(str(tmp_path))
- result = provider._load_skill("nonexistent")
+ async def test_load_skill_unknown_returns_error(self, tmp_path: Path) -> None:
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "nonexistent")
assert result.startswith("Error:")
- def test_load_skill_empty_name_returns_error(self, tmp_path: Path) -> None:
- provider = SkillsProvider(str(tmp_path))
- result = provider._load_skill("")
+ async def test_load_skill_empty_name_returns_error(self, tmp_path: Path) -> None:
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "")
assert result.startswith("Error:")
async def test_read_skill_resource_returns_content(self, tmp_path: Path) -> None:
@@ -602,32 +654,36 @@ async def test_read_skill_resource_returns_content(self, tmp_path: Path) -> None
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
- provider = SkillsProvider(str(tmp_path))
- result = await provider._read_skill_resource("my-skill", "refs/FAQ.md")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "refs/FAQ.md")
assert result == "FAQ content"
async def test_read_skill_resource_unknown_skill_returns_error(self, tmp_path: Path) -> None:
- provider = SkillsProvider(str(tmp_path))
- result = await provider._read_skill_resource("nonexistent", "file.md")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "nonexistent", "file.md")
assert result.startswith("Error:")
async def test_read_skill_resource_empty_name_returns_error(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- provider = SkillsProvider(str(tmp_path))
- result = await provider._read_skill_resource("my-skill", "")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "")
assert result.startswith("Error:")
async def test_read_skill_resource_unknown_resource_returns_error(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- provider = SkillsProvider(str(tmp_path))
- result = await provider._read_skill_resource("my-skill", "nonexistent.md")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "nonexistent.md")
assert result.startswith("Error:")
async def test_skills_sorted_in_prompt(self, tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_write_skill(skills_dir, "zebra", description="Z skill.")
_write_skill(skills_dir, "alpha", description="A skill.")
- provider = SkillsProvider(str(skills_dir))
+ provider = SkillsProvider.from_paths(str(skills_dir))
context = SessionContext(input_messages=[])
await provider.before_run(
@@ -642,7 +698,7 @@ async def test_skills_sorted_in_prompt(self, tmp_path: Path) -> None:
async def test_xml_escaping_in_prompt(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill", description="Uses & stuff")
- provider = SkillsProvider(str(tmp_path))
+ provider = SkillsProvider.from_paths(str(tmp_path))
context = SessionContext(input_messages=[])
await provider.before_run(
@@ -686,7 +742,7 @@ def test_detects_symlinked_file(self, tmp_path: Path) -> None:
full_path = str(symlink_path)
directory_path = str(skill_dir) + os.sep
- assert _has_symlink_in_path(full_path, directory_path) is True
+ assert _FileSkillsSource._has_symlink_in_path(full_path, directory_path) is True
def test_detects_symlinked_directory(self, tmp_path: Path) -> None:
"""A symlink to a directory outside should be detected for paths through it."""
@@ -702,7 +758,7 @@ def test_detects_symlinked_directory(self, tmp_path: Path) -> None:
full_path = str(skill_dir / "linked-dir" / "data.txt")
directory_path = str(skill_dir) + os.sep
- assert _has_symlink_in_path(full_path, directory_path) is True
+ assert _FileSkillsSource._has_symlink_in_path(full_path, directory_path) is True
def test_returns_false_for_regular_files(self, tmp_path: Path) -> None:
"""Regular (non-symlinked) files should not be flagged."""
@@ -714,10 +770,10 @@ def test_returns_false_for_regular_files(self, tmp_path: Path) -> None:
full_path = str(regular_file)
directory_path = str(skill_dir) + os.sep
- assert _has_symlink_in_path(full_path, directory_path) is False
+ assert _FileSkillsSource._has_symlink_in_path(full_path, directory_path) is False
- def test_discover_skips_symlinked_resource(self, tmp_path: Path) -> None:
- """_discover_file_skills should skip a symlinked resource but keep the skill."""
+ async def test_discover_skips_symlinked_resource(self, tmp_path: Path) -> None:
+ """get_skills() should skip a symlinked resource but keep the skill."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
@@ -735,14 +791,14 @@ def test_discover_skips_symlinked_resource(self, tmp_path: Path) -> None:
# Also add a safe resource
(refs_dir / "safe.md").write_text("safe content", encoding="utf-8")
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert "my-skill" in skills
resource_names = [r.name for r in skills["my-skill"].resources]
assert "refs/leak.md" not in resource_names
assert "refs/safe.md" in resource_names
- def test_read_skill_resource_rejects_symlinked_resource(self, tmp_path: Path) -> None:
- """_read_skill_resource should raise ValueError for a symlinked resource."""
+ def test_discover_resource_files_rejects_symlinked_resource(self, tmp_path: Path) -> None:
+ """_discover_resource_files should exclude a symlinked resource file."""
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
@@ -753,14 +809,8 @@ def test_read_skill_resource_rejects_symlinked_resource(self, tmp_path: Path) ->
refs_dir.mkdir()
(refs_dir / "leak.md").symlink_to(outside_file)
- skill = Skill(
- name="test",
- description="Test skill",
- content="See [doc](refs/leak.md).",
- path=str(skill_dir),
- )
- with pytest.raises(ValueError, match="symlink"):
- _read_file_skill_resource(skill, "refs/leak.md")
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
+ assert "refs/leak.md" not in resources
def test_discover_skips_symlinked_script(self, tmp_path: Path) -> None:
"""_discover_script_files should skip scripts with symlinks in their path."""
@@ -778,7 +828,7 @@ def test_discover_skips_symlinked_script(self, tmp_path: Path) -> None:
(scripts_dir / "safe.py").write_text("print('safe')", encoding="utf-8")
(scripts_dir / "leak.py").symlink_to(outside_script)
- discovered = _discover_script_files(str(skill_dir))
+ discovered = _FileSkillsSource._discover_script_files(str(skill_dir))
discovered_names = [p for p in discovered]
assert "scripts/safe.py" in discovered_names
assert "scripts/leak.py" not in discovered_names
@@ -808,15 +858,15 @@ def test_docstrings_include_experimental_warning(self) -> None:
assert ".. warning:: Experimental" not in SkillScript.parameters_schema.__doc__
def test_feature_metadata_is_set(self) -> None:
- assert SkillResource.__feature_stage__ == "experimental"
- assert SkillScript.__feature_stage__ == "experimental"
- assert Skill.__feature_stage__ == "experimental"
- assert SkillsProvider.__feature_stage__ == "experimental"
- feature_ids = [
- SkillResource.__feature_id__,
- SkillScript.__feature_id__,
- Skill.__feature_id__,
- SkillsProvider.__feature_id__,
+ assert getattr(SkillResource, "__feature_stage__", None) == "experimental"
+ assert getattr(SkillScript, "__feature_stage__", None) == "experimental"
+ assert getattr(Skill, "__feature_stage__", None) == "experimental"
+ assert getattr(SkillsProvider, "__feature_stage__", None) == "experimental"
+ feature_ids: list[str | None] = [
+ getattr(SkillResource, "__feature_id__", None),
+ getattr(SkillScript, "__feature_id__", None),
+ getattr(Skill, "__feature_id__", None),
+ getattr(SkillsProvider, "__feature_id__", None),
]
assert all(isinstance(feature_id, str) and feature_id for feature_id in feature_ids)
assert len(set(feature_ids)) == 1
@@ -831,7 +881,7 @@ class TestSkillResource:
"""Tests for SkillResource dataclass."""
def test_static_content(self) -> None:
- resource = SkillResource(name="ref", content="static content")
+ resource = InlineSkillResource(name="ref", content="static content")
assert resource.name == "ref"
assert resource.content == "static content"
assert resource.function is None
@@ -840,60 +890,74 @@ def test_callable_function(self) -> None:
def my_func() -> str:
return "dynamic"
- resource = SkillResource(name="func", function=my_func)
+ resource = InlineSkillResource(name="func", function=my_func)
assert resource.name == "func"
assert resource.content is None
assert resource.function is my_func
-
def test_with_description(self) -> None:
- resource = SkillResource(name="ref", description="A reference doc.", content="data")
+ resource = InlineSkillResource(name="ref", description="A reference doc.", content="data")
assert resource.description == "A reference doc."
def test_requires_content_or_function(self) -> None:
with pytest.raises(ValueError, match="must have either content or function"):
- SkillResource(name="empty")
+ InlineSkillResource(name="empty")
def test_content_and_function_mutually_exclusive(self) -> None:
with pytest.raises(ValueError, match="must have either content or function, not both"):
- SkillResource(name="both", content="static", function=lambda: "dynamic")
+ InlineSkillResource(name="both", content="static", function=lambda: "dynamic")
def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
def func_with_kwargs(**kwargs: Any) -> str:
return "dynamic"
- resource = SkillResource(name="res", function=func_with_kwargs)
+ resource = InlineSkillResource(name="res", function=func_with_kwargs)
assert resource._accepts_kwargs is True
def test_accepts_kwargs_false_for_regular_function(self) -> None:
def func_no_kwargs() -> str:
return "dynamic"
- resource = SkillResource(name="res", function=func_no_kwargs)
+ resource = InlineSkillResource(name="res", function=func_no_kwargs)
assert resource._accepts_kwargs is False
# ---------------------------------------------------------------------------
-# Tests: Skill
+# Tests: InlineSkill
# ---------------------------------------------------------------------------
-class TestSkill:
- """Tests for Skill dataclass and .resource decorator."""
+class TestInlineSkill:
+ """Tests for InlineSkill and .resource decorator."""
+
+ def test_skill_is_abstract(self) -> None:
+ """Skill base class cannot be instantiated directly."""
+ with pytest.raises(TypeError):
+ Skill() # type: ignore[abstract]
+
+ def test_inline_skill_is_skill(self) -> None:
+ """InlineSkill is a subclass of Skill."""
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
+ assert isinstance(skill, Skill)
+
+ def test_file_skill_is_skill(self) -> None:
+ """FileSkill is a subclass of Skill."""
+ skill = FileSkill(name="my-skill", description="A skill.", content="Body", path="/tmp/skill")
+ assert isinstance(skill, Skill)
def test_basic_construction(self) -> None:
- skill = Skill(name="my-skill", description="A test skill.", content="Instructions.")
+ skill = InlineSkill(name="my-skill", description="A test skill.", instructions="Instructions.")
assert skill.name == "my-skill"
assert skill.description == "A test skill."
- assert skill.content == "Instructions."
+ assert skill.instructions == "Instructions."
assert skill.resources == []
def test_construction_with_static_resources(self) -> None:
- skill = Skill(
+ skill = InlineSkill(
name="my-skill",
description="A test skill.",
- content="Instructions.",
+ instructions="Instructions.",
resources=[
- SkillResource(name="ref", content="Reference content"),
+ InlineSkillResource(name="ref", content="Reference content"),
],
)
assert len(skill.resources) == 1
@@ -901,39 +965,34 @@ def test_construction_with_static_resources(self) -> None:
def test_empty_name_raises(self) -> None:
with pytest.raises(ValueError, match="cannot be empty"):
- Skill(name="", description="A skill.", content="Body")
+ InlineSkill(name="", description="A skill.", instructions="Body")
- def test_invalid_name_skipped(self) -> None:
- invalid_skill = Skill(name="Invalid-Name", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[invalid_skill])
- assert len(provider._skills) == 0
+ def test_invalid_name_raises(self) -> None:
+ with pytest.raises(ValueError, match="Invalid skill name"):
+ InlineSkill(name="Invalid-Name", description="A skill.", instructions="Body")
- def test_name_starts_with_hyphen_skipped(self) -> None:
- invalid_skill = Skill(name="-bad-name", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[invalid_skill])
- assert len(provider._skills) == 0
+ def test_name_starts_with_hyphen_raises(self) -> None:
+ with pytest.raises(ValueError, match="Invalid skill name"):
+ InlineSkill(name="-bad-name", description="A skill.", instructions="Body")
- def test_name_with_consecutive_hyphens_skipped(self) -> None:
- invalid_skill = Skill(name="consecutive--hyphens", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[invalid_skill])
- assert len(provider._skills) == 0
+ def test_name_with_consecutive_hyphens_raises(self) -> None:
+ with pytest.raises(ValueError, match="Invalid skill name"):
+ InlineSkill(name="consecutive--hyphens", description="A skill.", instructions="Body")
- def test_name_too_long_skipped(self) -> None:
- invalid_skill = Skill(name="a" * 65, description="A skill.", content="Body")
- provider = SkillsProvider(skills=[invalid_skill])
- assert len(provider._skills) == 0
+ def test_name_too_long_raises(self) -> None:
+ with pytest.raises(ValueError, match="Invalid skill name"):
+ InlineSkill(name="a" * 65, description="A skill.", instructions="Body")
def test_empty_description_raises(self) -> None:
with pytest.raises(ValueError, match="cannot be empty"):
- Skill(name="my-skill", description="", content="Body")
+ InlineSkill(name="my-skill", description="", instructions="Body")
- def test_description_too_long_skipped(self) -> None:
- invalid_skill = Skill(name="my-skill", description="a" * 1025, content="Body")
- provider = SkillsProvider(skills=[invalid_skill])
- assert len(provider._skills) == 0
+ def test_description_too_long_raises(self) -> None:
+ with pytest.raises(ValueError, match="invalid description"):
+ InlineSkill(name="my-skill", description="a" * 1025, instructions="Body")
def test_resource_decorator_bare(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
def get_schema() -> Any:
@@ -943,10 +1002,11 @@ def get_schema() -> Any:
assert len(skill.resources) == 1
assert skill.resources[0].name == "get_schema"
assert skill.resources[0].description == "Get the database schema."
+ assert isinstance(skill.resources[0], InlineSkillResource)
assert skill.resources[0].function is get_schema
def test_resource_decorator_with_args(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource(name="custom-name", description="Custom description")
def my_resource() -> Any:
@@ -958,7 +1018,7 @@ def my_resource() -> Any:
def test_resource_decorator_returns_function(self) -> None:
"""Decorator should return the original function unchanged."""
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
def get_data() -> Any:
@@ -968,7 +1028,7 @@ def get_data() -> Any:
assert get_data() == "data"
def test_multiple_resources(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
def resource_a() -> Any:
@@ -984,13 +1044,14 @@ def resource_b() -> Any:
assert "resource_b" in names
def test_resource_decorator_async(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
async def get_async_data() -> Any:
return "async data"
assert len(skill.resources) == 1
+ assert isinstance(skill.resources[0], InlineSkillResource)
assert skill.resources[0].function is get_async_data
@@ -1002,32 +1063,35 @@ async def get_async_data() -> Any:
class TestSkillsProviderCodeSkill:
"""Tests for SkillsProvider with code-defined skills."""
- def test_code_skill_only(self) -> None:
- skill = Skill(name="prog-skill", description="A code-defined skill.", content="Do the thing.")
- provider = SkillsProvider(skills=[skill])
- assert "prog-skill" in provider._skills
-
- def test_load_skill_returns_content(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Code-defined instructions.")
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("prog-skill")
+ async def test_code_skill_only(self) -> None:
+ skill = InlineSkill(name="prog-skill", description="A code-defined skill.", instructions="Do the thing.")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert "prog-skill" in _ctx(provider)[0]
+
+ async def test_load_skill_returns_content(self) -> None:
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Code-defined instructions.")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "prog-skill")
assert "prog-skill" in result
assert "A skill." in result
assert "\nCode-defined instructions.\n" in result
assert "" not in result
- def test_load_skill_appends_resource_listing(self) -> None:
- skill = Skill(
+ async def test_load_skill_appends_resource_listing(self) -> None:
+ skill = InlineSkill(
name="prog-skill",
description="A skill.",
- content="Do things.",
+ instructions="Do things.",
resources=[
- SkillResource(name="ref-a", content="a", description="First resource"),
- SkillResource(name="ref-b", content="b"),
+ InlineSkillResource(name="ref-a", content="a", description="First resource"),
+ InlineSkillResource(name="ref-b", content="b"),
],
)
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("prog-skill")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "prog-skill")
assert "prog-skill" in result
assert "A skill." in result
assert "Do things." in result
@@ -1035,148 +1099,164 @@ def test_load_skill_appends_resource_listing(self) -> None:
assert '' in result
assert '' in result
- def test_load_skill_no_resources_no_listing(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Body only.")
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("prog-skill")
+ async def test_load_skill_no_resources_no_listing(self) -> None:
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body only.")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "prog-skill")
assert "Body only." in result
assert "" not in result
async def test_read_static_resource(self) -> None:
- skill = Skill(
+ skill = InlineSkill(
name="prog-skill",
description="A skill.",
- content="Body",
- resources=[SkillResource(name="ref", content="static content")],
+ instructions="Body",
+ resources=[InlineSkillResource(name="ref", content="static content")],
)
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "ref")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "ref")
assert result == "static content"
async def test_read_callable_resource_sync(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
def get_schema() -> Any:
return "CREATE TABLE users"
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_schema")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_schema")
assert result == "CREATE TABLE users"
async def test_read_callable_resource_async(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
async def get_data() -> Any:
return "async data"
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_data")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_data")
assert result == "async data"
async def test_read_resource_case_insensitive(self) -> None:
- skill = Skill(
+ skill = InlineSkill(
name="prog-skill",
description="A skill.",
- content="Body",
- resources=[SkillResource(name="MyRef", content="content")],
+ instructions="Body",
+ resources=[InlineSkillResource(name="MyRef", content="content")],
)
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "myref")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "myref")
assert result == "content"
async def test_read_unknown_resource_returns_error(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "nonexistent")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "nonexistent")
assert result.startswith("Error:")
async def test_read_callable_resource_sync_with_kwargs(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
def get_user_config(**kwargs: Any) -> Any:
user_id = kwargs.get("user_id", "unknown")
return f"config for {user_id}"
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_user_config", user_id="user_123")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(
+ _raw_skills(provider), "prog-skill", "get_user_config", user_id="user_123"
+ )
assert result == "config for user_123"
async def test_read_callable_resource_async_with_kwargs(self) -> None:
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
async def get_user_data(**kwargs: Any) -> Any:
token = kwargs.get("auth_token", "none")
return f"data with token={token}"
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_user_data", auth_token="abc")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc")
assert result == "data with token=abc"
async def test_read_callable_resource_without_kwargs_ignores_extra_args(self) -> None:
"""Resource functions without **kwargs should still work when kwargs are passed."""
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
def static_resource() -> Any:
return "static content"
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "static_resource", user_id="ignored")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(
+ _raw_skills(provider), "prog-skill", "static_resource", user_id="ignored"
+ )
assert result == "static content"
async def test_read_callable_resource_returns_dict(self) -> None:
"""Resource functions may return non-string types, passed through as-is."""
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
def get_config() -> Any:
return {"max_retries": 3, "timeout": 30}
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_config")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_config")
assert result == {"max_retries": 3, "timeout": 30}
async def test_read_callable_resource_returns_list(self) -> None:
"""Resource functions may return lists, passed through as-is."""
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
def get_items() -> Any:
return [1, 2, 3]
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_items")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_items")
assert result == [1, 2, 3]
async def test_read_callable_resource_returns_none(self) -> None:
"""Resource functions may return None."""
- skill = Skill(name="prog-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Body")
@skill.resource
def get_nothing() -> Any:
return None
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("prog-skill", "get_nothing")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_nothing")
assert result is None
async def test_before_run_injects_code_skills(self) -> None:
- skill = Skill(name="prog-skill", description="A code-defined skill.", content="Body")
- provider = SkillsProvider(skills=[skill])
+ skill = InlineSkill(name="prog-skill", description="A code-defined skill.", instructions="Body")
+ provider = SkillsProvider([skill])
context = SessionContext(input_messages=[])
await provider.before_run(agent=AsyncMock(), session=AsyncMock(), context=context, state={})
assert len(context.instructions) == 1
assert "prog-skill" in context.instructions[0]
- assert len(context.tools) == 2
+ assert len(context.tools) == 1
async def test_before_run_empty_provider(self) -> None:
- provider = SkillsProvider()
+ provider = SkillsProvider([])
context = SessionContext(input_messages=[])
await provider.before_run(agent=AsyncMock(), session=AsyncMock(), context=context, state={})
@@ -1184,24 +1264,26 @@ async def test_before_run_empty_provider(self) -> None:
assert len(context.instructions) == 0
assert len(context.tools) == 0
- def test_combined_file_and_code_skill(self, tmp_path: Path) -> None:
+ async def test_combined_file_and_code_skill(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "file-skill")
- prog_skill = Skill(name="prog-skill", description="Code-defined.", content="Body")
- provider = SkillsProvider(skill_paths=str(tmp_path), skills=[prog_skill])
- assert "file-skill" in provider._skills
- assert "prog-skill" in provider._skills
+ prog_skill = InlineSkill(name="prog-skill", description="Code-defined.", instructions="Body")
+ provider = SkillsProviderBuilder().add_file_skills(str(tmp_path)).add_skills([prog_skill]).build()
+ await _init_provider(provider)
+ assert "file-skill" in _ctx(provider)[0]
+ assert "prog-skill" in _ctx(provider)[0]
- def test_duplicate_name_file_wins(self, tmp_path: Path) -> None:
+ async def test_duplicate_name_file_wins(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill", body="File version")
- prog_skill = Skill(name="my-skill", description="Code-defined.", content="Prog version")
- provider = SkillsProvider(skill_paths=str(tmp_path), skills=[prog_skill])
+ prog_skill = InlineSkill(name="my-skill", description="Code-defined.", instructions="Prog version")
+ provider = SkillsProviderBuilder().add_file_skills(str(tmp_path)).add_skills([prog_skill]).build()
+ await _init_provider(provider)
# File-based is loaded first, so it wins
- assert "File version" in provider._skills["my-skill"].content
+ assert "File version" in _ctx(provider)[0]["my-skill"].content
async def test_combined_prompt_includes_both(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "file-skill")
- prog_skill = Skill(name="prog-skill", description="A code-defined skill.", content="Body")
- provider = SkillsProvider(skill_paths=str(tmp_path), skills=[prog_skill])
+ prog_skill = InlineSkill(name="prog-skill", description="A code-defined skill.", instructions="Body")
+ provider = SkillsProviderBuilder().add_file_skills(str(tmp_path)).add_skills([prog_skill]).build()
context = SessionContext(input_messages=[])
await provider.before_run(agent=AsyncMock(), session=AsyncMock(), context=context, state={})
@@ -1210,7 +1292,7 @@ async def test_combined_prompt_includes_both(self, tmp_path: Path) -> None:
assert "file-skill" in prompt
assert "prog-skill" in prompt
- def test_custom_resource_extensions(self, tmp_path: Path) -> None:
+ async def test_custom_resource_extensions(self, tmp_path: Path) -> None:
"""SkillsProvider accepts custom resource_extensions."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
@@ -1222,8 +1304,9 @@ def test_custom_resource_extensions(self, tmp_path: Path) -> None:
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
# Only discover .json files
- provider = SkillsProvider(str(tmp_path), resource_extensions=(".json",))
- skill = provider._skills["my-skill"]
+ provider = SkillsProvider.from_paths(str(tmp_path), resource_extensions=(".json",))
+ await _init_provider(provider)
+ skill = _ctx(provider)[0]["my-skill"]
resource_names = [r.name for r in skill.resources]
assert "data.json" in resource_names
assert "notes.txt" not in resource_names
@@ -1257,9 +1340,9 @@ def test_path_set(self, tmp_path: Path) -> None:
skill = _read_and_parse_skill_file_for_test(tmp_path / "my-skill")
assert skill.path == str(tmp_path / "my-skill")
- def test_resources_populated(self, tmp_path: Path) -> None:
+ async def test_resources_populated(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill", resources={"refs/doc.md": "content"})
- skills = _discover_file_skills([str(tmp_path)])
+ skills = await _discover_file_skills_for_test([str(tmp_path)])
assert "my-skill" in skills
resource_names = [r.name for r in skills["my-skill"].resources]
assert "refs/doc.md" in resource_names
@@ -1273,34 +1356,37 @@ def test_resources_populated(self, tmp_path: Path) -> None:
class TestLoadSkillFormatting:
"""Tests for _load_skill output formatting differences between file-based and code-defined skills."""
- def test_file_skill_returns_raw_content(self, tmp_path: Path) -> None:
+ async def test_file_skill_returns_raw_content(self, tmp_path: Path) -> None:
"""File-based skills return raw SKILL.md content without XML wrapping."""
_write_skill(tmp_path, "my-skill", body="Do the thing.")
- provider = SkillsProvider(str(tmp_path))
- result = provider._load_skill("my-skill")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "Do the thing." in result
assert "" not in result
assert "" not in result
- def test_code_skill_wraps_in_xml(self) -> None:
+ async def test_code_skill_wraps_in_xml(self) -> None:
"""Code-defined skills are wrapped with name, description, and instructions tags."""
- skill = Skill(name="prog-skill", description="A skill.", content="Do stuff.")
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("prog-skill")
+ skill = InlineSkill(name="prog-skill", description="A skill.", instructions="Do stuff.")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "prog-skill")
assert "prog-skill" in result
assert "A skill." in result
assert "\nDo stuff.\n" in result
- def test_code_skill_single_resource_no_description(self) -> None:
+ async def test_code_skill_single_resource_no_description(self) -> None:
"""Resource without description omits the description attribute."""
- skill = Skill(
+ skill = InlineSkill(
name="prog-skill",
description="A skill.",
- content="Body.",
- resources=[SkillResource(name="data", content="val")],
+ instructions="Body.",
+ resources=[InlineSkillResource(name="data", content="val")],
)
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("prog-skill")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "prog-skill")
assert '' in result
assert "description=" not in result
@@ -1319,7 +1405,7 @@ def test_excludes_skill_md_case_insensitive(self, tmp_path: Path) -> None:
skill_dir.mkdir()
(skill_dir / "skill.md").write_text("lowercase name", encoding="utf-8")
(skill_dir / "other.md").write_text("keep me", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
names = [r.lower() for r in resources]
assert "skill.md" not in names
assert "other.md" in resources
@@ -1329,14 +1415,14 @@ def test_skips_directories(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
subdir = skill_dir / "data.json"
subdir.mkdir(parents=True)
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert resources == []
def test_extension_matching_is_case_insensitive(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "NOTES.TXT").write_text("caps", encoding="utf-8")
- resources = _discover_resource_files(str(skill_dir))
+ resources = _FileSkillsSource._discover_resource_files(str(skill_dir))
assert len(resources) == 1
@@ -1350,20 +1436,20 @@ class TestIsPathWithinDirectory:
def test_path_inside_directory(self, tmp_path: Path) -> None:
child = str(tmp_path / "sub" / "file.txt")
- assert _is_path_within_directory(child, str(tmp_path)) is True
+ assert _FileSkillsSource._is_path_within_directory(child, str(tmp_path)) is True
def test_path_outside_directory(self, tmp_path: Path) -> None:
outside = str(tmp_path.parent / "other" / "file.txt")
- assert _is_path_within_directory(outside, str(tmp_path)) is False
+ assert _FileSkillsSource._is_path_within_directory(outside, str(tmp_path)) is False
def test_path_is_directory_itself(self, tmp_path: Path) -> None:
- assert _is_path_within_directory(str(tmp_path), str(tmp_path)) is True
+ assert _FileSkillsSource._is_path_within_directory(str(tmp_path), str(tmp_path)) is True
def test_similar_prefix_not_matched(self, tmp_path: Path) -> None:
"""'skill-a-evil' is not inside 'skill-a'."""
dir_a = str(tmp_path / "skill-a")
evil = str(tmp_path / "skill-a-evil" / "file.txt")
- assert _is_path_within_directory(evil, dir_a) is False
+ assert _FileSkillsSource._is_path_within_directory(evil, dir_a) is False
# ---------------------------------------------------------------------------
@@ -1377,11 +1463,11 @@ class TestHasSymlinkInPathEdgeCases:
def test_raises_when_path_not_relative(self, tmp_path: Path) -> None:
unrelated = str(tmp_path.parent / "other" / "file.txt")
with pytest.raises(ValueError, match="does not start with directory"):
- _has_symlink_in_path(unrelated, str(tmp_path))
+ _FileSkillsSource._has_symlink_in_path(unrelated, str(tmp_path))
def test_returns_false_for_empty_relative(self, tmp_path: Path) -> None:
"""When path equals directory, relative is empty so no symlinks."""
- assert _has_symlink_in_path(str(tmp_path), str(tmp_path)) is False
+ assert _FileSkillsSource._has_symlink_in_path(str(tmp_path), str(tmp_path)) is False
# ---------------------------------------------------------------------------
@@ -1393,78 +1479,78 @@ class TestValidateSkillMetadata:
"""Tests for _validate_skill_metadata."""
def test_valid_metadata(self) -> None:
- assert _validate_skill_metadata("my-skill", "A description.", "source") is None
+ assert _FileSkillsSource._validate_skill_metadata("my-skill", "A description.", "source") is None
def test_none_name(self) -> None:
- result = _validate_skill_metadata(None, "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata(None, "desc", "source")
assert result is not None
assert "missing a name" in result
def test_empty_name(self) -> None:
- result = _validate_skill_metadata("", "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata("", "desc", "source")
assert result is not None
assert "missing a name" in result
def test_whitespace_only_name(self) -> None:
- result = _validate_skill_metadata(" ", "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata(" ", "desc", "source")
assert result is not None
assert "missing a name" in result
def test_name_at_max_length(self) -> None:
name = "a" * 64
- assert _validate_skill_metadata(name, "desc", "source") is None
+ assert _FileSkillsSource._validate_skill_metadata(name, "desc", "source") is None
def test_name_exceeds_max_length(self) -> None:
name = "a" * 65
- result = _validate_skill_metadata(name, "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata(name, "desc", "source")
assert result is not None
assert "invalid name" in result
def test_name_with_uppercase(self) -> None:
- result = _validate_skill_metadata("BadName", "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata("BadName", "desc", "source")
assert result is not None
assert "invalid name" in result
def test_name_starts_with_hyphen(self) -> None:
- result = _validate_skill_metadata("-bad", "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata("-bad", "desc", "source")
assert result is not None
assert "invalid name" in result
def test_name_ends_with_hyphen(self) -> None:
- result = _validate_skill_metadata("bad-", "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata("bad-", "desc", "source")
assert result is not None
assert "invalid name" in result
def test_name_with_consecutive_hyphens(self) -> None:
- result = _validate_skill_metadata("consecutive--hyphens", "desc", "source")
+ result = _FileSkillsSource._validate_skill_metadata("consecutive--hyphens", "desc", "source")
assert result is not None
assert "invalid name" in result
def test_single_char_name(self) -> None:
- assert _validate_skill_metadata("a", "desc", "source") is None
+ assert _FileSkillsSource._validate_skill_metadata("a", "desc", "source") is None
def test_none_description(self) -> None:
- result = _validate_skill_metadata("my-skill", None, "source")
+ result = _FileSkillsSource._validate_skill_metadata("my-skill", None, "source")
assert result is not None
assert "missing a description" in result
def test_empty_description(self) -> None:
- result = _validate_skill_metadata("my-skill", "", "source")
+ result = _FileSkillsSource._validate_skill_metadata("my-skill", "", "source")
assert result is not None
assert "missing a description" in result
def test_whitespace_only_description(self) -> None:
- result = _validate_skill_metadata("my-skill", " ", "source")
+ result = _FileSkillsSource._validate_skill_metadata("my-skill", " ", "source")
assert result is not None
assert "missing a description" in result
def test_description_at_max_length(self) -> None:
desc = "a" * 1024
- assert _validate_skill_metadata("my-skill", desc, "source") is None
+ assert _FileSkillsSource._validate_skill_metadata("my-skill", desc, "source") is None
def test_description_exceeds_max_length(self) -> None:
desc = "a" * 1025
- result = _validate_skill_metadata("my-skill", desc, "source")
+ result = _FileSkillsSource._validate_skill_metadata("my-skill", desc, "source")
assert result is not None
assert "invalid description" in result
@@ -1479,37 +1565,37 @@ class TestDiscoverSkillDirectories:
def test_finds_skill_at_root(self, tmp_path: Path) -> None:
(tmp_path / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n", encoding="utf-8")
- dirs = _discover_skill_directories([str(tmp_path)])
+ dirs = _FileSkillsSource._discover_skill_directories([str(tmp_path)])
assert len(dirs) == 1
def test_finds_nested_skill(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n", encoding="utf-8")
- dirs = _discover_skill_directories([str(tmp_path)])
+ dirs = _FileSkillsSource._discover_skill_directories([str(tmp_path)])
assert len(dirs) == 1
assert str(sub.absolute()) in dirs[0]
def test_skips_empty_path_string(self) -> None:
- dirs = _discover_skill_directories(["", " "])
+ dirs = _FileSkillsSource._discover_skill_directories(["", " "])
assert dirs == []
def test_skips_nonexistent_path(self) -> None:
- dirs = _discover_skill_directories(["/nonexistent/does/not/exist"])
+ dirs = _FileSkillsSource._discover_skill_directories(["/nonexistent/does/not/exist"])
assert dirs == []
def test_depth_limit_excludes_deep_skill(self, tmp_path: Path) -> None:
deep = tmp_path / "l1" / "l2" / "l3"
deep.mkdir(parents=True)
(deep / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n", encoding="utf-8")
- dirs = _discover_skill_directories([str(tmp_path)])
+ dirs = _FileSkillsSource._discover_skill_directories([str(tmp_path)])
assert len(dirs) == 0
def test_depth_limit_includes_at_boundary(self, tmp_path: Path) -> None:
at_boundary = tmp_path / "l1" / "l2"
at_boundary.mkdir(parents=True)
(at_boundary / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n", encoding="utf-8")
- dirs = _discover_skill_directories([str(tmp_path)])
+ dirs = _FileSkillsSource._discover_skill_directories([str(tmp_path)])
assert len(dirs) == 1
@@ -1525,7 +1611,7 @@ def test_valid_file(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A skill.\n---\nBody.", encoding="utf-8")
- result = _read_and_parse_skill_file(str(skill_dir))
+ result = _FileSkillsSource._read_and_parse_skill_file(str(skill_dir))
assert result is not None
name, desc, content = result
assert name == "my-skill"
@@ -1535,14 +1621,14 @@ def test_valid_file(self, tmp_path: Path) -> None:
def test_missing_skill_md_returns_none(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "no-skill"
skill_dir.mkdir()
- result = _read_and_parse_skill_file(str(skill_dir))
+ result = _FileSkillsSource._read_and_parse_skill_file(str(skill_dir))
assert result is None
def test_invalid_frontmatter_returns_none(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "bad-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("No frontmatter at all.", encoding="utf-8")
- result = _read_and_parse_skill_file(str(skill_dir))
+ result = _FileSkillsSource._read_and_parse_skill_file(str(skill_dir))
assert result is None
def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None:
@@ -1551,7 +1637,7 @@ def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None:
(skill_dir / "SKILL.md").write_text(
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
- result = _read_and_parse_skill_file(str(skill_dir))
+ result = _FileSkillsSource._read_and_parse_skill_file(str(skill_dir))
assert result is None
@@ -1564,47 +1650,96 @@ class TestCreateResourceElement:
"""Tests for _create_resource_element."""
def test_name_only(self) -> None:
- r = SkillResource(name="my-ref", content="data")
- elem = _create_resource_element(r)
+ r = InlineSkillResource(name="my-ref", content="data")
+ elem = InlineSkill._create_resource_element(r)
assert elem == ' '
def test_with_description(self) -> None:
- r = SkillResource(name="my-ref", description="A reference.", content="data")
- elem = _create_resource_element(r)
+ r = InlineSkillResource(name="my-ref", description="A reference.", content="data")
+ elem = InlineSkill._create_resource_element(r)
assert elem == ' '
def test_xml_escapes_name(self) -> None:
- r = SkillResource(name='ref"special', content="data")
- elem = _create_resource_element(r)
+ r = InlineSkillResource(name='ref"special', content="data")
+ elem = InlineSkill._create_resource_element(r)
assert """ in elem
def test_xml_escapes_description(self) -> None:
- r = SkillResource(name="ref", description='Uses & "quotes"', content="data")
- elem = _create_resource_element(r)
+ r = InlineSkillResource(name="ref", description='Uses & "quotes"', content="data")
+ elem = InlineSkill._create_resource_element(r)
assert "<tags>" in elem
assert "&" in elem
assert """ in elem
# ---------------------------------------------------------------------------
-# Tests: _read_file_skill_resource edge cases
+# Tests: _FileSkillResource edge cases
# ---------------------------------------------------------------------------
class TestReadFileSkillResourceEdgeCases:
- """Edge-case tests for _read_file_skill_resource."""
+ """Edge-case tests for _FileSkillResource."""
+
+ def test_constructor_validates_full_path(self) -> None:
+ with pytest.raises(ValueError, match="full_path cannot be empty"):
+ _FileSkillResource(name="some-file.md", full_path="")
+
+ def test_constructor_rejects_whitespace_full_path(self) -> None:
+ with pytest.raises(ValueError, match="full_path cannot be empty"):
+ _FileSkillResource(name="some-file.md", full_path=" ")
+
+ def test_full_path_property(self) -> None:
+ resource = _FileSkillResource(name="doc.md", full_path=f"{_ABS}/doc.md")
+ assert resource.full_path == f"{_ABS}/doc.md"
+
+ async def test_nonexistent_file_raises(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "skill"
+ skill_dir.mkdir()
+ full_path = str(skill_dir / "missing.md")
+ resource = _FileSkillResource(name="missing.md", full_path=full_path)
+ with pytest.raises(ValueError, match="not found"):
+ await resource.read()
+
+
+class TestGetValidatedResourcePath:
+ """Tests for _FileSkillsSource._get_validated_resource_path security validation."""
- def test_skill_with_no_path_raises(self) -> None:
- skill = Skill(name="no-path", description="No path.", content="Body")
- with pytest.raises(ValueError, match="has no path set"):
- _read_file_skill_resource(skill, "some-file.md")
+ def test_returns_valid_path(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "skill"
+ skill_dir.mkdir()
+ (skill_dir / "doc.md").write_text("hello")
+ result = _FileSkillsSource._get_validated_resource_path(str(skill_dir), "doc.md")
+ assert Path(result).is_file()
+
+ def test_rejects_relative_skill_dir(self) -> None:
+ with pytest.raises(ValueError, match="skill_dir must be an absolute path"):
+ _FileSkillsSource._get_validated_resource_path("relative/path", "doc.md")
+
+ def test_rejects_path_outside_skill_dir(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "skill"
+ skill_dir.mkdir()
+ outside_file = tmp_path / "secret.md"
+ outside_file.write_text("secret")
+ with pytest.raises(ValueError, match="outside the skill directory"):
+ _FileSkillsSource._get_validated_resource_path(str(skill_dir), "../secret.md")
+
+ def test_rejects_nonexistent_file(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "skill"
+ skill_dir.mkdir()
+ with pytest.raises(ValueError, match="not found"):
+ _FileSkillsSource._get_validated_resource_path(str(skill_dir), "missing.md")
- def test_nonexistent_file_raises(self, tmp_path: Path) -> None:
+ @pytest.mark.skipif(os.name == "nt", reason="symlinks require elevated privileges on Windows")
+ def test_rejects_symlink_in_path(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
- skill = Skill(name="test", description="Test.", content="Body", path=str(skill_dir))
- with pytest.raises(ValueError, match="not found in skill"):
- _read_file_skill_resource(skill, "missing.md")
+ real_subdir = tmp_path / "external"
+ real_subdir.mkdir()
+ (real_subdir / "data.md").write_text("external data")
+ link = skill_dir / "linked"
+ link.symlink_to(real_subdir)
+ with pytest.raises(ValueError, match="symlink"):
+ _FileSkillsSource._get_validated_resource_path(str(skill_dir), "linked/data.md")
# ---------------------------------------------------------------------------
@@ -1616,37 +1751,38 @@ class TestNormalizeResourcePathEdgeCases:
"""Additional edge-case tests for _normalize_resource_path."""
def test_bare_filename(self) -> None:
- assert _normalize_resource_path("file.md") == "file.md"
+ assert _FileSkillsSource._normalize_resource_path("file.md") == "file.md"
def test_deeply_nested_path(self) -> None:
- assert _normalize_resource_path("a/b/c/d.md") == "a/b/c/d.md"
+ assert _FileSkillsSource._normalize_resource_path("a/b/c/d.md") == "a/b/c/d.md"
def test_mixed_separators(self) -> None:
- assert _normalize_resource_path("a\\b/c\\d.md") == "a/b/c/d.md"
+ assert _FileSkillsSource._normalize_resource_path("a\\b/c\\d.md") == "a/b/c/d.md"
def test_dot_prefix_only(self) -> None:
- assert _normalize_resource_path("./file.md") == "file.md"
+ assert _FileSkillsSource._normalize_resource_path("./file.md") == "file.md"
# ---------------------------------------------------------------------------
-# Tests: _discover_file_skills edge cases
+# Tests: file skill discovery edge cases
# ---------------------------------------------------------------------------
class TestDiscoverFileSkillsEdgeCases:
- """Edge-case tests for _discover_file_skills."""
+ """Edge-case tests for file skill discovery."""
- def test_none_path_returns_empty(self) -> None:
- assert _discover_file_skills(None) == {}
+ async def test_empty_paths_returns_empty(self) -> None:
+ skills = await _discover_file_skills_for_test([])
+ assert len(skills) == 0
- def test_accepts_path_object(self, tmp_path: Path) -> None:
+ async def test_accepts_path_object(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- skills = _discover_file_skills(tmp_path)
+ skills = await _discover_file_skills_for_test(tmp_path)
assert "my-skill" in skills
- def test_accepts_single_string_path(self, tmp_path: Path) -> None:
+ async def test_accepts_single_string_path(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- skills = _discover_file_skills(str(tmp_path))
+ skills = await _discover_file_skills_for_test(str(tmp_path))
assert "my-skill" in skills
@@ -1660,25 +1796,25 @@ class TestExtractFrontmatterEdgeCases:
def test_whitespace_only_name(self) -> None:
content = "---\nname: ' '\ndescription: A skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_whitespace_only_description(self) -> None:
content = "---\nname: test-skill\ndescription: ' '\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is None
def test_name_exactly_max_length(self) -> None:
name = "a" * 64
content = f"---\nname: {name}\ndescription: A skill.\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result[0] == name
def test_description_exactly_max_length(self) -> None:
desc = "a" * 1024
content = f"---\nname: test-skill\ndescription: {desc}\n---\nBody."
- result = _extract_frontmatter(content, "test.md")
+ result = _FileSkillsSource._extract_frontmatter(content, "test.md")
assert result is not None
assert result[1] == desc
@@ -1692,26 +1828,26 @@ class TestCreateInstructionsEdgeCases:
"""Additional edge-case tests for _create_instructions."""
def test_custom_template_with_empty_skills_returns_none(self) -> None:
- result = _create_instructions("Custom: {skills}", {})
+ result = SkillsProvider._create_instructions("Custom: {skills}", [])
assert result is None
def test_custom_template_with_literal_braces(self) -> None:
- skills = {
- "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
- }
+ skills = [
+ InlineSkill(name="my-skill", description="Skill.", instructions="Body"),
+ ]
template = "Header {{literal}} {skills} footer."
- result = _create_instructions(template, skills)
+ result = SkillsProvider._create_instructions(template, skills)
assert result is not None
assert "{literal}" in result
assert "my-skill" in result
def test_multiple_skills_generates_sorted_xml(self) -> None:
- skills = {
- "charlie": Skill(name="charlie", description="C.", content="Body"),
- "alpha": Skill(name="alpha", description="A.", content="Body"),
- "bravo": Skill(name="bravo", description="B.", content="Body"),
- }
- result = _create_instructions(None, skills)
+ skills = [
+ InlineSkill(name="charlie", description="C.", instructions="Body"),
+ InlineSkill(name="alpha", description="A.", instructions="Body"),
+ InlineSkill(name="bravo", description="B.", instructions="Body"),
+ ]
+ result = SkillsProvider._create_instructions(None, skills)
assert result is not None
alpha_pos = result.index("alpha")
bravo_pos = result.index("bravo")
@@ -1720,21 +1856,48 @@ def test_multiple_skills_generates_sorted_xml(self) -> None:
def test_custom_template_missing_runner_instructions_raises(self) -> None:
"""Custom template without {runner_instructions} raises when scripts are enabled."""
- skills = {
- "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
- }
+ skills = [
+ InlineSkill(name="my-skill", description="Skill.", instructions="Body"),
+ ]
template = "Skills: {skills}"
with pytest.raises(ValueError, match="runner_instructions"):
- _create_instructions(template, skills, include_script_runner_instructions=True)
+ SkillsProvider._create_instructions(template, skills, include_script_runner_instructions=True)
+
+ def test_custom_template_missing_resource_instructions_raises(self) -> None:
+ """Custom template without {resource_instructions} raises when resources exist."""
+ skills = [
+ InlineSkill(name="my-skill", description="Skill.", instructions="Body"),
+ ]
+ template = "Skills: {skills}"
+ with pytest.raises(ValueError, match="resource_instructions"):
+ SkillsProvider._create_instructions(template, skills, include_resource_instructions=True)
+
+ def test_include_resource_instructions_true_adds_resource_text(self) -> None:
+ """When include_resource_instructions is True, resource instructions appear in the prompt."""
+ skills = [
+ InlineSkill(name="my-skill", description="Skill.", instructions="Body"),
+ ]
+ result = SkillsProvider._create_instructions(None, skills, include_resource_instructions=True)
+ assert result is not None
+ assert "read_skill_resource" in result
+
+ def test_include_resource_instructions_false_omits_resource_text(self) -> None:
+ """When include_resource_instructions is False, resource instructions do not appear."""
+ skills = [
+ InlineSkill(name="my-skill", description="Skill.", instructions="Body"),
+ ]
+ result = SkillsProvider._create_instructions(None, skills, include_resource_instructions=False)
+ assert result is not None
+ assert "read_skill_resource" not in result
def test_custom_template_with_unknown_placeholder_raises(self) -> None:
"""Template with an unknown placeholder raises ValueError."""
- skills = {
- "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
- }
+ skills = [
+ InlineSkill(name="my-skill", description="Skill.", instructions="Body"),
+ ]
template = "Skills: {skills} {unknown_key}"
with pytest.raises(ValueError, match="valid format string"):
- _create_instructions(template, skills)
+ SkillsProvider._create_instructions(template, skills)
# ---------------------------------------------------------------------------
@@ -1745,81 +1908,88 @@ def test_custom_template_with_unknown_placeholder_raises(self) -> None:
class TestSkillsProviderEdgeCases:
"""Additional edge-case tests for SkillsProvider."""
- def test_accepts_path_object(self, tmp_path: Path) -> None:
+ async def test_accepts_path_object(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- provider = SkillsProvider(tmp_path)
- assert "my-skill" in provider._skills
+ provider = SkillsProvider.from_paths(tmp_path)
+ await _init_provider(provider)
+ assert "my-skill" in _ctx(provider)[0]
- def test_load_skill_whitespace_name_returns_error(self, tmp_path: Path) -> None:
+ async def test_load_skill_whitespace_name_returns_error(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
- provider = SkillsProvider(str(tmp_path))
- result = provider._load_skill(" ")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), " ")
assert result.startswith("Error:")
assert "empty" in result
async def test_read_skill_resource_whitespace_skill_name_returns_error(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource(" ", "ref")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), " ", "ref")
assert result.startswith("Error:")
assert "empty" in result
async def test_read_skill_resource_whitespace_resource_name_returns_error(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", " ")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", " ")
assert result.startswith("Error:")
assert "empty" in result
async def test_read_callable_resource_exception_returns_error(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
def exploding_resource() -> Any:
raise RuntimeError("boom")
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "exploding_resource")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "exploding_resource")
assert result.startswith("Error:")
assert "Failed to read resource" in result
async def test_read_async_callable_resource_exception_returns_error(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
async def async_exploding() -> Any:
raise ValueError("async boom")
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "async_exploding")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "async_exploding")
assert result.startswith("Error:")
- def test_load_code_skill_xml_escapes_metadata(self) -> None:
- skill = Skill(name="my-skill", description='Uses & "quotes"', content="Body")
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("my-skill")
+ async def test_load_code_skill_xml_escapes_metadata(self) -> None:
+ skill = InlineSkill(name="my-skill", description='Uses & "quotes"', instructions="Body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "<tags>" in result
assert "&" in result
- def test_code_skill_deduplication(self) -> None:
- skill1 = Skill(name="my-skill", description="First.", content="Body 1")
- skill2 = Skill(name="my-skill", description="Second.", content="Body 2")
- provider = SkillsProvider(skills=[skill1, skill2])
- assert len(provider._skills) == 1
- assert "First." in provider._skills["my-skill"].description
+ async def test_code_skill_deduplication(self) -> None:
+ skill1 = InlineSkill(name="my-skill", description="First.", instructions="Body 1")
+ skill2 = InlineSkill(name="my-skill", description="Second.", instructions="Body 2")
+ provider = SkillsProvider([skill1, skill2])
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 1
+ assert "First." in _ctx(provider)[0]["my-skill"].description
async def test_before_run_extends_tools_even_without_instructions(self) -> None:
"""If instructions are somehow None but skills exist, tools should still be added."""
- skill = Skill(name="my-skill", description="A skill.", content="Body")
- provider = SkillsProvider(skills=[skill])
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
+ provider = SkillsProvider([skill])
context = SessionContext(input_messages=[])
await provider.before_run(agent=AsyncMock(), session=AsyncMock(), context=context, state={})
- assert len(context.tools) == 2
+ assert len(context.tools) == 1
tool_names = {t.name for t in context.tools}
assert "load_skill" in tool_names
- assert "read_skill_resource" in tool_names
# ---------------------------------------------------------------------------
@@ -1832,17 +2002,83 @@ class TestSkillResourceEdgeCases:
def test_empty_name_raises(self) -> None:
with pytest.raises(ValueError, match="cannot be empty"):
- SkillResource(name="", content="data")
+ InlineSkillResource(name="", content="data")
def test_whitespace_only_name_raises(self) -> None:
with pytest.raises(ValueError, match="cannot be empty"):
- SkillResource(name=" ", content="data")
+ InlineSkillResource(name=" ", content="data")
def test_description_defaults_to_none(self) -> None:
- r = SkillResource(name="ref", content="data")
+ r = InlineSkillResource(name="ref", content="data")
assert r.description is None
+# ---------------------------------------------------------------------------
+# Tests: SkillResource.read()
+# ---------------------------------------------------------------------------
+
+
+class TestSkillResourceRead:
+ """Tests for SkillResource.read() method."""
+
+ async def test_read_static_content(self) -> None:
+ """read() returns static content directly."""
+ r = InlineSkillResource(name="ref", content="hello")
+ result = await r.read()
+ assert result == "hello"
+
+ async def test_read_sync_function(self) -> None:
+ """read() invokes a sync function and returns its result."""
+ r = InlineSkillResource(name="ref", function=lambda: "computed")
+ result = await r.read()
+ assert result == "computed"
+
+ async def test_read_async_function(self) -> None:
+ """read() awaits an async function and returns its result."""
+ async def get_data() -> str:
+ return "async result"
+
+ r = InlineSkillResource(name="ref", function=get_data)
+ result = await r.read()
+ assert result == "async result"
+
+ async def test_read_function_with_kwargs(self) -> None:
+ """read() forwards kwargs to functions that accept them."""
+ def get_config(**kwargs: Any) -> str:
+ return f"user={kwargs.get('user_id')}"
+
+ r = InlineSkillResource(name="ref", function=get_config)
+ result = await r.read(user_id="u42")
+ assert result == "user=u42"
+
+ async def test_read_async_function_with_kwargs(self) -> None:
+ """read() forwards kwargs to async functions that accept them."""
+ async def get_config(**kwargs: Any) -> str:
+ return f"user={kwargs.get('user_id')}"
+
+ r = InlineSkillResource(name="ref", function=get_config)
+ result = await r.read(user_id="u42")
+ assert result == "user=u42"
+
+ async def test_read_function_without_kwargs_ignores_extra(self) -> None:
+ """read() does not pass kwargs to functions that don't accept them."""
+ def simple() -> str:
+ return "fixed"
+
+ r = InlineSkillResource(name="ref", function=simple)
+ result = await r.read(user_id="ignored")
+ assert result == "fixed"
+
+ async def test_read_function_raises_propagates(self) -> None:
+ """read() propagates exceptions from the function."""
+ def failing() -> str:
+ raise RuntimeError("boom")
+
+ r = InlineSkillResource(name="ref", function=failing)
+ with pytest.raises(RuntimeError, match="boom"):
+ await r.read()
+
+
# ---------------------------------------------------------------------------
# Tests: Skill.resource decorator edge cases
# ---------------------------------------------------------------------------
@@ -1852,7 +2088,7 @@ class TestSkillResourceDecoratorEdgeCases:
"""Additional edge-case tests for the @skill.resource decorator."""
def test_decorator_no_docstring_description_is_none(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
def no_docs() -> Any:
@@ -1861,7 +2097,7 @@ def no_docs() -> Any:
assert skill.resources[0].description is None
def test_decorator_with_name_only(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource(name="custom-name")
def get_data() -> Any:
@@ -1873,7 +2109,7 @@ def get_data() -> Any:
assert skill.resources[0].description == "Some docs."
def test_decorator_with_description_only(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource(description="Custom desc")
def get_data() -> Any:
@@ -1883,7 +2119,7 @@ def get_data() -> Any:
assert skill.resources[0].description == "Custom desc"
def test_decorator_preserves_original_function_identity(self) -> None:
- skill = Skill(name="my-skill", description="A skill.", content="Body")
+ skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@skill.resource
def original() -> Any:
@@ -1908,22 +2144,22 @@ class TestSkillScript:
def test_empty_name_raises(self) -> None:
with pytest.raises(ValueError, match="Script name cannot be empty"):
- SkillScript(name="")
+ InlineSkillScript(name="", function=lambda: None)
def test_whitespace_name_raises(self) -> None:
with pytest.raises(ValueError, match="Script name cannot be empty"):
- SkillScript(name=" ")
+ InlineSkillScript(name=" ", function=lambda: None)
- def test_path_default_none(self) -> None:
- script = SkillScript(name="test", function=lambda: None)
- assert script.path is None
+ def test_inline_script_has_no_path(self) -> None:
+ script = InlineSkillScript(name="test", function=lambda: None)
+ assert not hasattr(script, "path")
- def test_path_set_explicitly(self) -> None:
- script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py")
- assert script.path == "/skills/my-skill/scripts/gen.py"
+ def test_full_path_set_explicitly(self) -> None:
+ script = FileSkillScript(name="gen.py", full_path=f"{_ABS}/my-skill/scripts/gen.py")
+ assert script.full_path == f"{_ABS}/my-skill/scripts/gen.py"
def test_create_with_function(self) -> None:
- script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result")
+ script = InlineSkillScript(name="analyze", description="Run analysis", function=lambda: "result")
assert script.name == "analyze"
assert script.description == "Run analysis"
assert script.function is not None
@@ -1932,16 +2168,108 @@ def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
def func_with_kwargs(**kwargs: Any) -> str:
return "result"
- script = SkillScript(name="s1", function=func_with_kwargs)
+ script = InlineSkillScript(name="s1", function=func_with_kwargs)
assert script._accepts_kwargs is True
def test_accepts_kwargs_false_for_regular_function(self) -> None:
def func_no_kwargs(x: int = 0) -> str:
return "result"
- script = SkillScript(name="s1", function=func_no_kwargs)
+ script = InlineSkillScript(name="s1", function=func_no_kwargs)
assert script._accepts_kwargs is False
+ def test_runner_stored(self) -> None:
+ runner = _noop_script_runner
+ script = FileSkillScript(name="s1", full_path=f"{_ABS}/test/s1.py", runner=runner)
+ assert script._runner is runner
+
+ def test_runner_none_by_default(self) -> None:
+ script = FileSkillScript(name="s1", full_path=f"{_ABS}/test/s1.py")
+ assert script._runner is None
+
+
+class TestSkillScriptRun:
+ """Tests for SkillScript.run()."""
+
+ async def test_run_code_defined_sync(self) -> None:
+ def greet(name: str = "world") -> str:
+ return f"hello {name}"
+
+ script = InlineSkillScript(name="greet", function=greet)
+ skill = InlineSkill(name="s", description="d", instructions="c")
+ result = await script.run(skill, args={"name": "Alice"})
+ assert result == "hello Alice"
+
+ async def test_run_code_defined_async(self) -> None:
+ async def greet(name: str = "world") -> str:
+ return f"async {name}"
+
+ script = InlineSkillScript(name="greet", function=greet)
+ skill = InlineSkill(name="s", description="d", instructions="c")
+ result = await script.run(skill, args={"name": "Bob"})
+ assert result == "async Bob"
+
+ async def test_run_code_defined_with_kwargs(self) -> None:
+ def func(x: int = 0, **kwargs: Any) -> dict[str, Any]:
+ return {"x": x, **kwargs}
+
+ script = InlineSkillScript(name="f", function=func)
+ skill = InlineSkill(name="s", description="d", instructions="c")
+ result = await script.run(skill, args={"x": 1}, extra="val")
+ assert result == {"x": 1, "extra": "val"}
+
+ async def test_run_code_defined_no_args(self) -> None:
+ script = InlineSkillScript(name="f", function=lambda: 42)
+ skill = InlineSkill(name="s", description="d", instructions="c")
+ result = await script.run(skill)
+ assert result == 42
+
+ async def test_run_file_based_with_runner(self) -> None:
+ captured: dict[str, Any] = {}
+
+ def runner(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str:
+ captured["skill"] = skill.name
+ captured["script"] = script.name
+ captured["args"] = args
+ return "runner_result"
+
+ script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
+ skill = FileSkill(name="my-skill", description="d", content="c", path=f"{_ABS}/test")
+ result = await script.run(skill, args={"key": "val"})
+ assert result == "runner_result"
+ assert captured["skill"] == "my-skill"
+ assert captured["script"] == "run.py"
+ assert captured["args"] == {"key": "val"}
+
+ async def test_run_file_based_with_async_runner(self) -> None:
+ async def runner(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str:
+ return "async_runner"
+
+ script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
+ skill = FileSkill(name="s", description="d", content="c", path=f"{_ABS}/test")
+ result = await script.run(skill, args=None)
+ assert result == "async_runner"
+
+ async def test_run_file_based_without_runner_raises(self) -> None:
+ script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
+ skill = FileSkill(name="s", description="d", content="c", path=f"{_ABS}/test")
+ with pytest.raises(ValueError, match="requires a runner"):
+ await script.run(skill)
+
+ async def test_run_file_based_with_non_file_skill_raises_type_error(self) -> None:
+ script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=_noop_script_runner)
+ skill = InlineSkill(name="s", description="d", instructions="c")
+ with pytest.raises(TypeError, match="requires a FileSkill"):
+ await script.run(skill)
+
+ def test_full_path_rejects_relative(self) -> None:
+ with pytest.raises(ValueError, match="absolute path"):
+ FileSkillScript(name="run.py", full_path="scripts/run.py")
+
+ def test_full_path_rejects_empty(self) -> None:
+ with pytest.raises(ValueError, match="cannot be empty"):
+ FileSkillScript(name="run.py", full_path="")
+
# ---------------------------------------------------------------------------
# @skill.script decorator tests
@@ -1952,7 +2280,7 @@ class TestSkillScriptDecorator:
"""Tests for the @skill.script decorator."""
def test_bare_decorator(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
def analyze(query: str) -> str:
@@ -1962,10 +2290,11 @@ def analyze(query: str) -> str:
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "analyze"
assert skill.scripts[0].description == "Run analysis."
+ assert isinstance(skill.scripts[0], InlineSkillScript)
assert skill.scripts[0].function is analyze
def test_parameterized_decorator(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script(name="custom-name", description="Custom desc")
def my_func() -> str:
@@ -1974,10 +2303,11 @@ def my_func() -> str:
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "custom-name"
assert skill.scripts[0].description == "Custom desc"
+ assert isinstance(skill.scripts[0], InlineSkillScript)
assert skill.scripts[0].function is my_func
def test_multiple_scripts(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
def script_a() -> str:
@@ -1992,7 +2322,7 @@ def script_b() -> str:
assert skill.scripts[1].name == "script_b"
def test_async_script(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
async def fetch_data() -> str:
@@ -2001,10 +2331,11 @@ async def fetch_data() -> str:
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "fetch_data"
+ assert isinstance(skill.scripts[0], InlineSkillScript)
assert skill.scripts[0].function is fetch_data
def test_decorator_returns_original_function(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
def original() -> str:
@@ -2027,14 +2358,12 @@ class TestSkillWithScripts:
"""Tests for the Skill class with scripts attribute."""
def test_default_empty_scripts(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
assert skill.scripts == []
def test_scripts_at_construction(self) -> None:
- from agent_framework import SkillScript
-
- scripts = [SkillScript(name="s1", function=lambda: None)]
- skill = Skill(name="my-skill", description="test", content="body", scripts=scripts)
+ scripts = [InlineSkillScript(name="s1", function=lambda: None)]
+ skill = InlineSkill(name="my-skill", description="test", instructions="body", scripts=scripts)
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "s1"
@@ -2048,8 +2377,6 @@ class TestSkillScriptRunnerProtocol:
"""Tests for the SkillScriptRunner protocol."""
async def test_async_callable_satisfies_protocol(self) -> None:
- from agent_framework import SkillScript, SkillScriptRunner
-
results: list[tuple] = []
async def my_runner(skill, script, args=None):
@@ -2058,8 +2385,8 @@ async def my_runner(skill, script, args=None):
assert isinstance(my_runner, SkillScriptRunner)
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="my-script", path="scripts/run.py")
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = FileSkillScript(name="my-script", full_path=f"{_ABS}/test/scripts/run.py")
skill.scripts.append(script)
result = await my_runner(skill, script, args={"key": "val"})
@@ -2069,8 +2396,6 @@ async def my_runner(skill, script, args=None):
assert results[0] == ("test-skill", "my-script", {"key": "val"})
async def test_callable_class_satisfies_protocol(self) -> None:
- from agent_framework import SkillScript, SkillScriptRunner
-
class _CustomRunner:
async def __call__(self, skill, script, args=None):
return "custom result"
@@ -2078,40 +2403,34 @@ async def __call__(self, skill, script, args=None):
runner = _CustomRunner()
assert isinstance(runner, SkillScriptRunner)
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="my-script", function=lambda: None)
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = InlineSkillScript(name="my-script", function=lambda: None)
skill.scripts.append(script)
result = await runner(skill, script, args={"key": "val"})
assert result == "custom result"
async def test_runner_returns_none(self) -> None:
- from agent_framework import SkillScript
-
async def noop_runner(skill, script, args=None):
return None
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="s1", function=lambda: None)
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = InlineSkillScript(name="s1", function=lambda: None)
result = await noop_runner(skill, script)
assert result is None
async def test_runner_returns_object(self) -> None:
- from agent_framework import SkillScript
-
async def dict_runner(skill, script, args=None):
return {"exit_code": 0, "output": "ok"}
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="s1", path="scripts/run.py")
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = FileSkillScript(name="s1", full_path=f"{_ABS}/test/scripts/run.py")
result = await dict_runner(skill, script)
assert result == {"exit_code": 0, "output": "ok"}
def test_sync_callable_satisfies_protocol(self) -> None:
- from agent_framework import SkillScript, SkillScriptRunner
-
results: list[tuple] = []
def my_runner(skill, script, args=None):
@@ -2120,8 +2439,8 @@ def my_runner(skill, script, args=None):
assert isinstance(my_runner, SkillScriptRunner)
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="my-script", path="scripts/run.py")
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = FileSkillScript(name="my-script", full_path=f"{_ABS}/test/scripts/run.py")
skill.scripts.append(script)
result = my_runner(skill, script, args={"key": "val"})
@@ -2131,8 +2450,6 @@ def my_runner(skill, script, args=None):
assert results[0] == ("test-skill", "my-script", {"key": "val"})
def test_sync_callable_class_satisfies_protocol(self) -> None:
- from agent_framework import SkillScript, SkillScriptRunner
-
class _SyncRunner:
def __call__(self, skill, script, args=None):
return "sync result"
@@ -2140,33 +2457,29 @@ def __call__(self, skill, script, args=None):
runner = _SyncRunner()
assert isinstance(runner, SkillScriptRunner)
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="my-script", function=lambda: None)
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = InlineSkillScript(name="my-script", function=lambda: None)
skill.scripts.append(script)
result = runner(skill, script, args={"key": "val"})
assert result == "sync result"
def test_sync_runner_returns_none(self) -> None:
- from agent_framework import SkillScript
-
def noop_runner(skill, script, args=None):
return None
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="s1", function=lambda: None)
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = InlineSkillScript(name="s1", function=lambda: None)
result = noop_runner(skill, script)
assert result is None
def test_sync_runner_returns_object(self) -> None:
- from agent_framework import SkillScript
-
def dict_runner(skill, script, args=None):
return {"exit_code": 0, "output": "ok"}
- skill = Skill(name="test-skill", description="test", content="body")
- script = SkillScript(name="s1", path="scripts/run.py")
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ script = FileSkillScript(name="s1", full_path=f"{_ABS}/test/scripts/run.py")
result = dict_runner(skill, script)
assert result == {"exit_code": 0, "output": "ok"}
@@ -2180,48 +2493,86 @@ def dict_runner(skill, script, args=None):
class TestSkillsProviderFactories:
"""Tests for the SkillsProvider constructor auto-wiring behavior."""
- def test_code_skills_with_scripts_creates_provider(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ async def test_code_skills_with_scripts_creates_provider(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- assert len(provider._skills) == 1
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 1
# Default runner auto-wired: base tools + run_skill_script
- assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
- def test_code_skills_no_scripts(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
- provider = SkillsProvider(skills=[skill])
- # No scripts with functions, no runner — only base tools
- assert len(provider._tools) == 2
- assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+ async def test_code_skills_no_scripts(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ # No scripts with functions, no runner, no resources — only load_skill
+ assert len(_ctx(provider)[2]) == 1
+ assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
async def test_code_script_runs_directly(self) -> None:
- from agent_framework import SkillScript
-
def my_function(key: str = "") -> str:
return f"executed: {key}"
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=my_function))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=my_function))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"key": "hello"})
assert result == "executed: hello"
- def test_no_scripts_no_tool(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ async def test_no_scripts_no_tool(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
# No scripts at all — no run_skill_script tool
- provider = SkillsProvider(skills=[skill])
- assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
-
- def test_file_skills_with_custom_runner(self, tmp_path: Path) -> None:
- from agent_framework import SkillScriptRunner
-
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
+
+ async def test_no_resources_no_read_skill_resource_tool(self) -> None:
+ """When no skill has resources, read_skill_resource tool is not advertised."""
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert not any(hasattr(t, "name") and t.name == "read_skill_resource" for t in _ctx(provider)[2])
+
+ async def test_resources_present_includes_read_skill_resource_tool(self) -> None:
+ """When a skill has resources, read_skill_resource tool is advertised."""
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.resources.append(InlineSkillResource(name="ref", content="reference data"))
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert any(hasattr(t, "name") and t.name == "read_skill_resource" for t in _ctx(provider)[2])
+
+ async def test_resources_present_includes_resource_instructions(self) -> None:
+ """When a skill has resources, instructions mention read_skill_resource."""
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.resources.append(InlineSkillResource(name="ref", content="reference data"))
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert "read_skill_resource" in (_ctx(provider)[1] or "")
+
+ async def test_no_resources_excludes_resource_instructions(self) -> None:
+ """When no skill has resources, instructions do not mention read_skill_resource."""
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert "read_skill_resource" not in (_ctx(provider)[1] or "")
+
+ async def test_read_skill_resource_tool_returns_content(self) -> None:
+ """The read_skill_resource tool returns resource content when invoked."""
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.resources.append(InlineSkillResource(name="ref", content="reference data"))
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ read_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "read_skill_resource")
+ result = await read_tool.func(skill_name="my-skill", resource_name="ref")
+ assert result == "reference data"
+
+ async def test_file_skills_with_custom_runner(self, tmp_path: Path) -> None:
class _CustomRunner:
async def __call__(self, skill, script, args=None):
return "custom result"
@@ -2236,15 +2587,14 @@ async def __call__(self, skill, script, args=None):
)
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
- provider = SkillsProvider(
- skill_paths=str(tmp_path),
+ provider = SkillsProvider.from_paths(
+ str(tmp_path),
script_runner=_CustomRunner(),
)
- assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
-
- def test_file_skills_with_sync_runner(self, tmp_path: Path) -> None:
- from agent_framework import SkillScriptRunner
+ await _init_provider(provider)
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
+ async def test_file_skills_with_sync_runner(self, tmp_path: Path) -> None:
def sync_runner(skill, script, args=None):
return "sync result"
@@ -2258,11 +2608,12 @@ def sync_runner(skill, script, args=None):
)
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
- provider = SkillsProvider(
- skill_paths=str(tmp_path),
+ provider = SkillsProvider.from_paths(
+ str(tmp_path),
script_runner=sync_runner,
)
- assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+ await _init_provider(provider)
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
async def test_file_script_with_sync_runner_executes(self, tmp_path: Path) -> None:
"""A sync script_runner is awaitable through the provider's run_skill_script."""
@@ -2277,15 +2628,16 @@ async def test_file_script_with_sync_runner_executes(self, tmp_path: Path) -> No
def sync_runner(skill, script, args=None):
return f"sync: {script.name} args={args}"
- provider = SkillsProvider(
- skill_paths=str(tmp_path),
+ provider = SkillsProvider.from_paths(
+ str(tmp_path),
script_runner=sync_runner,
)
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="run.py", args={"key": "val"})
assert result == "sync: run.py args={'key': 'val'}"
- def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None:
+ async def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -2294,15 +2646,14 @@ def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None:
)
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
- provider = SkillsProvider(
- skill_paths=str(tmp_path),
+ provider = SkillsProvider.from_paths(
+ str(tmp_path),
script_runner=_noop_script_runner,
)
- assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
-
- def test_combined_skills(self, tmp_path: Path) -> None:
- from agent_framework import SkillScript
+ await _init_provider(provider)
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
+ async def test_combined_skills(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "file-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -2310,18 +2661,20 @@ def test_combined_skills(self, tmp_path: Path) -> None:
encoding="utf-8",
)
- code_skill = Skill(name="code-skill", description="test", content="body")
- code_skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ code_skill = InlineSkill(name="code-skill", description="test", instructions="body")
+ code_skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(
- skill_paths=str(tmp_path),
- skills=[code_skill],
- script_runner=_noop_script_runner,
+ provider = (
+ SkillsProviderBuilder()
+ .add_file_skills(str(tmp_path), script_runner=_noop_script_runner)
+ .add_skills([code_skill])
+ .build()
)
- assert "file-skill" in provider._skills
- assert "code-skill" in provider._skills
+ await _init_provider(provider)
+ assert "file-skill" in _ctx(provider)[0]
+ assert "code-skill" in _ctx(provider)[0]
- def test_file_scripts_without_runner_raises(self, tmp_path: Path) -> None:
+ async def test_file_scripts_without_runner_no_error_at_init(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -2330,19 +2683,19 @@ def test_file_scripts_without_runner_raises(self, tmp_path: Path) -> None:
)
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
- with pytest.raises(ValueError, match="script_runner"):
- SkillsProvider(skill_paths=str(tmp_path))
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ # Initialization succeeds; the error now surfaces at script.run() time
+ await _init_provider(provider)
async def test_file_script_error_without_runner(self) -> None:
- from agent_framework import SkillScript
-
# A skill with both a code script and a file-based script
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="code-s", function=lambda: "ok"))
- skill.scripts.append(SkillScript(name="file-s", path="scripts/s1.py"))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="code-s", function=lambda: "ok"))
+ skill.scripts.append(FileSkillScript(name="file-s", full_path=f"{_ABS}/test/scripts/s1.py"))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
# Code script works
result = await run_tool.func(skill_name="my-skill", script_name="code-s")
@@ -2351,66 +2704,55 @@ async def test_file_script_error_without_runner(self) -> None:
# File script without runner returns error
result = await run_tool.func(skill_name="my-skill", script_name="file-s")
assert "Error" in result
- assert "script_runner" in result
+ assert "Failed to run" in result
async def test_async_code_script_runs_directly(self) -> None:
- from agent_framework import SkillScript
-
async def async_func(x: int = 0) -> str:
return f"async: {x}"
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=async_func))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=async_func))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"x": 42})
assert result == "async: 42"
async def test_code_script_returns_object(self) -> None:
"""Code-defined scripts can return non-string objects."""
- from agent_framework import SkillScript
-
def returns_dict() -> dict:
return {"status": "ok", "value": 42}
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=returns_dict))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=returns_dict))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1")
assert result == {"status": "ok", "value": 42}
async def test_code_script_returns_none(self) -> None:
"""Code-defined scripts returning None pass through as None."""
- from agent_framework import SkillScript
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
-
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1")
assert result is None
- async def test_script_with_path_and_function_raises_error(self) -> None:
- """A script cannot have both a path and a function."""
- from agent_framework import SkillScript
-
- with pytest.raises(ValueError, match="must have either function or path, not both"):
- SkillScript(name="s1", function=lambda: "direct", path="scripts/s1.py")
-
async def test_script_with_path_errors_without_runner(self) -> None:
"""A file-based script without a runner should return an error."""
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="code-s", function=lambda: "ok"))
- skill.scripts.append(SkillScript(name="path-s", path="scripts/s1.py"))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="code-s", function=lambda: "ok"))
+ skill.scripts.append(FileSkillScript(name="path-s", full_path=f"{_ABS}/test/scripts/s1.py"))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
# Code-only script still works
result = await run_tool.func(skill_name="my-skill", script_name="code-s")
@@ -2419,90 +2761,97 @@ async def test_script_with_path_errors_without_runner(self) -> None:
# Path+function script without runner returns error
result = await run_tool.func(skill_name="my-skill", script_name="path-s")
assert "Error" in result
- assert "script_runner" in result
+ assert "script_runner" in result or "Failed to run" in result
async def test_run_skill_script_error_on_missing_skill(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="nonexistent", script_name="s1")
assert "Error" in result
assert "nonexistent" in result
async def test_run_skill_script_sync_with_kwargs(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
def greet(name: str, **kwargs: Any) -> str:
user_id = kwargs.get("user_id", "unknown")
return f"Hello {name} (user={user_id})"
- provider = SkillsProvider(skills=[skill])
- result = await provider._run_skill_script("my-skill", "greet", args={"name": "Alice"}, user_id="u42")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._run_skill_script(
+ _raw_skills(provider), "my-skill", "greet", args={"name": "Alice"}, user_id="u42"
+ )
assert result == "Hello Alice (user=u42)"
async def test_run_skill_script_async_with_kwargs(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
async def fetch(url: str, **kwargs: Any) -> str:
token = kwargs.get("auth_token", "none")
return f"fetched {url} with token={token}"
- provider = SkillsProvider(skills=[skill])
- result = await provider._run_skill_script("my-skill", "fetch", args={"url": "http://x"}, auth_token="abc")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._run_skill_script(
+ _raw_skills(provider), "my-skill", "fetch", args={"url": "http://x"}, auth_token="abc"
+ )
assert result == "fetched http://x with token=abc"
async def test_run_skill_script_without_kwargs_ignores_extra_args(self) -> None:
"""Script functions without **kwargs should still work when runtime kwargs are passed."""
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
def simple(query: str) -> str:
return f"result: {query}"
- provider = SkillsProvider(skills=[skill])
- result = await provider._run_skill_script("my-skill", "simple", args={"query": "test"}, user_id="ignored")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._run_skill_script(
+ _raw_skills(provider), "my-skill", "simple", args={"query": "test"}, user_id="ignored"
+ )
assert result == "result: test"
async def test_run_skill_script_conflicting_args_and_kwargs_raises(self) -> None:
"""Conflicting keys in args and kwargs should raise TypeError."""
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
@skill.script
def process(**kwargs: Any) -> str:
return f"mode={kwargs.get('mode', 'default')}"
- provider = SkillsProvider(skills=[skill])
- result = await provider._run_skill_script(
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._run_skill_script(_raw_skills(provider),
"my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
)
assert "Error" in result
async def test_run_skill_script_error_on_missing_script(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="nonexistent")
assert "Error" in result
assert "nonexistent" in result
async def test_run_skill_script_error_on_empty_names(self) -> None:
- from agent_framework import SkillScript
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
-
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="", script_name="s1")
assert "Error" in result
@@ -2510,98 +2859,92 @@ async def test_run_skill_script_error_on_empty_names(self) -> None:
result = await run_tool.func(skill_name="my-skill", script_name="")
assert "Error" in result
- def test_instructions_include_script_runner_hints(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ async def test_instructions_include_script_runner_hints(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- assert "run_skill_script" in provider._instructions
- assert "not as top-level tool parameters" in provider._instructions
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert "run_skill_script" in _ctx(provider)[1]
+ assert "not as top-level tool parameters" in _ctx(provider)[1]
- def test_no_scripts_no_runner_no_script_instructions(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
- provider = SkillsProvider(skills=[skill])
+ async def test_no_scripts_no_runner_no_script_instructions(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
# No scripts and no runner — instructions should not mention run_skill_script
- assert "run_skill_script" not in (provider._instructions or "")
-
- def test_tool_schema_args_description_mentions_key_format(self) -> None:
- from agent_framework import SkillScript
+ assert "run_skill_script" not in (_ctx(provider)[1] or "")
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ async def test_tool_schema_args_description_mentions_key_format(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_desc = run_tool.parameters()["properties"]["args"]["description"]
assert "without leading dashes" in args_desc
assert "script implementation or configured runner" in args_desc
- def test_require_script_approval_sets_approval_mode(self) -> None:
+ async def test_require_script_approval_sets_approval_mode(self) -> None:
"""When require_script_approval=True, the run_skill_script tool has approval_mode='always_require'."""
- from agent_framework import SkillScript
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
-
- provider = SkillsProvider(skills=[skill], require_script_approval=True)
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill], require_script_approval=True)
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
assert run_tool.approval_mode == "always_require"
- def test_require_script_approval_false_by_default(self) -> None:
+ async def test_require_script_approval_false_by_default(self) -> None:
"""By default, the run_skill_script tool has approval_mode='never_require'."""
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
assert run_tool.approval_mode == "never_require"
- def test_require_script_approval_does_not_affect_other_tools(self) -> None:
- """The load_skill and read_skill_resource tools should never require approval."""
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ async def test_require_script_approval_does_not_affect_other_tools(self) -> None:
+ """The load_skill tool should never require approval."""
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- provider = SkillsProvider(skills=[skill], require_script_approval=True)
- other_tools = [t for t in provider._tools if hasattr(t, "name") and t.name != "run_skill_script"]
- assert len(other_tools) == 2
+ provider = SkillsProvider([skill], require_script_approval=True)
+ await _init_provider(provider)
+ other_tools = [t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name != "run_skill_script"]
+ assert len(other_tools) == 1
for t in other_tools:
assert t.approval_mode == "never_require"
async def test_code_script_exception_returns_error(self) -> None:
"""A code script function that raises should return an error string."""
- from agent_framework import SkillScript
-
def failing_script() -> str:
raise RuntimeError("Something went wrong")
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="boom", function=failing_script))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="boom", function=failing_script))
- provider = SkillsProvider(skills=[skill])
- run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="boom")
assert "Error" in result
assert "boom" in result
assert "Something went wrong" not in result
- def test_custom_template_without_runner_placeholder_raises(self) -> None:
+ async def test_custom_template_without_runner_placeholder_raises(self) -> None:
"""Provider with code scripts and custom template missing {runner_instructions} raises."""
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
+ provider = SkillsProvider(
+ [skill],
+ instruction_template="Skills: {skills}",
+ )
with pytest.raises(ValueError, match="runner_instructions"):
- SkillsProvider(
- skills=[skill],
- instruction_template="Skills: {skills}",
- )
+ await _init_provider(provider)
# ---------------------------------------------------------------------------
@@ -2612,7 +2955,7 @@ def test_custom_template_without_runner_placeholder_raises(self) -> None:
class TestFileScriptDiscovery:
"""Tests for automatic .py script discovery in skill directories."""
- def test_discovers_py_files(self, tmp_path: Path) -> None:
+ async def test_discovers_py_files(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -2621,12 +2964,12 @@ def test_discovers_py_files(self, tmp_path: Path) -> None:
)
(skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
- skills = _discover_file_skills(str(tmp_path))
+ skills = await _discover_file_skills_for_test(str(tmp_path))
assert "my-skill" in skills
assert len(skills["my-skill"].scripts) == 1
assert skills["my-skill"].scripts[0].name == "analyze.py"
- def test_discovered_script_has_relative_path(self, tmp_path: Path) -> None:
+ async def test_discovered_script_has_absolute_full_path(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir(parents=True)
@@ -2636,13 +2979,14 @@ def test_discovered_script_has_relative_path(self, tmp_path: Path) -> None:
)
(scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8")
- skills = _discover_file_skills(str(tmp_path))
+ skills = await _discover_file_skills_for_test(str(tmp_path))
script = skills["my-skill"].scripts[0]
- assert script.path is not None
- assert not os.path.isabs(script.path)
- assert script.path == "scripts/generate.py"
+ assert script.full_path is not None
+ assert os.path.isabs(script.full_path)
+ expected = str(Path(str(skill_dir), "scripts", "generate.py"))
+ assert script.full_path == expected
- def test_discovers_nested_scripts(self, tmp_path: Path) -> None:
+ async def test_discovers_nested_scripts(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir(parents=True)
@@ -2652,11 +2996,11 @@ def test_discovers_nested_scripts(self, tmp_path: Path) -> None:
)
(scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8")
- skills = _discover_file_skills(str(tmp_path))
+ skills = await _discover_file_skills_for_test(str(tmp_path))
assert len(skills["my-skill"].scripts) == 1
assert skills["my-skill"].scripts[0].name == "scripts/generate.py"
- def test_no_scripts_when_no_py_files(self, tmp_path: Path) -> None:
+ async def test_no_scripts_when_no_py_files(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -2665,15 +3009,15 @@ def test_no_scripts_when_no_py_files(self, tmp_path: Path) -> None:
)
(skill_dir / "readme.md").write_text("# Docs", encoding="utf-8")
- skills = _discover_file_skills(str(tmp_path))
+ skills = await _discover_file_skills_for_test(str(tmp_path))
assert len(skills["my-skill"].scripts) == 0
class TestCustomScriptExtensions:
"""Tests for the script_extensions parameter (parity with resource_extensions)."""
- def test_custom_script_extensions_via_discover_file_skills(self, tmp_path: Path) -> None:
- """_discover_file_skills forwards script_extensions to _discover_script_files."""
+ async def test_custom_script_extensions_via_get_skills(self, tmp_path: Path) -> None:
+ """get_skills() forwards script_extensions to _discover_script_files."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -2684,18 +3028,18 @@ def test_custom_script_extensions_via_discover_file_skills(self, tmp_path: Path)
(skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
# Default: only .py discovered
- skills_default = _discover_file_skills(str(tmp_path))
+ skills_default = await _discover_file_skills_for_test(str(tmp_path))
script_names_default = [s.name for s in skills_default["my-skill"].scripts]
assert "analyze.py" in script_names_default
assert "run.sh" not in script_names_default
# Custom: only .sh discovered
- skills_custom = _discover_file_skills(str(tmp_path), script_extensions=(".sh",))
+ skills_custom = await _discover_file_skills_for_test(str(tmp_path), script_extensions=(".sh",))
script_names_custom = [s.name for s in skills_custom["my-skill"].scripts]
assert "run.sh" in script_names_custom
assert "analyze.py" not in script_names_custom
- def test_custom_script_extensions_via_provider(self, tmp_path: Path) -> None:
+ async def test_custom_script_extensions_via_provider(self, tmp_path: Path) -> None:
"""SkillsProvider accepts custom script_extensions."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
@@ -2707,17 +3051,18 @@ def test_custom_script_extensions_via_provider(self, tmp_path: Path) -> None:
(skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
# Only discover .sh scripts
- provider = SkillsProvider(
+ provider = SkillsProvider.from_paths(
str(tmp_path),
script_extensions=(".sh",),
script_runner=_noop_script_runner,
)
- skill = provider._skills["my-skill"]
+ await _init_provider(provider)
+ skill = _ctx(provider)[0]["my-skill"]
script_names = [s.name for s in skill.scripts]
assert "run.sh" in script_names
assert "analyze.py" not in script_names
- def test_multiple_script_extensions(self, tmp_path: Path) -> None:
+ async def test_multiple_script_extensions(self, tmp_path: Path) -> None:
"""Multiple script extensions can be specified."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
@@ -2729,12 +3074,13 @@ def test_multiple_script_extensions(self, tmp_path: Path) -> None:
(skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
- provider = SkillsProvider(
+ provider = SkillsProvider.from_paths(
str(tmp_path),
script_extensions=(".py", ".sh"),
script_runner=_noop_script_runner,
)
- skill = provider._skills["my-skill"]
+ await _init_provider(provider)
+ skill = _ctx(provider)[0]["my-skill"]
script_names = [s.name for s in skill.scripts]
assert "analyze.py" in script_names
assert "run.sh" in script_names
@@ -2754,19 +3100,17 @@ class TestCreateInstructionsWithScripts:
"""Tests for script metadata in skill advertisement."""
def test_excludes_script_count(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
- result = _create_instructions(None, {"my-skill": skill})
+ result = SkillsProvider._create_instructions(None, [skill])
assert result is not None
assert "" not in result
def test_no_scripts_element_when_empty(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
- result = _create_instructions(None, {"my-skill": skill})
+ result = SkillsProvider._create_instructions(None, [skill])
assert result is not None
assert "" not in result
@@ -2779,37 +3123,36 @@ def test_no_scripts_element_when_empty(self) -> None:
class TestLoadSkillWithScripts:
"""Tests for script metadata in load_skill output."""
- def test_code_skill_includes_scripts_element(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="analyze", description="Run analysis", function=lambda: None))
+ async def test_code_skill_includes_scripts_element(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="analyze", description="Run analysis", function=lambda: None))
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("my-skill")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "" in result
assert 'name="analyze"' in result
assert 'description="Run analysis"' in result
- def test_code_skill_no_scripts_element(self) -> None:
- skill = Skill(name="my-skill", description="test", content="body")
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("my-skill")
+ async def test_code_skill_no_scripts_element(self) -> None:
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "" not in result
- def test_code_skill_scripts_element_contains_parameters(self) -> None:
+ async def test_code_skill_scripts_element_contains_parameters(self) -> None:
"""Scripts XML includes parameters schema when the function has typed parameters."""
- from agent_framework import SkillScript
-
def analyze(query: str, limit: int = 10) -> str:
return "result"
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="analyze", description="Run analysis", function=analyze))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="analyze", description="Run analysis", function=analyze))
- provider = SkillsProvider(skills=[skill])
- result = provider._load_skill("my-skill")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "" in result
assert 'name="analyze"' in result
@@ -2821,72 +3164,66 @@ class TestReadSkillResourceWithScripts:
"""Tests for _read_skill_resource falling back to scripts."""
async def test_reads_script_with_static_content(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="generate.py", function=lambda: "print('hello')"))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="generate.py", function=lambda: "print('hello')"))
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "generate.py")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "generate.py")
# Scripts are not returned via _read_skill_resource
assert "not found" in result
async def test_script_not_accessible_via_read_resource(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="run.py", function=lambda: "script output"))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="run.py", function=lambda: "script output"))
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "run.py")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "run.py")
# Scripts are separate from resources
assert "not found" in result
async def test_async_script_not_accessible_via_read_resource(self) -> None:
- from agent_framework import SkillScript
-
async def async_script() -> str:
return "async output"
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="run.py", function=async_script))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="run.py", function=async_script))
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "run.py")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "run.py")
assert "not found" in result
async def test_script_case_insensitive_not_in_resources(self) -> None:
- from agent_framework import SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="Generate.py", function=lambda: "code"))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="Generate.py", function=lambda: "code"))
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "generate.py")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "generate.py")
assert "not found" in result
async def test_resource_takes_priority_over_script(self) -> None:
- from agent_framework import SkillResource, SkillScript
-
- skill = Skill(name="my-skill", description="test", content="body")
- skill.resources.append(SkillResource(name="data.py", content="resource content"))
- skill.scripts.append(SkillScript(name="data.py", function=lambda: "script content"))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.resources.append(InlineSkillResource(name="data.py", content="resource content"))
+ skill.scripts.append(InlineSkillScript(name="data.py", function=lambda: "script content"))
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "data.py")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "data.py")
assert result == "resource content"
async def test_script_function_error_not_exposed_via_resources(self) -> None:
- from agent_framework import SkillScript
-
def failing_script() -> str:
raise RuntimeError("boom")
- skill = Skill(name="my-skill", description="test", content="body")
- skill.scripts.append(SkillScript(name="bad.py", function=failing_script))
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="bad.py", function=failing_script))
- provider = SkillsProvider(skills=[skill])
- result = await provider._read_skill_resource("my-skill", "bad.py")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "bad.py")
assert "not found" in result
@@ -2899,12 +3236,10 @@ class TestGenerateFunctionSchema:
"""Tests for SkillScript.parameters_schema lazy generation."""
def test_simple_function(self) -> None:
- from agent_framework import SkillScript
-
def analyze(query: str, limit: int) -> str:
return ""
- script = SkillScript(name="analyze", function=analyze)
+ script = InlineSkillScript(name="analyze", function=analyze)
schema = script.parameters_schema
assert schema is not None
assert schema["type"] == "object"
@@ -2914,12 +3249,10 @@ def analyze(query: str, limit: int) -> str:
assert "limit" in schema["required"]
def test_optional_parameter(self) -> None:
- from agent_framework import SkillScript
-
def fetch(url: str, timeout: int = 30) -> str:
return ""
- script = SkillScript(name="fetch", function=fetch)
+ script = InlineSkillScript(name="fetch", function=fetch)
schema = script.parameters_schema
assert schema is not None
assert "url" in schema["properties"]
@@ -2929,68 +3262,56 @@ def fetch(url: str, timeout: int = 30) -> str:
assert "timeout" not in schema.get("required", [])
def test_no_parameters_returns_none(self) -> None:
- from agent_framework import SkillScript
-
def noop() -> None:
pass
- script = SkillScript(name="noop", function=noop)
+ script = InlineSkillScript(name="noop", function=noop)
assert script.parameters_schema is None
def test_skips_self_and_cls(self) -> None:
- from agent_framework import SkillScript
-
def method(self, query: str) -> str: # noqa: ANN001
return ""
- script = SkillScript(name="method", function=method)
+ script = InlineSkillScript(name="method", function=method)
schema = script.parameters_schema
assert schema is not None
assert "self" not in schema["properties"]
assert "query" in schema["properties"]
def test_skips_var_keyword(self) -> None:
- from agent_framework import SkillScript
-
def func(name: str, **kwargs: Any) -> str:
return ""
- script = SkillScript(name="func", function=func)
+ script = InlineSkillScript(name="func", function=func)
schema = script.parameters_schema
assert schema is not None
assert "kwargs" not in schema["properties"]
assert "name" in schema["properties"]
def test_async_function(self) -> None:
- from agent_framework import SkillScript
-
async def fetch_data(url: str) -> str:
return ""
- script = SkillScript(name="fetch_data", function=fetch_data)
+ script = InlineSkillScript(name="fetch_data", function=fetch_data)
schema = script.parameters_schema
assert schema is not None
assert "url" in schema["properties"]
def test_bool_and_float_types(self) -> None:
- from agent_framework import SkillScript
-
def process(verbose: bool, threshold: float) -> None:
pass
- script = SkillScript(name="process", function=process)
+ script = InlineSkillScript(name="process", function=process)
schema = script.parameters_schema
assert schema is not None
assert "verbose" in schema["properties"]
assert "threshold" in schema["properties"]
def test_lazy_generation_is_cached(self) -> None:
- from agent_framework import SkillScript
-
def analyze(query: str) -> str:
return ""
- script = SkillScript(name="analyze", function=analyze)
+ script = InlineSkillScript(name="analyze", function=analyze)
first = script.parameters_schema
second = script.parameters_schema
assert first is second
@@ -3005,42 +3326,34 @@ class TestCreateScriptElement:
"""Tests for _create_script_element."""
def test_name_only(self) -> None:
- from agent_framework import SkillScript
-
- s = SkillScript(name="run.py", path="scripts/run.py")
+ s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' '
def test_with_description(self) -> None:
- from agent_framework import SkillScript
-
- s = SkillScript(name="run.py", description="Execute script.", path="scripts/run.py")
+ s = FileSkillScript(name="run.py", description="Execute script.", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' '
def test_xml_escapes_name(self) -> None:
- from agent_framework import SkillScript
-
- s = SkillScript(name='script"special', path="scripts/s.py")
+ s = FileSkillScript(name='script"special', full_path=f"{_ABS}/test/scripts/s.py")
elem = _create_script_element(s)
assert """ in elem
def test_xml_escapes_description(self) -> None:
- from agent_framework import SkillScript
-
- s = SkillScript(name="run.py", description='Uses & "quotes"', path="scripts/run.py")
+ s = FileSkillScript(
+ name="run.py", description='Uses & "quotes"', full_path=f"{_ABS}/test/scripts/run.py"
+ )
elem = _create_script_element(s)
assert "<tags>" in elem
assert "&" in elem
assert """ in elem
def test_includes_parameters_for_code_script(self) -> None:
- from agent_framework import SkillScript
-
def analyze(query: str, limit: int = 10) -> str:
return ""
- s = SkillScript(name="analyze", description="Run analysis", function=analyze)
+ s = InlineSkillScript(name="analyze", description="Run analysis", function=analyze)
elem = _create_script_element(s)
assert "" in elem
assert "" in elem
@@ -3048,9 +3361,7 @@ def analyze(query: str, limit: int = 10) -> str:
assert """ not in elem
def test_no_parameters_for_file_script(self) -> None:
- from agent_framework import SkillScript
-
- s = SkillScript(name="run.py", path="scripts/run.py")
+ s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert "" not in elem
@@ -3064,49 +3375,39 @@ class TestSkillScriptParametersSchema:
"""Tests for parameters_schema auto-generation on SkillScript."""
def test_auto_generated_from_function(self) -> None:
- from agent_framework import SkillScript
-
def analyze(query: str) -> str:
return ""
- script = SkillScript(name="analyze", function=analyze)
+ script = InlineSkillScript(name="analyze", function=analyze)
assert script.parameters_schema is not None
assert "query" in script.parameters_schema["properties"]
def test_none_for_file_based_script(self) -> None:
- from agent_framework import SkillScript
-
- script = SkillScript(name="run.py", path="scripts/run.py")
+ script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
assert script.parameters_schema is None
def test_no_params_function_returns_none(self) -> None:
- from agent_framework import SkillScript
-
def noop() -> None:
pass
- script = SkillScript(name="noop", function=noop)
+ script = InlineSkillScript(name="noop", function=noop)
assert script.parameters_schema is None
def test_kwargs_only_function_returns_none(self) -> None:
- from agent_framework import SkillScript
-
def func(**kwargs: Any) -> str:
return ""
- script = SkillScript(name="func", function=func)
+ script = InlineSkillScript(name="func", function=func)
assert script.parameters_schema is None
def test_no_params_caching_does_not_reinspect(self) -> None:
"""parameters_schema caches the None result and does not re-inspect."""
from unittest.mock import patch
- from agent_framework import SkillScript
-
def noop() -> None:
pass
- script = SkillScript(name="noop", function=noop)
+ script = InlineSkillScript(name="noop", function=noop)
first = script.parameters_schema
assert first is None
# Second access should not create a new FunctionTool
@@ -3116,32 +3417,27 @@ def noop() -> None:
# ---------------------------------------------------------------------------
-# Tests: _load_skills merging behavior
+# Tests: Source-based merging behavior
# ---------------------------------------------------------------------------
class TestLoadSkillsMerging:
- """Tests for _load_skills merging file-based and code-defined skills."""
-
- def test_code_skill_with_invalid_name_is_skipped(self) -> None:
- """Code skills with invalid metadata (e.g. uppercase name) are skipped without raising."""
- invalid_skill = Skill(name="my-skill", description="valid", content="body")
- # Bypass Skill.__init__ validation by setting the name after construction
- invalid_skill.name = "INVALID_NAME"
+ """Tests for source-based merging of file-based and code-defined skills."""
- valid_skill = Skill(name="good-skill", description="valid", content="body")
+ def test_code_skill_with_invalid_name_raises(self) -> None:
+ """Code skills with invalid metadata (e.g. uppercase name) raise at construction."""
+ with pytest.raises(ValueError, match="Invalid skill name"):
+ InlineSkill(name="INVALID_NAME", description="valid", instructions="body")
- result = _load_skills(
- skill_paths=None,
- skills=[invalid_skill, valid_skill],
- resource_extensions=DEFAULT_RESOURCE_EXTENSIONS,
- script_extensions=DEFAULT_SCRIPT_EXTENSIONS,
+ async def test_file_skill_takes_precedence_over_code_skill(self, tmp_path: Path) -> None:
+ """When file-based and code-defined skills share a name, file-based wins."""
+ from agent_framework._skills import (
+ _AggregatingSkillsSource,
+ _DeduplicatingSkillsSource,
+ _FileSkillsSource,
+ _InMemorySkillsSource,
)
- assert "good-skill" in result
- assert "INVALID_NAME" not in result
- def test_file_skill_takes_precedence_over_code_skill(self, tmp_path: Path) -> None:
- """When file-based and code-defined skills share a name, file-based wins."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
@@ -3149,13 +3445,581 @@ def test_file_skill_takes_precedence_over_code_skill(self, tmp_path: Path) -> No
encoding="utf-8",
)
- code_skill = Skill(name="my-skill", description="Code skill.", content="Code body.")
+ code_skill = InlineSkill(name="my-skill", description="Code skill.", instructions="Code body.")
- result = _load_skills(
- skill_paths=str(tmp_path),
- skills=[code_skill],
- resource_extensions=DEFAULT_RESOURCE_EXTENSIONS,
- script_extensions=DEFAULT_SCRIPT_EXTENSIONS,
+ source = _DeduplicatingSkillsSource(
+ _AggregatingSkillsSource([
+ _FileSkillsSource(str(tmp_path)),
+ _InMemorySkillsSource([code_skill]),
+ ])
)
- assert "my-skill" in result
- assert result["my-skill"].path is not None # file-based skill has path set
+ result = await source.get_skills()
+ skills_by_name = {s.name: s for s in result}
+ assert "my-skill" in skills_by_name
+ assert skills_by_name["my-skill"].path is not None # file-based skill has path set
+
+
+# ---------------------------------------------------------------------------
+# Tests: SkillsSource classes
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsSource:
+ """Tests for the abstract SkillsSource and concrete implementations."""
+
+ async def test_file_skills_source_discovers_skills(self, tmp_path: Path) -> None:
+ """_FileSkillsSource discovers skills from SKILL.md files."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: Test skill.\n---\nBody.",
+ encoding="utf-8",
+ )
+
+ source = _FileSkillsSource(str(tmp_path))
+ skills = await source.get_skills()
+ assert len(skills) == 1
+ assert skills[0].name == "my-skill"
+ assert skills[0].path is not None
+
+ async def test_file_skills_source_with_extensions(self, tmp_path: Path) -> None:
+ """_FileSkillsSource resource_extensions controls extension filtering."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: Test skill.\n---\nBody.",
+ encoding="utf-8",
+ )
+ (skill_dir / "data.json").write_text("{}", encoding="utf-8")
+ (skill_dir / "data.csv").write_text("a,b", encoding="utf-8")
+
+ # Only allow .json resources
+ source = _FileSkillsSource(str(tmp_path), resource_extensions=(".json",))
+ skills = await source.get_skills()
+ assert len(skills) == 1
+ resource_names = [r.name for r in skills[0].resources]
+ assert "data.json" in resource_names
+ assert "data.csv" not in resource_names
+
+ async def test_in_memory_skills_source_returns_all_skills(self) -> None:
+ """_InMemorySkillsSource returns all provided skills."""
+ from agent_framework._skills import _InMemorySkillsSource
+
+ s1 = InlineSkill(name="skill-a", description="A", instructions="body")
+ s2 = InlineSkill(name="skill-b", description="B", instructions="body")
+
+ source = _InMemorySkillsSource([s1, s2])
+ skills = await source.get_skills()
+ assert len(skills) == 2
+ assert skills[0].name == "skill-a"
+ assert skills[1].name == "skill-b"
+
+ async def test_aggregating_source_combines_sources(self) -> None:
+ """Aggregating source concatenates results from multiple sources."""
+ from agent_framework._skills import _AggregatingSkillsSource, _InMemorySkillsSource
+
+ s1 = InlineSkill(name="skill-a", description="A", instructions="body")
+ s2 = InlineSkill(name="skill-b", description="B", instructions="body")
+
+ source = _AggregatingSkillsSource([
+ _InMemorySkillsSource([s1]),
+ _InMemorySkillsSource([s2]),
+ ])
+ skills = await source.get_skills()
+ names = [s.name for s in skills]
+ assert names == ["skill-a", "skill-b"]
+
+ async def test_filtering_source_filters_by_predicate(self) -> None:
+ """_FilteringSkillsSource only returns skills matching the predicate."""
+ from agent_framework._skills import _FilteringSkillsSource, _InMemorySkillsSource
+
+ s1 = InlineSkill(name="keep-me", description="keep", instructions="body")
+ s2 = InlineSkill(name="drop-me", description="drop", instructions="body")
+
+ source = _FilteringSkillsSource(
+ _InMemorySkillsSource([s1, s2]),
+ predicate=lambda s: s.name.startswith("keep"),
+ )
+ skills = await source.get_skills()
+ assert len(skills) == 1
+ assert skills[0].name == "keep-me"
+
+ async def test_deduplicating_source_removes_duplicates(self) -> None:
+ """_DeduplicatingSkillsSource keeps first skill with each name."""
+ from agent_framework._skills import _DeduplicatingSkillsSource, _InMemorySkillsSource
+
+ s1 = InlineSkill(name="my-skill", description="first", instructions="body1")
+ s2 = InlineSkill(name="my-skill", description="second", instructions="body2")
+ s3 = InlineSkill(name="other", description="other", instructions="body3")
+
+ source = _DeduplicatingSkillsSource(_InMemorySkillsSource([s1, s2, s3]))
+ skills = await source.get_skills()
+ assert len(skills) == 2
+ names = {s.name for s in skills}
+ assert names == {"my-skill", "other"}
+ # First one wins
+ my_skill = next(s for s in skills if s.name == "my-skill")
+ assert my_skill.description == "first"
+
+ async def test_delegating_source_delegates(self) -> None:
+ """_DelegatingSkillsSource delegates to inner source by default."""
+ from agent_framework._skills import _DelegatingSkillsSource, _InMemorySkillsSource
+
+ skill = InlineSkill(name="test-skill", description="test", instructions="body")
+ inner = _InMemorySkillsSource([skill])
+
+ class PassthroughSource(_DelegatingSkillsSource):
+ pass
+
+ source = PassthroughSource(inner)
+ assert source.inner_source is inner
+ skills = await source.get_skills()
+ assert len(skills) == 1
+ assert skills[0].name == "test-skill"
+
+ async def test_provider_with_source_parameter(self, tmp_path: Path) -> None:
+ """SkillsProvider works with the new source= parameter."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: Test skill.\n---\nBody.",
+ encoding="utf-8",
+ )
+
+ source = _FileSkillsSource(str(tmp_path))
+ provider = SkillsProvider(source)
+ await _init_provider(provider)
+ assert "my-skill" in _ctx(provider)[0]
+
+ async def test_provider_source_overrides_legacy_params(self, tmp_path: Path) -> None:
+ """When source= is provided, skill_paths and skills are ignored."""
+ from agent_framework._skills import _InMemorySkillsSource
+
+ code_skill = InlineSkill(name="code-skill", description="test", instructions="body")
+ source = _InMemorySkillsSource([code_skill])
+
+ # Pass skill_paths that would normally discover file skills — should be ignored
+ provider = SkillsProvider(source)
+ await _init_provider(provider)
+ assert "code-skill" in _ctx(provider)[0]
+ assert len(_ctx(provider)[0]) == 1
+
+ async def test_composed_source_pipeline(self, tmp_path: Path) -> None:
+ """Full source composition: file + code → aggregate → dedup → filter."""
+ from agent_framework._skills import (
+ _AggregatingSkillsSource,
+ _DeduplicatingSkillsSource,
+ _FileSkillsSource,
+ _FilteringSkillsSource,
+ _InMemorySkillsSource,
+ )
+
+ skill_dir = tmp_path / "file-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: file-skill\ndescription: File.\n---\nBody.",
+ encoding="utf-8",
+ )
+
+ code_skill = InlineSkill(name="code-skill", description="Code.", instructions="Body.")
+ internal = InlineSkill(name="internal", description="Internal.", instructions="Body.")
+
+ source = _FilteringSkillsSource(
+ _DeduplicatingSkillsSource(
+ _AggregatingSkillsSource([
+ _FileSkillsSource(str(tmp_path)),
+ _InMemorySkillsSource([code_skill, internal]),
+ ])
+ ),
+ predicate=lambda s: s.name != "internal",
+ )
+
+ skills = await source.get_skills()
+ names = {s.name for s in skills}
+ assert names == {"file-skill", "code-skill"}
+ assert "internal" not in names
+
+
+# ---------------------------------------------------------------------------
+# Tests: SkillsProviderBuilder
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsProviderBuilder:
+ """Tests for the fluent SkillsProviderBuilder."""
+
+ async def test_build_with_file_skills(self, tmp_path: Path) -> None:
+ """Builder with file skills creates a working provider."""
+ from agent_framework import SkillsProviderBuilder
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: Test.\n---\nBody.",
+ encoding="utf-8",
+ )
+
+ provider = SkillsProviderBuilder().add_file_skills(str(tmp_path)).build()
+ await _init_provider(provider)
+ assert "my-skill" in _ctx(provider)[0]
+
+ async def test_build_with_code_skills(self) -> None:
+ """Builder with code skills creates a working provider."""
+ from agent_framework import SkillsProviderBuilder
+
+ skill = InlineSkill(name="code-skill", description="test", instructions="body")
+ provider = SkillsProviderBuilder().add_skill(skill).build()
+ await _init_provider(provider)
+ assert "code-skill" in _ctx(provider)[0]
+
+ async def test_build_with_multiple_skills(self) -> None:
+ """Builder with add_skills registers multiple skills."""
+ from agent_framework import SkillsProviderBuilder
+
+ s1 = InlineSkill(name="skill-a", description="A", instructions="body")
+ s2 = InlineSkill(name="skill-b", description="B", instructions="body")
+ provider = SkillsProviderBuilder().add_skills([s1, s2]).build()
+ await _init_provider(provider)
+ assert "skill-a" in _ctx(provider)[0]
+ assert "skill-b" in _ctx(provider)[0]
+
+ async def test_build_with_custom_source(self) -> None:
+ """Builder with a custom source creates a working provider."""
+ from agent_framework import SkillsProviderBuilder
+ from agent_framework._skills import _InMemorySkillsSource
+
+ skill = InlineSkill(name="custom", description="test", instructions="body")
+ source = _InMemorySkillsSource([skill])
+ provider = SkillsProviderBuilder().add_source(source).build()
+ await _init_provider(provider)
+ assert "custom" in _ctx(provider)[0]
+
+ async def test_build_with_filter(self, tmp_path: Path) -> None:
+ """Builder with a filter predicate excludes matching skills."""
+ from agent_framework import SkillsProviderBuilder
+
+ s1 = InlineSkill(name="keep-me", description="keep", instructions="body")
+ s2 = InlineSkill(name="drop-me", description="drop", instructions="body")
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_skills([s1, s2])
+ .with_filter(lambda s: s.name.startswith("keep"))
+ .build()
+ )
+ await _init_provider(provider)
+ assert "keep-me" in _ctx(provider)[0]
+ assert "drop-me" not in _ctx(provider)[0]
+
+ async def test_build_deduplicates(self) -> None:
+ """Builder automatically deduplicates skills by name."""
+ from agent_framework import SkillsProviderBuilder
+ from agent_framework._skills import _InMemorySkillsSource
+
+ s1 = InlineSkill(name="dup", description="first", instructions="body1")
+ s2 = InlineSkill(name="dup", description="second", instructions="body2")
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_source(_InMemorySkillsSource([s1]))
+ .add_source(_InMemorySkillsSource([s2]))
+ .build()
+ )
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 1
+ assert _ctx(provider)[0]["dup"].description == "first"
+
+ def test_fluent_chaining_returns_same_builder(self) -> None:
+ """All builder methods return the same builder instance."""
+ from agent_framework import SkillsProviderBuilder
+
+ builder = SkillsProviderBuilder()
+ skill = InlineSkill(name="test", description="test", instructions="body")
+
+ assert builder.add_skill(skill) is builder
+ assert builder.add_skills([]) is builder
+ assert builder.add_file_skills("./fake") is builder
+ assert builder.with_prompt_template("{skills}") is builder
+ assert builder.with_script_approval(True) is builder
+ assert builder.with_file_script_runner(_noop_script_runner) is builder
+ assert builder.with_filter(lambda s: True) is builder
+
+ async def test_build_with_script_runner(self, tmp_path: Path) -> None:
+ """Builder-level script runner is used by file sources."""
+ from agent_framework import SkillsProviderBuilder
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_file_skills(str(tmp_path))
+ .with_file_script_runner(_noop_script_runner)
+ .build()
+ )
+ await _init_provider(provider)
+ assert "my-skill" in _ctx(provider)[0]
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2])
+
+ async def test_build_with_script_approval(self) -> None:
+ """Builder with script approval sets the approval mode."""
+ from agent_framework import SkillsProviderBuilder
+
+ skill = InlineSkill(name="my-skill", description="test", instructions="body")
+ skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
+
+ provider = (
+ SkillsProviderBuilder()
+ .add_skill(skill)
+ .with_script_approval()
+ .build()
+ )
+ await _init_provider(provider)
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
+ assert run_tool.approval_mode == "always_require"
+
+ async def test_build_empty(self) -> None:
+ """Builder with no sources creates an empty provider."""
+ from agent_framework import SkillsProviderBuilder
+
+ provider = SkillsProviderBuilder().build()
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 0
+
+ async def test_per_source_runner_overrides_builder_runner(self, tmp_path: Path) -> None:
+ """Per-source script runner takes precedence when no builder-level runner is on the provider."""
+ from agent_framework import SkillsProviderBuilder
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ call_log: list[str] = []
+
+ async def source_runner(skill: Any, script: Any, args: Any = None) -> str:
+ call_log.append("source")
+ return "source"
+
+ # Only set the runner on the source, not on the builder
+ provider = (
+ SkillsProviderBuilder()
+ .add_file_skills(str(tmp_path), script_runner=source_runner)
+ .build()
+ )
+ await _init_provider(provider)
+
+ # The source-level runner should be discovered and used
+ run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="run.py")
+ assert result == "source"
+ assert call_log == ["source"]
+
+
+# ---------------------------------------------------------------------------
+# Tests: SkillsProvider factory methods
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsProviderFactoryMethods:
+ """Tests for the SkillsProvider factory class methods."""
+
+ def test_from_paths_creates_provider(self, tmp_path: Path) -> None:
+ """from_paths returns a SkillsProvider instance."""
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ assert isinstance(provider, SkillsProvider)
+ assert provider.source_id == "agent_skills"
+
+ async def test_from_paths_discovers_skills(self, tmp_path: Path) -> None:
+ """from_paths discovers file-based skills."""
+ _write_skill(tmp_path, "my-skill")
+ provider = SkillsProvider.from_paths(str(tmp_path))
+ await _init_provider(provider)
+ assert "my-skill" in _ctx(provider)[0]
+
+ async def test_from_paths_accepts_multiple_paths(self, tmp_path: Path) -> None:
+ """from_paths accepts a sequence of paths."""
+ dir1 = tmp_path / "dir1"
+ dir2 = tmp_path / "dir2"
+ _write_skill(dir1, "skill-a")
+ _write_skill(dir2, "skill-b")
+ provider = SkillsProvider.from_paths([str(dir1), str(dir2)])
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 2
+
+ async def test_from_paths_custom_source_id(self, tmp_path: Path) -> None:
+ """from_paths supports custom source_id."""
+ provider = SkillsProvider.from_paths(str(tmp_path), source_id="custom")
+ assert provider.source_id == "custom"
+
+ async def test_from_paths_with_resource_extensions(self, tmp_path: Path) -> None:
+ """from_paths respects resource_extensions."""
+ _write_skill(tmp_path, "my-skill")
+ provider = SkillsProvider.from_paths(str(tmp_path), resource_extensions=(".json",))
+ await _init_provider(provider)
+ assert "my-skill" in _ctx(provider)[0]
+
+ def test_init_with_skills_creates_provider(self) -> None:
+ """Constructor with skill list returns a SkillsProvider instance."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider([skill])
+ assert isinstance(provider, SkillsProvider)
+
+ async def test_init_with_skills_registers_skills(self) -> None:
+ """Constructor with skill list registers code-defined skills."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ assert "test-skill" in _ctx(provider)[0]
+
+ async def test_init_with_empty_list(self) -> None:
+ """Constructor with empty list creates provider with no skills."""
+ provider = SkillsProvider([])
+ await _init_provider(provider)
+ assert len(_ctx(provider)[0]) == 0
+
+ async def test_init_with_skills_and_options(self) -> None:
+ """Constructor with skills passes through keyword options."""
+ skill = InlineSkill(name="my-skill", description="Test", instructions="Body")
+ provider = SkillsProvider(
+ [skill],
+ require_script_approval=True,
+ source_id="custom",
+ )
+ assert provider.source_id == "custom"
+ assert provider._require_script_approval is True
+
+ def test_init_with_source_creates_provider(self) -> None:
+ """Constructor with SkillsSource returns a SkillsProvider instance."""
+ from agent_framework._skills import _InMemorySkillsSource
+
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ source = _InMemorySkillsSource([skill])
+ provider = SkillsProvider(source)
+ assert isinstance(provider, SkillsProvider)
+
+ async def test_init_with_source_uses_provided_source(self) -> None:
+ """Constructor with SkillsSource uses the exact source given."""
+ from agent_framework._skills import _InMemorySkillsSource
+
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ source = _InMemorySkillsSource([skill])
+ provider = SkillsProvider(source)
+ await _init_provider(provider)
+ assert "test-skill" in _ctx(provider)[0]
+
+
+# ---------------------------------------------------------------------------
+# Tests: disable_caching
+# ---------------------------------------------------------------------------
+
+
+class TestDisableCaching:
+ """Tests for the disable_caching option."""
+
+ async def test_default_caching_enabled(self) -> None:
+ """By default, _get_or_create_context only builds once."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider([skill])
+ await _init_provider(provider)
+ first_ctx = provider._cached_context # pyright: ignore[reportPrivateUsage]
+ assert first_ctx is not None
+
+ # Calling _get_or_create_context again should return cached result
+ skills, _, _ = await provider._get_or_create_context()
+ assert skills is first_ctx[0] # Same object reference
+
+ async def test_disable_caching_rebuilds_on_every_call(self) -> None:
+ """With disable_caching=True, _create_context rebuilds every time."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider([skill], disable_caching=True)
+ await _init_provider(provider)
+ first_ctx = provider._cached_context # pyright: ignore[reportPrivateUsage]
+ assert first_ctx is not None
+
+ # Calling _create_context again should rebuild
+ skills, _, _ = await provider._create_context()
+ assert skills is not first_ctx[0] # Different object
+
+ async def test_disable_caching_via_constructor(self) -> None:
+ """disable_caching works via the primary constructor."""
+ from agent_framework._skills import _InMemorySkillsSource
+
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ source = _InMemorySkillsSource([skill])
+ provider = SkillsProvider(source, disable_caching=True)
+ assert provider._disable_caching is True
+
+ async def test_caching_enabled_by_default_in_builder(self) -> None:
+ """Builder defaults to caching enabled."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProviderBuilder().add_skill(skill).build()
+ assert provider._disable_caching is False
+
+ async def test_disable_caching_before_run_rebuilds(self) -> None:
+ """before_run with disable_caching=True calls _create_context each time."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider([skill], disable_caching=True)
+ context = SessionContext(input_messages=[])
+ await provider.before_run(agent=AsyncMock(), session=AsyncMock(), context=context, state={})
+ assert context.instructions # Skills instructions were added
+
+
+# ---------------------------------------------------------------------------
+# Tests: SkillsProvider constructor edge cases
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsProviderConstructorEdgeCases:
+ """Tests for SkillsProvider constructor source coercion."""
+
+ async def test_single_skill_accepted(self) -> None:
+ """A single Skill (not a list) is accepted and wrapped."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider(skill)
+ await _init_provider(provider)
+ skills = _ctx(provider)[0]
+ assert len(skills) == 1
+ assert "test-skill" in skills
+
+ async def test_template_missing_skills_placeholder_raises(self) -> None:
+ """Instruction template without {skills} raises ValueError."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ provider = SkillsProvider([skill], instruction_template="No placeholder here.")
+ with pytest.raises(ValueError, match="skills"):
+ await _init_provider(provider)
+
+ def test_string_source_rejected_with_helpful_error(self) -> None:
+ """Passing a string (path) to SkillsProvider raises TypeError."""
+ with pytest.raises(TypeError, match="from_paths"):
+ SkillsProvider("./skills") # type: ignore[arg-type]
+
+ def test_path_source_rejected_with_helpful_error(self) -> None:
+ """Passing a Path to SkillsProvider raises TypeError."""
+ with pytest.raises(TypeError, match="from_paths"):
+ SkillsProvider(Path("./skills")) # type: ignore[arg-type]
+
+
+# ---------------------------------------------------------------------------
+# Tests: InlineSkill content caching
+# ---------------------------------------------------------------------------
+
+
+class TestInlineSkillContentCaching:
+ """Tests for InlineSkill.content caching."""
+
+ def test_content_cached_after_first_access(self) -> None:
+ """InlineSkill.content returns the same object on subsequent accesses."""
+ skill = InlineSkill(name="test-skill", description="Test", instructions="Body")
+ first = skill.content
+ second = skill.content
+ assert first is second # Same object (cached)
+ assert "test-skill" in first
diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
index a68b09540b..f984dab97d 100644
--- a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
+++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
@@ -11,7 +11,7 @@
from textwrap import dedent
from typing import Any
-from agent_framework import Agent, Skill, SkillResource, SkillsProvider
+from agent_framework import Agent, InlineSkill, InlineSkillResource, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -46,10 +46,10 @@
# ---------------------------------------------------------------------------
# 1. Static Resources — inline content passed at construction time
# ---------------------------------------------------------------------------
-unit_converter_skill = Skill(
+unit_converter_skill = InlineSkill(
name="unit-converter",
description="Convert between common units using a conversion factor",
- content=dedent("""\
+ instructions=dedent("""\
Use this skill when the user asks to convert between units.
1. Review the conversion-tables resource to find the factor for the
@@ -58,7 +58,7 @@
3. Use the convert script, passing the value and factor from the table.
"""),
resources=[
- SkillResource(
+ InlineSkillResource(
name="conversion-tables",
content=dedent("""\
# Conversion Tables
@@ -146,7 +146,7 @@ async def main() -> None:
async with Agent(
client=client,
instructions="You are a helpful assistant that can convert units.",
- context_providers=[SkillsProvider(skills=[unit_converter_skill])],
+ context_providers=[SkillsProvider([unit_converter_skill])],
) as agent:
print("Converting units")
print("-" * 60)
diff --git a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
index 809c1cdd63..35a9af9d66 100644
--- a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
+++ b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
@@ -59,7 +59,7 @@ async def main() -> None:
# Discovers skills from the 'skills' directory and configures the
# subprocess_script_runner to run file-based scripts.
skills_dir = Path(__file__).parent / "skills"
- skills_provider = SkillsProvider(
+ skills_provider = SkillsProvider.from_paths(
skill_paths=str(skills_dir),
script_runner=subprocess_script_runner,
)
diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md
index d41f7dbaa9..704c32b60e 100644
--- a/python/samples/02-agents/skills/mixed_skills/README.md
+++ b/python/samples/02-agents/skills/mixed_skills/README.md
@@ -38,11 +38,11 @@ File scripts are executed as **local Python subprocesses** via the
```
┌─────────────────────────────────────────────────────────────┐
-│ SkillsProvider( │
-│ skill_paths="./skills", # file skills │
-│ skills=[volume_converter_skill], # code skills │
-│ script_runner=runner, │
-│ ) │
+│ SkillsProviderBuilder() │
+│ .add_file_skills("./skills", # file skills │
+│ script_runner=runner) │
+│ .add_skill(volume_converter_skill) # code skills │
+│ .build() │
└─────────────┬───────────────────────────────────────────────┘
│
▼
diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
index 5843dcdd67..7e6865719c 100644
--- a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
+++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
@@ -15,8 +15,8 @@
from agent_framework import (
Agent,
- Skill,
- SkillsProvider,
+ InlineSkill,
+ SkillsProviderBuilder,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
@@ -63,10 +63,10 @@
# 1. Define a code skill with @skill.script and @skill.resource decorators
# ---------------------------------------------------------------------------
-volume_converter_skill = Skill(
+volume_converter_skill = InlineSkill(
name="volume-converter",
description="Convert between gallons and liters using a conversion factor",
- content=dedent("""\
+ instructions=dedent("""\
Use this skill when the user asks to convert between gallons and liters.
1. Review the conversion-table resource to find the correct factor.
@@ -126,10 +126,11 @@ async def main() -> None:
# The script_runner handles file-based scripts; code-defined scripts
# (@skill.script) run in-process automatically.
skills_dir = Path(__file__).parent / "skills"
- skills_provider = SkillsProvider(
- skill_paths=str(skills_dir),
- skills=[volume_converter_skill],
- script_runner=subprocess_script_runner,
+ skills_provider = (
+ SkillsProviderBuilder()
+ .add_file_skills(str(skills_dir), script_runner=subprocess_script_runner)
+ .add_skill(volume_converter_skill)
+ .build()
)
# Run the agent
diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py
index 770e003d39..bd956dec61 100644
--- a/python/samples/02-agents/skills/script_approval/script_approval.py
+++ b/python/samples/02-agents/skills/script_approval/script_approval.py
@@ -9,7 +9,7 @@
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
from textwrap import dedent
-from agent_framework import Agent, Skill, SkillsProvider
+from agent_framework import Agent, InlineSkill, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -42,10 +42,10 @@
load_dotenv()
# Define a code skill with a script that performs a sensitive operation
-deployment_skill = Skill(
+deployment_skill = InlineSkill(
name="deployment",
description="Tools for deploying application versions to production",
- content=dedent("""\
+ instructions=dedent("""\
Use this skill when the user asks to deploy an application.
1. Run the deploy script with the version and environment parameters.
@@ -72,7 +72,7 @@ async def main() -> None:
# Create the skills provider with script approval enabled
skills_provider = SkillsProvider(
- skills=[deployment_skill],
+ source=[deployment_skill],
require_script_approval=True,
)
diff --git a/python/samples/02-agents/skills/subprocess_script_runner.py b/python/samples/02-agents/skills/subprocess_script_runner.py
index 52bac380e8..d801b565f1 100644
--- a/python/samples/02-agents/skills/subprocess_script_runner.py
+++ b/python/samples/02-agents/skills/subprocess_script_runner.py
@@ -17,25 +17,21 @@
from pathlib import Path
from typing import Any
-from agent_framework import Skill, SkillScript
+from agent_framework import FileSkill, FileSkillScript
-def subprocess_script_runner(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str:
+def subprocess_script_runner(skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> str:
"""Run a skill script as a local Python subprocess.
- Resolves the script's absolute path from the skill directory, converts
- the ``args`` dict to CLI flags, and returns captured output.
+ Uses ``FileSkillScript.full_path`` as the script path, converts the
+ ``args`` dict to CLI flags, and returns captured output.
Args:
- skill: The skill that owns the script.
- script: The script to run.
+ skill: The file-based skill that owns the script.
+ script: The file-based script to run.
args: Optional arguments forwarded as CLI flags.
Returns:
The combined stdout/stderr output, or an error message.
"""
- if not skill.path:
- return f"Error: Skill '{skill.name}' has no directory path."
- if not script.path:
- return f"Error: Script '{script.name}' has no file path. Only file-based scripts can be executed locally."
- script_path = Path(skill.path) / script.path
+ script_path = Path(script.full_path)
if not script_path.is_file():
return f"Error: Script file not found: {script_path}"
cmd = [sys.executable, str(script_path)]