From 4173669c6ec4e2326c1ce1db6671d1f2188e2901 Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Mon, 9 Mar 2026 16:49:29 +0000 Subject: [PATCH 1/2] Changed Skills Resources to use typed deps. Signed-off-by: Suneet Nangia --- .../packages/core/agent_framework/__init__.py | 3 +- .../packages/core/agent_framework/_skills.py | 147 ++++++++++++++++-- .../packages/core/tests/core/test_skills.py | 118 +++++++++++--- .../02-agents/skills/code_skill/README.md | 4 +- .../02-agents/skills/code_skill/code_skill.py | 36 +++-- 5 files changed, 254 insertions(+), 54 deletions(-) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index ef03652898..358f7141cb 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -59,7 +59,7 @@ register_state_type, ) from ._settings import SecretString, load_settings -from ._skills import Skill, SkillResource, SkillsProvider +from ._skills import Skill, SkillContext, SkillResource, SkillsProvider from ._telemetry import ( AGENT_FRAMEWORK_USER_AGENT, APP_INFO, @@ -270,6 +270,7 @@ "SessionContext", "SingleEdgeGroup", "Skill", + "SkillContext", "SkillResource", "SkillsProvider", "SubWorkflowRequestMessage", diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index fc71329a5f..bd7a98708e 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -30,9 +30,10 @@ import os import re from collections.abc import Callable, Sequence +from dataclasses import dataclass from html import escape as xml_escape from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, ClassVar, Final +from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, TypeVar, get_origin from ._sessions import BaseContextProvider from ._tools import FunctionTool @@ -45,6 +46,85 @@ # region Models +DepsT = TypeVar("DepsT") + + +@dataclass +class SkillContext(Generic[DepsT]): + """Typed context provided to skill resource functions. + + .. warning:: Experimental + + This API is experimental and subject to change or removal + in future versions without notice. + + A generic context object that carries typed dependencies into resource + functions. Resource functions that declare a ``SkillContext[DepsT]`` + first parameter receive this context automatically when invoked. + + The context is mutable so that resources can enrich :attr:`deps` with + new state for subsequent resources (e.g. one resource loads data into + ``deps.db``, and a later resource queries it). + + Attributes: + deps: The dependency object supplied to :class:`SkillsProvider`. + + Examples: + .. code-block:: python + + from dataclasses import dataclass + from agent_framework import Skill, SkillContext, SkillsProvider + + + @dataclass + class MyDeps: + db: DatabaseClient + api_key: str + + + skill = Skill(name="my-skill", description="...", content="...") + + + @skill.resource + async def get_data(ctx: SkillContext[MyDeps]) -> str: + result = await ctx.deps.db.query("SELECT ...") + return str(result) + + + provider = SkillsProvider(skills=[skill], deps=MyDeps(db=conn, api_key="...")) + """ + + deps: DepsT + + +def _is_skill_context_annotation(annotation: Any) -> bool: + """Return whether *annotation* refers to :class:`SkillContext`. + + Handles both bare ``SkillContext`` and parameterized forms such as + ``SkillContext[MyDeps]``. Also resolves string annotations produced + by ``from __future__ import annotations``. + + Args: + annotation: The annotation value from an :class:`inspect.Parameter`. + + Returns: + ``True`` if *annotation* is a ``SkillContext`` type. + """ + if annotation is inspect.Parameter.empty: + return False + + # Handle stringified annotations from `from __future__ import annotations` + if isinstance(annotation, str): + stripped = annotation.strip() + return stripped == "SkillContext" or stripped.startswith("SkillContext[") + + # Direct class reference + if annotation is SkillContext: + return True + + # Parameterized generic: SkillContext[SomeType] + return get_origin(annotation) is SkillContext + class SkillResource: """A named piece of supplementary content attached to a skill. @@ -107,12 +187,21 @@ def __init__( self.content = content self.function = function - # Precompute whether the function accepts **kwargs to avoid - # repeated inspect.signature() calls on every invocation. - self._accepts_kwargs: bool = False + # Precompute whether the first positional parameter is typed as + # SkillContext to avoid repeated inspect.signature() calls. + self._takes_ctx: 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()) + + # Detect if the first positional parameter is typed as SkillContext. + positional_kinds = ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + for param in sig.parameters.values(): + if param.kind in positional_kinds: + self._takes_ctx = _is_skill_context_annotation(param.annotation) + break class Skill: @@ -370,6 +459,28 @@ class SkillsProvider(BaseContextProvider): skills=[my_skill], ) + With typed dependencies: + + .. code-block:: python + + from dataclasses import dataclass + + + @dataclass + class MyDeps: + db: DatabaseClient + + + skill = Skill(name="db-skill", description="DB operations", content="...") + + + @skill.resource + async def get_data(ctx: SkillContext[MyDeps]) -> str: + return str(await ctx.deps.db.query("SELECT ...")) + + + provider = SkillsProvider(skills=[skill], deps=MyDeps(db=conn)) + Attributes: DEFAULT_SOURCE_ID: Default value for the ``source_id`` used by this provider. """ @@ -384,6 +495,7 @@ def __init__( instruction_template: str | None = None, resource_extensions: tuple[str, ...] | None = None, source_id: str | None = None, + deps: Any = None, ) -> None: """Initialize a SkillsProvider. @@ -402,9 +514,14 @@ def __init__( resources. Defaults to ``DEFAULT_RESOURCE_EXTENSIONS`` (``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``). source_id: Unique identifier for this provider instance. + deps: Dependency object passed to resource functions that declare a + :class:`SkillContext` first parameter. Can be any type; type + safety is enforced at the resource function annotation site + (e.g. ``SkillContext[MyDeps]``). """ super().__init__(source_id or self.DEFAULT_SOURCE_ID) + self._deps = deps self._skills = _load_skills(skill_paths, skills, resource_extensions or DEFAULT_RESOURCE_EXTENSIONS) self._instructions = _create_instructions(instruction_template, self._skills) @@ -518,19 +635,18 @@ def _load_skill(self, skill_name: str) -> str: return content - async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwargs: Any) -> str: + async def _read_skill_resource(self, skill_name: str, resource_name: str) -> str: """Read a named resource from a skill. Resolves the resource by case-insensitive name lookup. Static ``content`` is returned directly; callable resources are invoked - (awaited if async). + (awaited if async). Resource functions that declare a + :class:`SkillContext` first parameter receive a context carrying + the provider's ``deps``. Args: 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 - that accept ``**kwargs`` (e.g. arguments passed via - ``agent.run(user_id="123")``). Returns: The resource content string, or a user-facing error message on @@ -559,12 +675,15 @@ async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwar if resource.function is not None: try: + # Build positional args: prepend SkillContext if the resource expects it. + args: tuple[Any, ...] = () + if resource._takes_ctx: # pyright: ignore[reportPrivateUsage] + args = (SkillContext(deps=self._deps),) + if inspect.iscoroutinefunction(resource.function): - result = ( - await resource.function(**kwargs) if resource._accepts_kwargs else await resource.function() # pyright: ignore[reportPrivateUsage] - ) + result = await resource.function(*args) else: - result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function() # pyright: ignore[reportPrivateUsage] + result = resource.function(*args) return str(result) except Exception as exc: logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name) diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index cb829b7b9f..2e6422790d 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -6,12 +6,11 @@ import os from pathlib import Path -from typing import Any from unittest.mock import AsyncMock import pytest -from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider +from agent_framework import SessionContext, Skill, SkillContext, SkillResource, SkillsProvider from agent_framework._skills import ( DEFAULT_RESOURCE_EXTENSIONS, _create_instructions, @@ -994,41 +993,116 @@ async def test_read_unknown_resource_returns_error(self) -> None: result = await provider._read_skill_resource("prog-skill", "nonexistent") assert result.startswith("Error:") - async def test_read_callable_resource_sync_with_kwargs(self) -> None: + async def test_read_resource_sync_with_skill_context(self) -> None: + """Sync resource receiving SkillContext gets typed deps.""" + + class FakeDeps: + value = "hello" + skill = Skill(name="prog-skill", description="A skill.", content="Body") @skill.resource - def get_user_config(**kwargs: Any) -> str: - user_id = kwargs.get("user_id", "unknown") - return f"config for {user_id}" + def get_info(ctx: SkillContext[FakeDeps]) -> str: + return f"info: {ctx.deps.value}" - provider = SkillsProvider(skills=[skill]) - result = await provider._read_skill_resource("prog-skill", "get_user_config", user_id="user_123") - assert result == "config for user_123" + provider = SkillsProvider(skills=[skill], deps=FakeDeps()) + result = await provider._read_skill_resource("prog-skill", "get_info") + assert result == "info: hello" + + async def test_read_resource_async_with_skill_context(self) -> None: + """Async resource receiving SkillContext gets typed deps.""" + + class FakeDeps: + data = "async-data" - async def test_read_callable_resource_async_with_kwargs(self) -> None: skill = Skill(name="prog-skill", description="A skill.", content="Body") @skill.resource - async def get_user_data(**kwargs: Any) -> str: - token = kwargs.get("auth_token", "none") - return f"data with token={token}" + async def get_data(ctx: SkillContext[FakeDeps]) -> str: + return f"result: {ctx.deps.data}" - provider = SkillsProvider(skills=[skill]) - result = await provider._read_skill_resource("prog-skill", "get_user_data", auth_token="abc") - assert result == "data with token=abc" + provider = SkillsProvider(skills=[skill], deps=FakeDeps()) + result = await provider._read_skill_resource("prog-skill", "get_data") + assert result == "result: async-data" + + async def test_read_resource_without_context_backward_compat(self) -> None: + """Resources without SkillContext still work with deps set on provider.""" + skill = Skill(name="prog-skill", description="A skill.", content="Body") + + @skill.resource + def plain_resource() -> str: + return "plain" + + provider = SkillsProvider(skills=[skill], deps={"ignored": True}) + result = await provider._read_skill_resource("prog-skill", "plain_resource") + assert result == "plain" - async def test_read_callable_resource_without_kwargs_ignores_extra_args(self) -> None: - """Resource functions without **kwargs should still work when kwargs are passed.""" + async def test_read_resource_skill_context_with_none_deps(self) -> None: + """SkillContext works when deps is None (default).""" skill = Skill(name="prog-skill", description="A skill.", content="Body") @skill.resource - def static_resource() -> str: - return "static content" + def get_deps_info(ctx: SkillContext[None]) -> str: + return f"deps={ctx.deps}" provider = SkillsProvider(skills=[skill]) - result = await provider._read_skill_resource("prog-skill", "static_resource", user_id="ignored") - assert result == "static content" + result = await provider._read_skill_resource("prog-skill", "get_deps_info") + assert result == "deps=None" + + async def test_read_resource_skill_context_mutates_deps(self) -> None: + """Resource can mutate deps for use by subsequent resource calls.""" + + class MutableDeps: + loaded: bool = False + + skill = Skill(name="prog-skill", description="A skill.", content="Body") + deps = MutableDeps() + + @skill.resource + def load_data(ctx: SkillContext[MutableDeps]) -> str: + ctx.deps.loaded = True + return "loaded" + + provider = SkillsProvider(skills=[skill], deps=deps) + result = await provider._read_skill_resource("prog-skill", "load_data") + assert result == "loaded" + assert deps.loaded is True + + def test_takes_ctx_false_for_no_params(self) -> None: + """_takes_ctx is False when function has no parameters.""" + + def no_params() -> str: + return "" + + resource = SkillResource(name="r", function=no_params) + assert resource._takes_ctx is False + + def test_takes_ctx_false_for_wrong_annotation(self) -> None: + """_takes_ctx is False when first param is not SkillContext.""" + + def wrong_type(x: str) -> str: + return x + + resource = SkillResource(name="r", function=wrong_type) + assert resource._takes_ctx is False + + def test_takes_ctx_false_for_no_annotation(self) -> None: + """_takes_ctx is False for unannotated first parameter.""" + + def no_annotation(x) -> str: # noqa: ANN001 + return str(x) + + resource = SkillResource(name="r", function=no_annotation) + assert resource._takes_ctx is False + + def test_takes_ctx_true_for_bare_skill_context(self) -> None: + """_takes_ctx is True for bare SkillContext (no type param).""" + + def with_ctx(ctx: SkillContext) -> str: # type: ignore[type-arg] + return "" + + resource = SkillResource(name="r", function=with_ctx) + assert resource._takes_ctx is True async def test_before_run_injects_code_skills(self) -> None: skill = Skill(name="prog-skill", description="A code-defined skill.", content="Body") diff --git a/python/samples/02-agents/skills/code_skill/README.md b/python/samples/02-agents/skills/code_skill/README.md index 4900d00eb5..d892400671 100644 --- a/python/samples/02-agents/skills/code_skill/README.md +++ b/python/samples/02-agents/skills/code_skill/README.md @@ -8,7 +8,7 @@ While file-based skills use `SKILL.md` files discovered on disk, code-defined sk 1. **Basic Code Skill** — Create a `Skill` directly with static resources (inline content) 2. **Dynamic Resources** — Attach callable resources via the `@skill.resource` decorator that generate content at invocation time -3. **Dynamic Resources with kwargs** — Attach a callable resource that accepts `**kwargs` to receive runtime arguments passed via `agent.run()`, useful for injecting request-scoped context (user tokens, session data) +3. **Typed Dependencies via `SkillContext`** — Declare a `SkillContext[DepsT]` first parameter on a resource function to receive typed dependencies injected by the `SkillsProvider` All patterns can be combined with file-based skills in a single `SkillsProvider`. @@ -48,7 +48,7 @@ uv run samples/02-agents/skills/code_skill/code_skill.py The sample runs two examples: 1. **Code style question** — Uses Pattern 1 (static resources): the agent loads the `code-style` skill and reads the `style-guide` resource to answer naming convention questions -2. **Project info question** — Uses Patterns 2 & 3 (dynamic resources with kwargs): the agent reads the dynamically generated `team-roster` resource and the `environment` resource which receives `app_version` via runtime kwargs +2. **Project info question** — Uses Patterns 2 & 3 (dynamic resources with `SkillContext`): the agent reads the dynamically generated `team-roster` resource and the `environment` resource which receives `app_version` via typed dependencies ## Learn More diff --git a/python/samples/02-agents/skills/code_skill/code_skill.py b/python/samples/02-agents/skills/code_skill/code_skill.py index e111567244..9589db5d2e 100644 --- a/python/samples/02-agents/skills/code_skill/code_skill.py +++ b/python/samples/02-agents/skills/code_skill/code_skill.py @@ -3,10 +3,10 @@ import asyncio import os import sys +from dataclasses import dataclass from textwrap import dedent -from typing import Any -from agent_framework import Agent, Skill, SkillResource, SkillsProvider +from agent_framework import Agent, Skill, SkillContext, SkillResource, SkillsProvider from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -25,12 +25,12 @@ decorator. Resources can be sync or async functions that generate content at invocation time. -Pattern 3: Dynamic Resources with kwargs - Attach a callable resource that accepts **kwargs to receive runtime - arguments passed via agent.run(). This is useful for injecting - request-scoped context (user tokens, session data) into skill resources. +Pattern 3: Typed Dependencies via SkillContext + Attach a callable resource whose first parameter is SkillContext[DepsT]. + The provider injects a typed context object at invocation time, giving + the resource access to shared dependencies (database clients, config, etc.). -Both patterns can be combined with file-based skills in a single SkillsProvider. +All patterns can be combined with file-based skills in a single SkillsProvider. """ # Load environment variables from .env file @@ -77,16 +77,22 @@ ) +# Pattern 3: Typed Dependencies via SkillContext +@dataclass +class ProjectDeps: + """Shared dependencies for project-info skill resources.""" + + app_version: str = "unknown" + + @project_info_skill.resource -def environment(**kwargs: Any) -> str: +def environment(ctx: SkillContext[ProjectDeps]) -> str: """Get current environment configuration.""" - # Access runtime kwargs passed via agent.run(app_version="...") - app_version = kwargs.get("app_version", "unknown") env = os.environ.get("APP_ENV", "development") region = os.environ.get("APP_REGION", "us-east-1") return f"""\ # Environment Configuration - - App Version: {app_version} + - App Version: {ctx.deps.app_version} - Environment: {env} - Region: {region} - Python: {sys.version} @@ -117,9 +123,10 @@ async def main() -> None: credential=AzureCliCredential(), ) - # Create the skills provider with both code-defined skills + # Create the skills provider with both code-defined skills and typed deps skills_provider = SkillsProvider( skills=[code_style_skill, project_info_skill], + deps=ProjectDeps(app_version="2.4.1"), ) async with Agent( @@ -133,11 +140,10 @@ async def main() -> None: response = await agent.run("What naming convention should I use for class attributes?") print(f"Agent: {response}\n") - # Example 2: Project info question (Pattern 2 & 3 — dynamic resources with kwargs) + # Example 2: Project info question (Patterns 2 & 3 — dynamic resources with SkillContext) print("Example 2: Project info question") print("---------------------------------") - # Pass app_version as a runtime kwarg; it flows to the environment() resource via **kwargs - response = await agent.run("What environment are we running in and who is on the team?", app_version="2.4.1") + response = await agent.run("What environment are we running in and who is on the team?") print(f"Agent: {response}\n") """ From 6c6c6a95018775953e20f466fc7bd18e02747e48 Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Mon, 9 Mar 2026 18:29:00 +0000 Subject: [PATCH 2/2] Update to use typing.get_type_hints(). Signed-off-by: Suneet Nangia --- .../packages/core/agent_framework/_skills.py | 62 +++++------ .../packages/core/tests/core/test_skills.py | 102 ++++++++++++++---- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index bd7a98708e..a0f7deac5d 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -29,6 +29,7 @@ import logging import os import re +import typing from collections.abc import Callable, Sequence from dataclasses import dataclass from html import escape as xml_escape @@ -97,33 +98,38 @@ async def get_data(ctx: SkillContext[MyDeps]) -> str: deps: DepsT -def _is_skill_context_annotation(annotation: Any) -> bool: - """Return whether *annotation* refers to :class:`SkillContext`. +def _is_skill_ctx(annotation: Any) -> bool: + """Return whether *annotation* is the ``SkillContext`` class, parameterized or not.""" + return annotation is SkillContext or get_origin(annotation) is SkillContext - Handles both bare ``SkillContext`` and parameterized forms such as - ``SkillContext[MyDeps]``. Also resolves string annotations produced - by ``from __future__ import annotations``. - Args: - annotation: The annotation value from an :class:`inspect.Parameter`. +def _resolve_takes_ctx(func: Callable[..., Any]) -> bool: + """Return whether *func*'s first positional parameter is :class:`SkillContext`. - Returns: - ``True`` if *annotation* is a ``SkillContext`` type. + Uses :func:`typing.get_type_hints` to resolve annotations, including + stringified forms produced by ``from __future__ import annotations``. + + .. note:: + + Deps classes must be defined at **module level** so that + :func:`typing.get_type_hints` can resolve them. """ - if annotation is inspect.Parameter.empty: + sig = inspect.signature(func) + first = next( + (p for p in sig.parameters.values() if p.kind in (_POS_ONLY, _POS_OR_KW)), + None, + ) + if first is None or first.annotation is inspect.Parameter.empty: + return False + try: + resolved = typing.get_type_hints(func).get(first.name) + return resolved is not None and _is_skill_ctx(resolved) + except Exception: return False - # Handle stringified annotations from `from __future__ import annotations` - if isinstance(annotation, str): - stripped = annotation.strip() - return stripped == "SkillContext" or stripped.startswith("SkillContext[") - - # Direct class reference - if annotation is SkillContext: - return True - # Parameterized generic: SkillContext[SomeType] - return get_origin(annotation) is SkillContext +_POS_ONLY = inspect.Parameter.POSITIONAL_ONLY +_POS_OR_KW = inspect.Parameter.POSITIONAL_OR_KEYWORD class SkillResource: @@ -188,20 +194,8 @@ def __init__( self.function = function # Precompute whether the first positional parameter is typed as - # SkillContext to avoid repeated inspect.signature() calls. - self._takes_ctx: bool = False - if function is not None: - sig = inspect.signature(function) - - # Detect if the first positional parameter is typed as SkillContext. - positional_kinds = ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - for param in sig.parameters.values(): - if param.kind in positional_kinds: - self._takes_ctx = _is_skill_context_annotation(param.annotation) - break + # SkillContext to avoid repeated inspection at invocation time. + self._takes_ctx: bool = _resolve_takes_ctx(function) if function is not None else False class Skill: diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 2e6422790d..457e7860f2 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -24,10 +24,30 @@ _normalize_resource_path, _read_and_parse_skill_file, _read_file_skill_resource, + _resolve_takes_ctx, _validate_skill_metadata, ) +# Module-level deps classes for SkillContext tests. +# Must be at module level so typing.get_type_hints() can resolve them +# when ``from __future__ import annotations`` is active. +class _SyncDeps: + value = "hello" + + +class _AsyncDeps: + data = "async-data" + + +class _MutableDeps: + loaded: bool = False + + +class _EmptyDeps: + pass + + def _symlinks_supported(tmp: Path) -> bool: """Return True if the current platform/environment supports symlinks.""" test_target = tmp / "_symlink_test_target" @@ -995,33 +1015,25 @@ async def test_read_unknown_resource_returns_error(self) -> None: async def test_read_resource_sync_with_skill_context(self) -> None: """Sync resource receiving SkillContext gets typed deps.""" - - class FakeDeps: - value = "hello" - skill = Skill(name="prog-skill", description="A skill.", content="Body") @skill.resource - def get_info(ctx: SkillContext[FakeDeps]) -> str: + def get_info(ctx: SkillContext[_SyncDeps]) -> str: return f"info: {ctx.deps.value}" - provider = SkillsProvider(skills=[skill], deps=FakeDeps()) + provider = SkillsProvider(skills=[skill], deps=_SyncDeps()) result = await provider._read_skill_resource("prog-skill", "get_info") assert result == "info: hello" async def test_read_resource_async_with_skill_context(self) -> None: """Async resource receiving SkillContext gets typed deps.""" - - class FakeDeps: - data = "async-data" - skill = Skill(name="prog-skill", description="A skill.", content="Body") @skill.resource - async def get_data(ctx: SkillContext[FakeDeps]) -> str: + async def get_data(ctx: SkillContext[_AsyncDeps]) -> str: return f"result: {ctx.deps.data}" - provider = SkillsProvider(skills=[skill], deps=FakeDeps()) + provider = SkillsProvider(skills=[skill], deps=_AsyncDeps()) result = await provider._read_skill_resource("prog-skill", "get_data") assert result == "result: async-data" @@ -1051,15 +1063,11 @@ def get_deps_info(ctx: SkillContext[None]) -> str: async def test_read_resource_skill_context_mutates_deps(self) -> None: """Resource can mutate deps for use by subsequent resource calls.""" - - class MutableDeps: - loaded: bool = False - skill = Skill(name="prog-skill", description="A skill.", content="Body") - deps = MutableDeps() + deps = _MutableDeps() @skill.resource - def load_data(ctx: SkillContext[MutableDeps]) -> str: + def load_data(ctx: SkillContext[_MutableDeps]) -> str: ctx.deps.loaded = True return "loaded" @@ -1104,6 +1112,64 @@ def with_ctx(ctx: SkillContext) -> str: # type: ignore[type-arg] resource = SkillResource(name="r", function=with_ctx) assert resource._takes_ctx is True + +class TestResolveSkillContextAnnotationForms: + """Tests for _resolve_takes_ctx covering all annotation forms. + + Because this test file uses ``from __future__ import annotations``, + inline annotations are strings and exercise the ``get_type_hints()`` + resolution path. + """ + + # -- Forms resolved via get_type_hints() (stringified by __future__) -- + + def test_parameterized_skill_context_via_future_annotations(self) -> None: + """SkillContext[T] with from __future__ import annotations.""" + + def func(ctx: SkillContext[_EmptyDeps]) -> str: + return "" + + assert _resolve_takes_ctx(func) is True + + def test_bare_skill_context_via_future_annotations(self) -> None: + """Bare SkillContext with from __future__ import annotations.""" + + def func(ctx: SkillContext) -> str: # type: ignore[type-arg] + return "" + + assert _resolve_takes_ctx(func) is True + + # -- Negative cases -- + + def test_unresolvable_string_annotation_returns_false(self) -> None: + """String annotation that get_type_hints can't resolve returns False.""" + + def func(ctx) -> str: # noqa: ANN001 + return "" + + func.__annotations__["ctx"] = "SomeOtherContext[Deps]" + assert _resolve_takes_ctx(func) is False + + def test_no_positional_params(self) -> None: + """Function with only **kwargs returns False.""" + + def func(**kwargs) -> str: # noqa: ANN003 + return "" + + assert _resolve_takes_ctx(func) is False + + def test_keyword_only_param_not_detected(self) -> None: + """Keyword-only param with SkillContext annotation is not detected.""" + + def func(*, ctx: SkillContext) -> str: # type: ignore[type-arg] + return "" + + assert _resolve_takes_ctx(func) is False + + +class TestSkillsProviderCodeSkillBeforeRun: + """Tests for SkillsProvider before_run and combined scenarios.""" + 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])