Skip to content

Commit f60b6b3

Browse files
tstadelsjrl
andauthored
draft: add SkillStore abstraction (#11480)
Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com>
1 parent a55a877 commit f60b6b3

15 files changed

Lines changed: 533 additions & 164 deletions

File tree

haystack/dataclasses/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"image_content": ["ImageContent"],
1616
"file_content": ["FileContent"],
1717
"document": ["Document"],
18+
"skill_meta": ["SkillMeta"],
1819
"sparse_embedding": ["SparseEmbedding"],
1920
"state": ["State"],
2021
"streaming_chunk": [
@@ -46,6 +47,7 @@
4647
from .document import Document as Document
4748
from .file_content import FileContent as FileContent
4849
from .image_content import ImageContent as ImageContent
50+
from .skill_meta import SkillMeta as SkillMeta
4951
from .sparse_embedding import SparseEmbedding as SparseEmbedding
5052
from .streaming_chunk import AsyncStreamingCallbackT as AsyncStreamingCallbackT
5153
from .streaming_chunk import ComponentInfo as ComponentInfo

haystack/dataclasses/skill_meta.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from dataclasses import dataclass, field
6+
from pathlib import Path
7+
8+
9+
@dataclass
10+
class SkillMeta:
11+
"""
12+
Metadata describing a single skill.
13+
14+
:param name: The skill's name, used by the agent to load it.
15+
:param description: A short description of when to use the skill. Shown to the agent up front.
16+
:param path: The skill's directory. Set by `FileSystemSkillStore`; can be `None` for other stores.
17+
"""
18+
19+
name: str
20+
description: str
21+
path: Path | None = field(default=None)

haystack/skill_stores/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import sys
6+
from typing import TYPE_CHECKING
7+
8+
from lazy_imports import LazyImporter
9+
10+
_import_structure = {"skill_store": ["FileSystemSkillStore"]}
11+
12+
if TYPE_CHECKING:
13+
from .skill_store import FileSystemSkillStore as FileSystemSkillStore
14+
else:
15+
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import yaml
9+
10+
from haystack.core.serialization import default_from_dict, default_to_dict
11+
from haystack.dataclasses.skill_meta import SkillMeta
12+
from haystack.skill_stores.types.protocol import SKILL_FILE_NAME
13+
14+
15+
def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
16+
"""
17+
Split a `SKILL.md` file into its YAML frontmatter and markdown body.
18+
19+
The frontmatter is the YAML block delimited by leading and trailing `---` lines. If no frontmatter is
20+
present, an empty mapping and the original text are returned.
21+
22+
:param text: The full contents of a `SKILL.md` file.
23+
:returns: A tuple of (frontmatter mapping, body).
24+
:raises ValueError: If the frontmatter is present but is not a valid YAML mapping.
25+
"""
26+
stripped = text.lstrip()
27+
if not stripped.startswith("---"):
28+
return {}, text
29+
30+
# Drop the leading '---' line, then split on the closing '---'.
31+
after_open = stripped[len("---") :].lstrip("\n")
32+
parts = after_open.split("\n---", 1)
33+
if len(parts) != 2:
34+
return {}, text
35+
36+
frontmatter_block, body = parts
37+
loaded = yaml.safe_load(frontmatter_block) or {}
38+
if not isinstance(loaded, dict):
39+
raise ValueError("Skill frontmatter must be a YAML mapping.") # noqa: TRY004
40+
return loaded, body.lstrip("\n")
41+
42+
43+
class FileSystemSkillStore:
44+
"""
45+
SkillStore backed by a directory of skill sub-directories on the local filesystem.
46+
47+
Expected layout:
48+
49+
```
50+
skills/
51+
pdf-forms/
52+
SKILL.md # frontmatter (name, description) + markdown instructions
53+
reference/forms.md # optional bundled file
54+
```
55+
56+
Only the frontmatter of each `SKILL.md` is read at construction time (cheap); bodies and bundled
57+
files are read lazily when the agent calls the corresponding tool.
58+
59+
:param skills_dir: Root directory that contains one sub-directory per skill.
60+
:raises ValueError: If `skills_dir` does not exist, is not a directory, a skill is missing a required
61+
frontmatter field, or two skills share the same name.
62+
"""
63+
64+
def __init__(self, skills_dir: str | Path) -> None:
65+
self.skills_dir = Path(skills_dir)
66+
self._skills = self._scan()
67+
68+
def _scan(self) -> dict[str, SkillMeta]:
69+
if not self.skills_dir.is_dir():
70+
raise ValueError(f"Skills directory '{self.skills_dir}' does not exist or is not a directory.")
71+
72+
skills: dict[str, SkillMeta] = {}
73+
for skill_file in sorted(self.skills_dir.glob(f"*/{SKILL_FILE_NAME}")):
74+
skill_dir = skill_file.parent
75+
frontmatter, _ = _parse_frontmatter(skill_file.read_text(encoding="utf-8"))
76+
77+
name = frontmatter.get("name", skill_dir.name)
78+
description = frontmatter.get("description")
79+
if not description:
80+
raise ValueError(f"Skill '{name}' ({skill_file}) is missing a 'description' in its frontmatter.")
81+
if name in skills:
82+
raise ValueError(f"Duplicate skill name '{name}' found in '{self.skills_dir}'.")
83+
84+
skills[name] = SkillMeta(name=name, description=description, path=skill_dir)
85+
return skills
86+
87+
def list_skills(self) -> dict[str, SkillMeta]:
88+
"""Lists all skills available on disk"""
89+
return self._skills
90+
91+
def load_skill_body(self, name: str) -> str:
92+
"""Loads the skill body from disk"""
93+
meta = self._skills.get(name)
94+
if meta is None:
95+
raise KeyError(name)
96+
if meta.path is None:
97+
raise ValueError(f"Skill '{name}' is missing its directory path in metadata.")
98+
_, body = _parse_frontmatter((meta.path / SKILL_FILE_NAME).read_text(encoding="utf-8"))
99+
return body
100+
101+
def list_skill_files(self, name: str) -> list[str]:
102+
"""List all files in a skill directory, excluding the SKILL.md file."""
103+
meta = self._skills.get(name)
104+
if meta is None:
105+
raise KeyError(name)
106+
if meta.path is None:
107+
raise ValueError(f"Skill '{name}' is missing its directory path in metadata.")
108+
return sorted(
109+
p.relative_to(meta.path).as_posix()
110+
for p in meta.path.rglob("*")
111+
if p.is_file() and p.name != SKILL_FILE_NAME
112+
)
113+
114+
def read_skill_file(self, name: str, path: str) -> str:
115+
"""read_skill_file implementation that prevents path traversal outside the skill directory."""
116+
meta = self._skills.get(name)
117+
if meta is None:
118+
raise KeyError(name)
119+
if meta.path is None:
120+
raise ValueError(f"Skill '{name}' is missing its directory path in metadata.")
121+
skill_dir = meta.path.resolve()
122+
target = (skill_dir / path).resolve()
123+
if skill_dir != target and skill_dir not in target.parents:
124+
raise PermissionError(f"path escapes the '{name}' skill directory")
125+
if not target.is_file():
126+
raise FileNotFoundError(f"File '{path}' not found in skill '{name}'")
127+
return target.read_text(encoding="utf-8")
128+
129+
def to_dict(self) -> dict[str, Any]:
130+
"""Serialize this store to a dictionary for use with :meth:`from_dict`."""
131+
return default_to_dict(self, skills_dir=str(self.skills_dir))
132+
133+
@classmethod
134+
def from_dict(cls, data: dict[str, Any]) -> "FileSystemSkillStore":
135+
"""Deserialize a FileSystemSkillStore from its dictionary representation."""
136+
return default_from_dict(cls, data)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from .protocol import SkillStore
6+
7+
__all__ = ["SkillStore"]
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any, Protocol, runtime_checkable
6+
7+
from haystack.dataclasses.skill_meta import SkillMeta
8+
9+
SKILL_FILE_NAME = "SKILL.md"
10+
11+
12+
@runtime_checkable
13+
class SkillStore(Protocol):
14+
"""
15+
Protocol for a skill storage layer.
16+
17+
A `SkillStore` is responsible for discovering available skills and providing their content on demand.
18+
Implement this class to back `haystack.tools.SkillToolset` with any storage system — a local
19+
directory, a database, a remote API, or an in-memory fixture.
20+
21+
The three content methods (`load_skill_body`, `list_skill_files`,
22+
`read_skill_file`) are called lazily at agent runtime, not at construction time, so
23+
implementations may defer I/O until a skill is actually needed.
24+
"""
25+
26+
def list_skills(self) -> dict[str, SkillMeta]:
27+
"""
28+
Discover and return all available skills.
29+
30+
Called once during `haystack.tools.SkillToolset` initialization to build the skills catalog.
31+
32+
:returns: Mapping of skill name to its metadata.
33+
"""
34+
...
35+
36+
def load_skill_body(self, name: str) -> str:
37+
"""
38+
Return the markdown body of the named skill's instructions.
39+
40+
:param name: Skill name as returned by `list_skills`.
41+
:returns: The raw markdown body (frontmatter stripped).
42+
:raises KeyError: If no skill with `name` exists.
43+
"""
44+
...
45+
46+
def list_skill_files(self, name: str) -> list[str]:
47+
"""
48+
Return the relative paths of any files bundled with the named skill.
49+
50+
:param name: Skill name as returned by `list_skills`.
51+
:returns: Sorted list of POSIX-style paths relative to the skill root. Empty when there are no extras.
52+
:raises KeyError: If no skill with `name` exists.
53+
"""
54+
...
55+
56+
def read_skill_file(self, name: str, path: str) -> str:
57+
"""
58+
Read a file bundled with the named skill.
59+
60+
:param name: Skill name as returned by `list_skills`.
61+
:param path: Path of the file relative to the skill root (e.g. `"reference/forms.md"`).
62+
:returns: The file's text content.
63+
:raises KeyError: If no skill with `name` exists.
64+
:raises PermissionError: If `path` escapes the skill's root (path-traversal attempt).
65+
:raises FileNotFoundError: If the file does not exist within the skill.
66+
"""
67+
...
68+
69+
def to_dict(self) -> dict[str, Any]:
70+
"""
71+
Serialize this store to a dictionary for use with `from_dict`.
72+
73+
Override both this method and `from_dict` to make your custom store serializable with
74+
`haystack.tools.SkillToolset`.
75+
"""
76+
...
77+
78+
@classmethod
79+
def from_dict(cls, data: dict[str, Any]) -> "SkillStore":
80+
"""
81+
Deserialize a store from a dictionary produced by `to_dict`.
82+
83+
Override both this method and `to_dict` to make your custom store serializable with
84+
`haystack.tools.SkillToolset`.
85+
86+
:param data: Dictionary as produced by `to_dict`.
87+
"""
88+
...

haystack/tools/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from haystack.tools.tool import Tool, _check_duplicate_tool_names
1212
from haystack.tools.toolset import Toolset
1313
from haystack.tools.searchable_toolset import SearchableToolset
14-
from haystack.tools.skills import SkillMeta, SkillToolset
14+
from haystack.tools.skills import SkillToolset
1515
from haystack.tools.component_tool import ComponentTool
1616
from haystack.tools.pipeline_tool import PipelineTool
1717
from haystack.tools.serde_utils import deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset
@@ -33,7 +33,6 @@
3333
"serialize_tools_or_toolset",
3434
"Tool",
3535
"SearchableToolset",
36-
"SkillMeta",
3736
"SkillToolset",
3837
"ToolsType",
3938
"Toolset",

haystack/tools/skills/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5-
from haystack.tools.skills.skill_toolset import SkillMeta, SkillToolset
5+
from haystack.tools.skills.skill_toolset import SkillToolset
66

7-
__all__ = ["SkillMeta", "SkillToolset"]
7+
__all__ = ["SkillToolset"]

0 commit comments

Comments
 (0)