Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions libs/deepagents/deepagents/middleware/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@ class SkillsState(AgentState):
skills_load_errors: NotRequired[Annotated[list[str], PrivateStateAttr]]
"""Skill source loading errors. Not propagated to parent agents."""

skills_source_version: NotRequired[Annotated[str, PrivateStateAttr]]
"""Token identifying the source set used for `skills_metadata`."""


class SkillsStateUpdate(TypedDict):
"""State update for the skills middleware."""
Expand All @@ -324,6 +327,9 @@ class SkillsStateUpdate(TypedDict):
skills_load_errors: NotRequired[list[str]]
"""Skill source loading errors to merge into state."""

skills_source_version: NotRequired[str]
"""Token identifying the source set used for `skills_metadata`."""


def _validate_skill_name(name: str, directory_name: str) -> tuple[bool, str]:
"""Validate skill name per Agent Skills specification.
Expand Down Expand Up @@ -843,6 +849,7 @@ def __init__(
backend: BACKEND_TYPES,
sources: Sequence[SkillSource],
system_prompt: str | None = SKILLS_SYSTEM_PROMPT,
source_version: str | None = None,
) -> None:
"""Initialize the skills middleware.

Expand All @@ -861,6 +868,8 @@ def __init__(
`{skills_list}` slots for runtime substitution. Pass `None`
to skip appending entirely (skills are still loaded into
`state["skills_metadata"]`).
source_version: Optional source version. A changed value reloads
metadata from checkpointed sessions.

Raises:
TypeError: If a tuple entry in `sources` is not a two- or
Expand All @@ -886,6 +895,7 @@ def __init__(
self.source_labels: list[str] = [_derive_source_label(s) for s in sources]
self.source_name_prefixes: list[str] = [_source_name_prefix(s) for s in sources]
self.system_prompt_template = system_prompt
self.source_version = source_version

def _get_backend(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> BackendProtocol:
"""Resolve backend from instance or factory.
Expand Down Expand Up @@ -1012,8 +1022,7 @@ def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableCon
Returns:
State update with `skills_metadata` populated, or `None` if already present.
"""
# Skip if skills_metadata is already present in state (even if empty)
if "skills_metadata" in state:
if "skills_metadata" in state and (self.source_version is None or state.get("skills_source_version") == self.source_version):
return None

# Resolve backend (supports both direct instances and factory functions)
Expand All @@ -1032,6 +1041,8 @@ def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableCon

skills = list(all_skills.values())
update = SkillsStateUpdate(skills_metadata=skills)
if self.source_version is not None:
update["skills_source_version"] = self.source_version
if skills_load_errors:
# Log even when `system_prompt_template is None`, otherwise the
# warnings only reach the model via the prompt fragment and
Expand All @@ -1058,8 +1069,7 @@ async def abefore_agent(self, state: SkillsState, runtime: Runtime, config: Runn
Returns:
State update with `skills_metadata` populated, or `None` if already present.
"""
# Skip if skills_metadata is already present in state (even if empty)
if "skills_metadata" in state:
if "skills_metadata" in state and (self.source_version is None or state.get("skills_source_version") == self.source_version):
return None

# Resolve backend (supports both direct instances and factory functions)
Expand All @@ -1078,6 +1088,8 @@ async def abefore_agent(self, state: SkillsState, runtime: Runtime, config: Runn

skills = list(all_skills.values())
update = SkillsStateUpdate(skills_metadata=skills)
if self.source_version is not None:
update["skills_source_version"] = self.source_version
if skills_load_errors:
# Log even when `system_prompt_template is None`, otherwise the
# warnings only reach the model via the prompt fragment and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
MAX_SKILLS_LOAD_WARNINGS,
SkillMetadata,
SkillsMiddleware,
SkillsState,
_format_skill_annotations,
_list_skills,
_parse_skill_metadata,
Expand Down Expand Up @@ -1871,6 +1872,31 @@ def test_before_agent_skips_loading_if_metadata_present(tmp_path: Path) -> None:
assert result["skills_metadata"][0]["name"] == "test-skill"


def test_before_agent_reloads_when_source_token_changes(tmp_path: Path) -> None:
backend = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=False)
skill_path = tmp_path / "skills" / "test" / "SKILL.md"
skill_path.parent.mkdir(parents=True)
skill_path.write_text(
make_skill_content("test", "Test skill"),
encoding="utf-8",
)
middleware = SkillsMiddleware(
backend=backend,
sources=[str(tmp_path / "skills")],
source_version="new-version",
)
state: SkillsState = {
"skills_metadata": [],
"skills_source_version": "old-version",
}

result = middleware.before_agent(state, Runtime(), {})

assert result is not None
assert result["skills_source_version"] == "new-version"
assert [skill["name"] for skill in result["skills_metadata"]] == ["test"]


def test_create_deep_agent_with_skills_and_filesystem_backend(tmp_path: Path) -> None:
"""Test end-to-end: create_deep_agent with skills parameter and FilesystemBackend."""
# Create skill on filesystem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
import pytest
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.runtime import Runtime

from deepagents.backends.filesystem import FilesystemBackend
from deepagents.backends.protocol import FileDownloadResponse, FileInfo, LsResult
from deepagents.middleware.skills import SkillsMiddleware, _alist_skills
from deepagents.middleware.skills import SkillsMiddleware, SkillsState, _alist_skills
from tests.unit_tests.chat_model import GenericFakeChatModel


Expand Down Expand Up @@ -408,6 +409,33 @@ async def test_abefore_agent_skips_loading_if_metadata_present(tmp_path: Path) -
assert result is None


async def test_abefore_agent_reloads_when_source_token_changes(
tmp_path: Path,
) -> None:
backend = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=False)
skill_path = tmp_path / "skills" / "test" / "SKILL.md"
skill_path.parent.mkdir(parents=True)
skill_path.write_text(
make_skill_content("test", "Test skill"),
encoding="utf-8",
)
middleware = SkillsMiddleware(
backend=backend,
sources=[str(tmp_path / "skills")],
source_version="new-version",
)
state: SkillsState = {
"skills_metadata": [],
"skills_source_version": "old-version",
}

result = await middleware.abefore_agent(state, Runtime(), {})

assert result is not None
assert result["skills_source_version"] == "new-version"
assert [skill["name"] for skill in result["skills_metadata"]] == ["test"]


async def test_agent_with_skills_middleware_multiple_sources_async(tmp_path: Path) -> None:
"""Test agent with skills from multiple sources (async)."""
backend = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=False)
Expand Down
Loading