Coverage for haystack/skill_stores/file_system/skill_store.py: 98%

105 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 09:42 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import base64 

6import mimetypes 

7from pathlib import Path 

8from typing import Any 

9 

10import yaml 

11 

12from haystack.core.serialization import default_from_dict, default_to_dict 

13from haystack.dataclasses.file_content import FileContent 

14from haystack.dataclasses.image_content import IMAGE_MIME_TYPES, ImageContent 

15from haystack.dataclasses.skill_info import SkillInfo 

16from haystack.utils import Secret 

17 

18SKILL_FILE_NAME = "SKILL.md" 

19 

20# Non-text, non-image MIME types that are returned as `FileContent` so a multimodal LLM can ingest them directly. 

21# Scoped to PDF, which is what the major providers accept as a file input; everything else falls back to text. 

22_SUPPORTED_FILE_MIME_TYPES = {"application/pdf"} 

23 

24 

25def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: 

26 """ 

27 Split a `SKILL.md` file into its YAML frontmatter and markdown body. 

28 

29 The frontmatter is the YAML block delimited by a leading and a trailing line containing exactly `---`. 

30 If the first line is not `---`, no frontmatter is present and an empty mapping and the original text 

31 are returned. 

32 

33 :param text: The full contents of a `SKILL.md` file. 

34 :returns: A tuple of (frontmatter mapping, body). 

35 :raises ValueError: If the frontmatter is opened with `---` but never closed, is not valid YAML, or is 

36 not a YAML mapping. 

37 """ 

38 lines = text.lstrip().split("\n") 

39 if lines[0].rstrip() != "---": 

40 return {}, text 

41 

42 # Find the closing delimiter: the next line containing exactly '---'. 

43 closing_index = next((i for i, line in enumerate(lines[1:], start=1) if line.rstrip() == "---"), None) 

44 if closing_index is None: 

45 raise ValueError("Skill frontmatter is opened with '---' but never closed with a matching '---' line.") 

46 

47 frontmatter_block = "\n".join(lines[1:closing_index]) 

48 body = "\n".join(lines[closing_index + 1 :]) 

49 try: 

50 loaded = yaml.safe_load(frontmatter_block) or {} 

51 except yaml.YAMLError as e: 

52 raise ValueError(f"Skill frontmatter is not valid YAML: {e}") from e 

53 if not isinstance(loaded, dict): 

54 raise ValueError("Skill frontmatter must be a YAML mapping.") # noqa: TRY004 

55 return loaded, body.lstrip("\n") 

56 

57 

58class FileSystemSkillStore: 

59 """ 

60 SkillStore backed by a directory of skill sub-directories on the local filesystem. 

61 

62 Expected layout: 

63 

64 ``` 

65 skills/ 

66 pdf-forms/ 

67 SKILL.md # frontmatter (name, description) + markdown instructions 

68 reference/forms.md # optional bundled file 

69 ``` 

70 

71 The skill catalog is built by reading the frontmatter of each `SKILL.md` on `warm_up`; bodies and bundled files 

72 are read lazily when the agent calls the corresponding tool. 

73 """ 

74 

75 def __init__(self, skills_dir: str | Path | Secret) -> None: 

76 """ 

77 Initialize the store with the root directory to scan. 

78 

79 No filesystem access happens here; the directory is scanned lazily on first use (see `warm_up`), so the store 

80 can be constructed cheaply. 

81 

82 :param skills_dir: Root directory that contains one sub-directory per skill. Can also be a `Secret` 

83 (e.g. `Secret.from_env_var("SKILLS_DIR")`) to source the path from an environment variable rather than 

84 hard-coding it. 

85 """ 

86 self.skills_dir = skills_dir 

87 # Public metadata catalog returned by `list_skills`, populated on warm_up. 

88 self._skills: dict[str, SkillInfo] = {} 

89 # Private locator: maps each skill name to its directory, used to read content lazily. 

90 self._skill_dirs: dict[str, Path] = {} 

91 self._is_warmed_up = False 

92 

93 def _resolve_skills_dir(self) -> Path: 

94 """ 

95 Resolve `skills_dir` to a concrete filesystem path, reading the `Secret`'s value if one was passed. 

96 

97 :returns: The root directory that contains one sub-directory per skill. 

98 """ 

99 if isinstance(self.skills_dir, Secret): 

100 resolved = self.skills_dir.resolve_value() 

101 if resolved is None: 

102 raise ValueError("The 'skills_dir' secret did not resolve to a value.") 

103 return Path(resolved) 

104 return Path(self.skills_dir) 

105 

106 def warm_up(self) -> None: 

107 """ 

108 Scan `skills_dir` and build the skill catalog by reading each skill's `SKILL.md` frontmatter. 

109 

110 Only the frontmatter is read here; bodies and bundled files are read lazily when the corresponding method is 

111 called. Idempotent: repeated calls after the first are no-ops. 

112 

113 :raises ValueError: If `skills_dir` does not exist, is not a directory, a skill's frontmatter is missing, 

114 malformed, or missing a required field, or two skills share the same name. 

115 """ 

116 if self._is_warmed_up: 

117 return 

118 skills_dir = self._resolve_skills_dir() 

119 if not skills_dir.is_dir(): 

120 raise ValueError(f"Skills directory '{skills_dir}' does not exist or is not a directory.") 

121 

122 # Build into locals and swap at the end: if the scan fails halfway, no partial state is left behind (a retry 

123 # after fixing the offending skill starts clean), and concurrent callers only ever observe either an empty or 

124 # a complete catalog. 

125 skills: dict[str, SkillInfo] = {} 

126 skill_dirs: dict[str, Path] = {} 

127 for skill_file in sorted(skills_dir.glob(f"*/{SKILL_FILE_NAME}")): 

128 skill_dir = skill_file.parent 

129 frontmatter, _ = _parse_frontmatter(skill_file.read_text(encoding="utf-8")) 

130 

131 name = frontmatter.get("name", skill_dir.name) 

132 description = frontmatter.get("description") 

133 if not description: 

134 raise ValueError(f"Skill '{name}' ({skill_file}) is missing a 'description' in its frontmatter.") 

135 if name in skills: 

136 raise ValueError(f"Duplicate skill name '{name}' found in '{skills_dir}'.") 

137 

138 skills[name] = SkillInfo(name=name, description=description) 

139 skill_dirs[name] = skill_dir 

140 

141 self._skills = skills 

142 self._skill_dirs = skill_dirs 

143 self._is_warmed_up = True 

144 

145 def _skill_dir(self, name: str) -> Path: 

146 """ 

147 Return the directory of the named skill, warming up the store first if needed. 

148 

149 :param name: Skill name as returned by `list_skills`. 

150 :returns: The skill's directory. 

151 :raises KeyError: If no skill with `name` exists. 

152 """ 

153 self.warm_up() 

154 try: 

155 return self._skill_dirs[name] 

156 except KeyError: 

157 available = ", ".join(self._skills) or "none" 

158 # We suppress the original error since we are replacing it with a more informative error message 

159 raise KeyError(f"Unknown skill '{name}'. Available skills: {available}.") from None 

160 

161 def _readable_files_hint(self, name: str) -> str: 

162 """ 

163 Return a human-readable list of the files that can be read from the named skill. 

164 

165 Used to make `read_skill_file` errors actionable by telling the caller which paths are valid. 

166 

167 :param name: Skill name as returned by `list_skills`. 

168 :returns: Comma-separated relative paths, or `"none"` if the skill bundles no readable files. 

169 """ 

170 return ", ".join(self._list_skill_files(name)) or "none" 

171 

172 def list_skills(self) -> dict[str, SkillInfo]: 

173 """ 

174 Return all skills discovered on disk, warming up the store first if needed. 

175 

176 :returns: Mapping of skill name to its metadata. 

177 :raises ValueError: If the skills directory is invalid or a skill's frontmatter is malformed. 

178 """ 

179 self.warm_up() 

180 # We return a copy to prevent callers from mutating our internal state. 

181 return dict(self._skills) 

182 

183 def load_skill(self, name: str) -> tuple[str, list[str]]: 

184 """ 

185 Read the named skill's instruction body and the manifest of its bundled files. 

186 

187 :param name: Skill name as returned by `list_skills`. 

188 :returns: A tuple of (markdown body of the skill's `SKILL.md` with frontmatter stripped, sorted list of 

189 POSIX-style paths relative to the skill directory for any bundled files). The file list is empty when 

190 the skill bundles no extras. 

191 :raises KeyError: If no skill with `name` exists. 

192 """ 

193 _, body = _parse_frontmatter((self._skill_dir(name) / SKILL_FILE_NAME).read_text(encoding="utf-8")) 

194 return body, self._list_skill_files(name) 

195 

196 def _list_skill_files(self, name: str) -> list[str]: 

197 """ 

198 Return the relative paths of all files bundled with the named skill, excluding its `SKILL.md`. 

199 

200 :param name: Skill name as returned by `list_skills`. 

201 :returns: Sorted list of POSIX-style paths relative to the skill directory. Empty when there are none. 

202 :raises KeyError: If no skill with `name` exists. 

203 """ 

204 skill_dir = self._skill_dir(name).resolve() 

205 return sorted( 

206 p.relative_to(skill_dir).as_posix() 

207 for p in skill_dir.rglob("*") 

208 if p.is_file() and p.name != SKILL_FILE_NAME and p.resolve().is_relative_to(skill_dir) 

209 ) 

210 

211 def read_skill_file(self, name: str, path: str) -> str | ImageContent | FileContent: 

212 """ 

213 Read a file bundled with the named skill, preventing path traversal outside the skill directory. 

214 

215 The return type depends on the file: text files are returned as a `str`, image files (PNG, JPEG, ...) as an 

216 `ImageContent`, and PDFs as a `FileContent`, so a multimodal agent can pass them straight to the model. 

217 

218 :param name: Skill name as returned by `list_skills`. 

219 :param path: Path of the file relative to the skill directory (e.g. `"reference/forms.md"`). 

220 :returns: The file's text content (`str`), an `ImageContent` for images, or a `FileContent` for PDFs. 

221 :raises KeyError: If no skill with `name` exists. 

222 :raises PermissionError: If `path` resolves outside the skill's directory (path-traversal attempt). The 

223 message lists the readable files so the caller can retry with a valid path. 

224 :raises FileNotFoundError: If the file does not exist within the skill. The message lists the readable 

225 files so the caller can retry with a valid path. 

226 :raises ValueError: If the file is binary but not a supported image or PDF (i.e. not UTF-8 text either). 

227 """ 

228 skill_dir = self._skill_dir(name).resolve() 

229 target = (skill_dir / path).resolve() 

230 if skill_dir != target and skill_dir not in target.parents: 

231 raise PermissionError( 

232 f"Cannot read '{path}' from skill '{name}': the path resolves outside the skill directory. " 

233 f"Use a path relative to the skill root. Readable files: {self._readable_files_hint(name)}." 

234 ) 

235 if not target.is_file(): 

236 raise FileNotFoundError( 

237 f"File '{path}' not found in skill '{name}'. Readable files: {self._readable_files_hint(name)}." 

238 ) 

239 

240 # Check file types (PDF) before images: IMAGE_MIME_TYPES includes "application/pdf" (ImageContent can 

241 # rasterize PDFs), but a skill's bundled PDF should reach the model as a FileContent, not an image. 

242 mime_type, _ = mimetypes.guess_type(target.as_posix()) 

243 if mime_type in _SUPPORTED_FILE_MIME_TYPES: 

244 encoded = base64.b64encode(target.read_bytes()).decode("utf-8") 

245 return FileContent(base64_data=encoded, mime_type=mime_type, filename=target.name, validation=False) 

246 if mime_type in IMAGE_MIME_TYPES: 

247 encoded = base64.b64encode(target.read_bytes()).decode("utf-8") 

248 return ImageContent(base64_image=encoded, mime_type=mime_type, validation=False) 

249 try: 

250 return target.read_text(encoding="utf-8") 

251 except UnicodeDecodeError as e: 

252 raise ValueError( 

253 f"File '{path}' in skill '{name}' is not a readable asset. Only UTF-8 text, images, and PDFs " 

254 f"are supported." 

255 ) from e 

256 

257 def to_dict(self) -> dict[str, Any]: 

258 """ 

259 Serialize this store to a dictionary for use with `from_dict`. 

260 

261 :returns: Dictionary representation of the store. 

262 """ 

263 skills_dir = self.skills_dir if isinstance(self.skills_dir, Secret) else str(self.skills_dir) 

264 return default_to_dict(self, skills_dir=skills_dir) 

265 

266 @classmethod 

267 def from_dict(cls, data: dict[str, Any]) -> "FileSystemSkillStore": 

268 """ 

269 Deserialize a `FileSystemSkillStore` from its dictionary representation. 

270 

271 :param data: Dictionary representation of the store, as produced by `to_dict`. 

272 :returns: A new `FileSystemSkillStore` instance. 

273 """ 

274 return default_from_dict(cls, data)