deepagents 0.3.1__py3-none-any.whl → 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ """Skills middleware for loading and exposing agent skills to the system prompt.
2
+
3
+ This module implements Anthropic's agent skills pattern with progressive disclosure,
4
+ loading skills from backend storage via configurable sources.
5
+
6
+ ## Architecture
7
+
8
+ Skills are loaded from one or more **sources** - paths in a backend where skills are
9
+ organized. Sources are loaded in order, with later sources overriding earlier ones
10
+ when skills have the same name (last one wins). This enables layering: base -> user
11
+ -> project -> team skills.
12
+
13
+ The middleware uses backend APIs exclusively (no direct filesystem access), making it
14
+ portable across different storage backends (filesystem, state, remote storage, etc.).
15
+
16
+ For StateBackend (ephemeral/in-memory), use a factory function:
17
+ ```python
18
+ SkillsMiddleware(backend=lambda rt: StateBackend(rt), ...)
19
+ ```
20
+
21
+ ## Skill Structure
22
+
23
+ Each skill is a directory containing a SKILL.md file with YAML frontmatter:
24
+
25
+ ```
26
+ /skills/user/web-research/
27
+ ├── SKILL.md # Required: YAML frontmatter + markdown instructions
28
+ └── helper.py # Optional: supporting files
29
+ ```
30
+
31
+ SKILL.md format:
32
+ ```markdown
33
+ ---
34
+ name: web-research
35
+ description: Structured approach to conducting thorough web research
36
+ license: MIT
37
+ ---
38
+
39
+ # Web Research Skill
40
+
41
+ ## When to Use
42
+ - User asks you to research a topic
43
+ ...
44
+ ```
45
+
46
+ ## Skill Metadata (SkillMetadata)
47
+
48
+ Parsed from YAML frontmatter per Agent Skills specification:
49
+ - `name`: Skill identifier (max 64 chars, lowercase alphanumeric and hyphens)
50
+ - `description`: What the skill does (max 1024 chars)
51
+ - `path`: Backend path to the SKILL.md file
52
+ - Optional: `license`, `compatibility`, `metadata`, `allowed_tools`
53
+
54
+ ## Sources
55
+
56
+ Sources are simply paths to skill directories in the backend. The source name is
57
+ derived from the last component of the path (e.g., "/skills/user/" -> "user").
58
+
59
+ Example sources:
60
+ ```python
61
+ [
62
+ "/skills/user/",
63
+ "/skills/project/"
64
+ ]
65
+ ```
66
+
67
+ ## Path Conventions
68
+
69
+ All paths use POSIX conventions (forward slashes) via `PurePosixPath`:
70
+ - Backend paths: "/skills/user/web-research/SKILL.md"
71
+ - Virtual, platform-independent
72
+ - Backends handle platform-specific conversions as needed
73
+
74
+ ## Usage
75
+
76
+ ```python
77
+ from deepagents.backends.state import StateBackend
78
+ from deepagents.middleware.skills import SkillsMiddleware
79
+
80
+ middleware = SkillsMiddleware(
81
+ backend=my_backend,
82
+ sources=[
83
+ "/skills/base/",
84
+ "/skills/user/",
85
+ "/skills/project/",
86
+ ],
87
+ )
88
+ ```
89
+ """
90
+
91
+ from __future__ import annotations
92
+
93
+ import logging
94
+ import re
95
+ from pathlib import PurePosixPath
96
+ from typing import TYPE_CHECKING, Annotated
97
+
98
+ import yaml
99
+ from langchain.agents.middleware.types import PrivateStateAttr
100
+
101
+ if TYPE_CHECKING:
102
+ from deepagents.backends.protocol import BACKEND_TYPES, BackendProtocol
103
+
104
+ from collections.abc import Awaitable, Callable
105
+ from typing import NotRequired, TypedDict
106
+
107
+ from langchain.agents.middleware.types import (
108
+ AgentMiddleware,
109
+ AgentState,
110
+ ModelRequest,
111
+ ModelResponse,
112
+ )
113
+ from langchain_core.runnables import RunnableConfig
114
+ from langgraph.prebuilt import ToolRuntime
115
+ from langgraph.runtime import Runtime
116
+
117
+ logger = logging.getLogger(__name__)
118
+
119
+ # Security: Maximum size for SKILL.md files to prevent DoS attacks (10MB)
120
+ MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
121
+
122
+ # Agent Skills specification constraints (https://agentskills.io/specification)
123
+ MAX_SKILL_NAME_LENGTH = 64
124
+ MAX_SKILL_DESCRIPTION_LENGTH = 1024
125
+
126
+
127
+ class SkillMetadata(TypedDict):
128
+ """Metadata for a skill per Agent Skills specification (https://agentskills.io/specification)."""
129
+
130
+ name: str
131
+ """Skill identifier (max 64 chars, lowercase alphanumeric and hyphens)."""
132
+
133
+ description: str
134
+ """What the skill does (max 1024 chars)."""
135
+
136
+ path: str
137
+ """Path to the SKILL.md file."""
138
+
139
+ license: str | None
140
+ """License name or reference to bundled license file."""
141
+
142
+ compatibility: str | None
143
+ """Environment requirements (max 500 chars)."""
144
+
145
+ metadata: dict[str, str]
146
+ """Arbitrary key-value mapping for additional metadata."""
147
+
148
+ allowed_tools: list[str]
149
+ """Space-delimited list of pre-approved tools. (Experimental)"""
150
+
151
+
152
+ class SkillsState(AgentState):
153
+ """State for the skills middleware."""
154
+
155
+ skills_metadata: NotRequired[Annotated[list[SkillMetadata], PrivateStateAttr]]
156
+ """List of loaded skill metadata from all configured sources."""
157
+
158
+
159
+ class SkillsStateUpdate(TypedDict):
160
+ """State update for the skills middleware."""
161
+
162
+ skills_metadata: list[SkillMetadata]
163
+ """List of loaded skill metadata to merge into state."""
164
+
165
+
166
+ def _validate_skill_name(name: str, directory_name: str) -> tuple[bool, str]:
167
+ """Validate skill name per Agent Skills specification.
168
+
169
+ Requirements per spec:
170
+ - Max 64 characters
171
+ - Lowercase alphanumeric and hyphens only (a-z, 0-9, -)
172
+ - Cannot start or end with hyphen
173
+ - No consecutive hyphens
174
+ - Must match parent directory name
175
+
176
+ Args:
177
+ name: Skill name from YAML frontmatter
178
+ directory_name: Parent directory name
179
+
180
+ Returns:
181
+ (is_valid, error_message) tuple. Error message is empty if valid.
182
+ """
183
+ if not name:
184
+ return False, "name is required"
185
+ if len(name) > MAX_SKILL_NAME_LENGTH:
186
+ return False, "name exceeds 64 characters"
187
+ # Pattern: lowercase alphanumeric, single hyphens between segments, no start/end hyphen
188
+ if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
189
+ return False, "name must be lowercase alphanumeric with single hyphens only"
190
+ if name != directory_name:
191
+ return False, f"name '{name}' must match directory name '{directory_name}'"
192
+ return True, ""
193
+
194
+
195
+ def _parse_skill_metadata(
196
+ content: str,
197
+ skill_path: str,
198
+ directory_name: str,
199
+ ) -> SkillMetadata | None:
200
+ """Parse YAML frontmatter from SKILL.md content.
201
+
202
+ Extracts metadata per Agent Skills specification from YAML frontmatter delimited
203
+ by --- markers at the start of the content.
204
+
205
+ Args:
206
+ content: Content of the SKILL.md file
207
+ skill_path: Path to the SKILL.md file (for error messages and metadata)
208
+ directory_name: Name of the parent directory containing the skill
209
+
210
+ Returns:
211
+ SkillMetadata if parsing succeeds, None if parsing fails or validation errors occur
212
+ """
213
+ if len(content) > MAX_SKILL_FILE_SIZE:
214
+ logger.warning("Skipping %s: content too large (%d bytes)", skill_path, len(content))
215
+ return None
216
+
217
+ # Match YAML frontmatter between --- delimiters
218
+ frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
219
+ match = re.match(frontmatter_pattern, content, re.DOTALL)
220
+
221
+ if not match:
222
+ logger.warning("Skipping %s: no valid YAML frontmatter found", skill_path)
223
+ return None
224
+
225
+ frontmatter_str = match.group(1)
226
+
227
+ # Parse YAML using safe_load for proper nested structure support
228
+ try:
229
+ frontmatter_data = yaml.safe_load(frontmatter_str)
230
+ except yaml.YAMLError as e:
231
+ logger.warning("Invalid YAML in %s: %s", skill_path, e)
232
+ return None
233
+
234
+ if not isinstance(frontmatter_data, dict):
235
+ logger.warning("Skipping %s: frontmatter is not a mapping", skill_path)
236
+ return None
237
+
238
+ # Validate required fields
239
+ name = frontmatter_data.get("name")
240
+ description = frontmatter_data.get("description")
241
+
242
+ if not name or not description:
243
+ logger.warning("Skipping %s: missing required 'name' or 'description'", skill_path)
244
+ return None
245
+
246
+ # Validate name format per spec (warn but continue loading for backwards compatibility)
247
+ is_valid, error = _validate_skill_name(str(name), directory_name)
248
+ if not is_valid:
249
+ logger.warning(
250
+ "Skill '%s' in %s does not follow Agent Skills specification: %s. Consider renaming for spec compliance.",
251
+ name,
252
+ skill_path,
253
+ error,
254
+ )
255
+
256
+ # Validate description length per spec (max 1024 chars)
257
+ description_str = str(description).strip()
258
+ if len(description_str) > MAX_SKILL_DESCRIPTION_LENGTH:
259
+ logger.warning(
260
+ "Description exceeds %d characters in %s, truncating",
261
+ MAX_SKILL_DESCRIPTION_LENGTH,
262
+ skill_path,
263
+ )
264
+ description_str = description_str[:MAX_SKILL_DESCRIPTION_LENGTH]
265
+
266
+ if frontmatter_data.get("allowed-tools"):
267
+ allowed_tools = frontmatter_data.get("allowed-tools").split(" ")
268
+ else:
269
+ allowed_tools = []
270
+
271
+ return SkillMetadata(
272
+ name=str(name),
273
+ description=description_str,
274
+ path=skill_path,
275
+ metadata=frontmatter_data.get("metadata", {}),
276
+ license=frontmatter_data.get("license", "").strip() or None,
277
+ compatibility=frontmatter_data.get("compatibility", "").strip() or None,
278
+ allowed_tools=allowed_tools,
279
+ )
280
+
281
+
282
+ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetadata]:
283
+ """List all skills from a backend source.
284
+
285
+ Scans backend for subdirectories containing SKILL.md files, downloads their content,
286
+ parses YAML frontmatter, and returns skill metadata.
287
+
288
+ Expected structure:
289
+ source_path/
290
+ ├── skill-name/
291
+ │ ├── SKILL.md # Required
292
+ │ └── helper.py # Optional
293
+
294
+ Args:
295
+ backend: Backend instance to use for file operations
296
+ source_path: Path to the skills directory in the backend
297
+
298
+ Returns:
299
+ List of skill metadata from successfully parsed SKILL.md files
300
+ """
301
+ base_path = source_path
302
+
303
+ skills: list[SkillMetadata] = []
304
+ items = backend.ls_info(base_path)
305
+ # Find all skill directories (directories containing SKILL.md)
306
+ skill_dirs = []
307
+ for item in items:
308
+ if not item.get("is_dir"):
309
+ continue
310
+ skill_dirs.append(item["path"])
311
+
312
+ if not skill_dirs:
313
+ return []
314
+
315
+ # For each skill directory, check if SKILL.md exists and download it
316
+ skill_md_paths = []
317
+ for skill_dir_path in skill_dirs:
318
+ # Construct SKILL.md path using PurePosixPath for safe, standardized path operations
319
+ skill_dir = PurePosixPath(skill_dir_path)
320
+ skill_md_path = str(skill_dir / "SKILL.md")
321
+ skill_md_paths.append((skill_dir_path, skill_md_path))
322
+
323
+ paths_to_download = [skill_md_path for _, skill_md_path in skill_md_paths]
324
+ responses = backend.download_files(paths_to_download)
325
+
326
+ # Parse each downloaded SKILL.md
327
+ for (skill_dir_path, skill_md_path), response in zip(skill_md_paths, responses, strict=True):
328
+ if response.error:
329
+ # Skill doesn't have a SKILL.md, skip it
330
+ continue
331
+
332
+ if response.content is None:
333
+ logger.warning("Downloaded skill file %s has no content", skill_md_path)
334
+ continue
335
+
336
+ try:
337
+ content = response.content.decode("utf-8")
338
+ except UnicodeDecodeError as e:
339
+ logger.warning("Error decoding %s: %s", skill_md_path, e)
340
+ continue
341
+
342
+ # Extract directory name from path using PurePosixPath
343
+ directory_name = PurePosixPath(skill_dir_path).name
344
+
345
+ # Parse metadata
346
+ skill_metadata = _parse_skill_metadata(
347
+ content=content,
348
+ skill_path=skill_md_path,
349
+ directory_name=directory_name,
350
+ )
351
+ if skill_metadata:
352
+ skills.append(skill_metadata)
353
+
354
+ return skills
355
+
356
+
357
+ async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetadata]:
358
+ """List all skills from a backend source (async version).
359
+
360
+ Scans backend for subdirectories containing SKILL.md files, downloads their content,
361
+ parses YAML frontmatter, and returns skill metadata.
362
+
363
+ Expected structure:
364
+ source_path/
365
+ ├── skill-name/
366
+ │ ├── SKILL.md # Required
367
+ │ └── helper.py # Optional
368
+
369
+ Args:
370
+ backend: Backend instance to use for file operations
371
+ source_path: Path to the skills directory in the backend
372
+
373
+ Returns:
374
+ List of skill metadata from successfully parsed SKILL.md files
375
+ """
376
+ base_path = source_path
377
+
378
+ skills: list[SkillMetadata] = []
379
+ items = await backend.als_info(base_path)
380
+ # Find all skill directories (directories containing SKILL.md)
381
+ skill_dirs = []
382
+ for item in items:
383
+ if not item.get("is_dir"):
384
+ continue
385
+ skill_dirs.append(item["path"])
386
+
387
+ if not skill_dirs:
388
+ return []
389
+
390
+ # For each skill directory, check if SKILL.md exists and download it
391
+ skill_md_paths = []
392
+ for skill_dir_path in skill_dirs:
393
+ # Construct SKILL.md path using PurePosixPath for safe, standardized path operations
394
+ skill_dir = PurePosixPath(skill_dir_path)
395
+ skill_md_path = str(skill_dir / "SKILL.md")
396
+ skill_md_paths.append((skill_dir_path, skill_md_path))
397
+
398
+ paths_to_download = [skill_md_path for _, skill_md_path in skill_md_paths]
399
+ responses = await backend.adownload_files(paths_to_download)
400
+
401
+ # Parse each downloaded SKILL.md
402
+ for (skill_dir_path, skill_md_path), response in zip(skill_md_paths, responses, strict=True):
403
+ if response.error:
404
+ # Skill doesn't have a SKILL.md, skip it
405
+ continue
406
+
407
+ if response.content is None:
408
+ logger.warning("Downloaded skill file %s has no content", skill_md_path)
409
+ continue
410
+
411
+ try:
412
+ content = response.content.decode("utf-8")
413
+ except UnicodeDecodeError as e:
414
+ logger.warning("Error decoding %s: %s", skill_md_path, e)
415
+ continue
416
+
417
+ # Extract directory name from path using PurePosixPath
418
+ directory_name = PurePosixPath(skill_dir_path).name
419
+
420
+ # Parse metadata
421
+ skill_metadata = _parse_skill_metadata(
422
+ content=content,
423
+ skill_path=skill_md_path,
424
+ directory_name=directory_name,
425
+ )
426
+ if skill_metadata:
427
+ skills.append(skill_metadata)
428
+
429
+ return skills
430
+
431
+
432
+ SKILLS_SYSTEM_PROMPT = """
433
+
434
+ ## Skills System
435
+
436
+ You have access to a skills library that provides specialized capabilities and domain knowledge.
437
+
438
+ {skills_locations}
439
+
440
+ **Available Skills:**
441
+
442
+ {skills_list}
443
+
444
+ **How to Use Skills (Progressive Disclosure):**
445
+
446
+ Skills follow a **progressive disclosure** pattern - you see their name and description above, but only read full instructions when needed:
447
+
448
+ 1. **Recognize when a skill applies**: Check if the user's task matches a skill's description
449
+ 2. **Read the skill's full instructions**: Use the path shown in the skill list above
450
+ 3. **Follow the skill's instructions**: SKILL.md contains step-by-step workflows, best practices, and examples
451
+ 4. **Access supporting files**: Skills may include helper scripts, configs, or reference docs - use absolute paths
452
+
453
+ **When to Use Skills:**
454
+ - User's request matches a skill's domain (e.g., "research X" -> web-research skill)
455
+ - You need specialized knowledge or structured workflows
456
+ - A skill provides proven patterns for complex tasks
457
+
458
+ **Executing Skill Scripts:**
459
+ Skills may contain Python scripts or other executable files. Always use absolute paths from the skill list.
460
+
461
+ **Example Workflow:**
462
+
463
+ User: "Can you research the latest developments in quantum computing?"
464
+
465
+ 1. Check available skills -> See "web-research" skill with its path
466
+ 2. Read the skill using the path shown
467
+ 3. Follow the skill's research workflow (search -> organize -> synthesize)
468
+ 4. Use any helper scripts with absolute paths
469
+
470
+ Remember: Skills make you more capable and consistent. When in doubt, check if a skill exists for the task!
471
+ """
472
+
473
+
474
+ class SkillsMiddleware(AgentMiddleware):
475
+ """Middleware for loading and exposing agent skills to the system prompt.
476
+
477
+ Loads skills from backend sources and injects them into the system prompt
478
+ using progressive disclosure (metadata first, full content on demand).
479
+
480
+ Skills are loaded in source order with later sources overriding earlier ones.
481
+
482
+ Example:
483
+ ```python
484
+ from deepagents.backends.filesystem import FilesystemBackend
485
+
486
+ backend = FilesystemBackend(root_dir="/path/to/skills")
487
+ middleware = SkillsMiddleware(
488
+ backend=backend,
489
+ sources=[
490
+ "/path/to/skills/user/",
491
+ "/path/to/skills/project/",
492
+ ],
493
+ )
494
+ ```
495
+
496
+ Args:
497
+ backend: Backend instance for file operations
498
+ sources: List of skill source paths. Source names are derived from the last path component.
499
+ """
500
+
501
+ state_schema = SkillsState
502
+
503
+ def __init__(self, *, backend: BACKEND_TYPES, sources: list[str]) -> None:
504
+ """Initialize the skills middleware.
505
+
506
+ Args:
507
+ backend: Backend instance or factory function that takes runtime and returns a backend.
508
+ Use a factory for StateBackend: `lambda rt: StateBackend(rt)`
509
+ sources: List of skill source paths (e.g., ["/skills/user/", "/skills/project/"]).
510
+ """
511
+ self._backend = backend
512
+ self.sources = sources
513
+ self.system_prompt_template = SKILLS_SYSTEM_PROMPT
514
+
515
+ def _get_backend(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> BackendProtocol:
516
+ """Resolve backend from instance or factory.
517
+
518
+ Args:
519
+ state: Current agent state.
520
+ runtime: Runtime context for factory functions.
521
+ config: Runnable config to pass to backend factory.
522
+
523
+ Returns:
524
+ Resolved backend instance
525
+ """
526
+ if callable(self._backend):
527
+ # Construct an artificial tool runtime to resolve backend factory
528
+ tool_runtime = ToolRuntime(
529
+ state=state,
530
+ context=runtime.context,
531
+ stream_writer=runtime.stream_writer,
532
+ store=runtime.store,
533
+ config=config,
534
+ tool_call_id=None,
535
+ )
536
+ backend = self._backend(tool_runtime)
537
+ if backend is None:
538
+ raise AssertionError("SkillsMiddleware requires a valid backend instance")
539
+ return backend
540
+
541
+ return self._backend
542
+
543
+ def _format_skills_locations(self) -> str:
544
+ """Format skills locations for display in system prompt."""
545
+ locations = []
546
+ for i, source_path in enumerate(self.sources):
547
+ name = PurePosixPath(source_path.rstrip("/")).name.capitalize()
548
+ suffix = " (higher priority)" if i == len(self.sources) - 1 else ""
549
+ locations.append(f"**{name} Skills**: `{source_path}`{suffix}")
550
+ return "\n".join(locations)
551
+
552
+ def _format_skills_list(self, skills: list[SkillMetadata]) -> str:
553
+ """Format skills metadata for display in system prompt."""
554
+ if not skills:
555
+ paths = [f"{source_path}" for source_path in self.sources]
556
+ return f"(No skills available yet. You can create skills in {' or '.join(paths)})"
557
+
558
+ lines = []
559
+ for skill in skills:
560
+ lines.append(f"- **{skill['name']}**: {skill['description']}")
561
+ lines.append(f" -> Read `{skill['path']}` for full instructions")
562
+
563
+ return "\n".join(lines)
564
+
565
+ def modify_request(self, request: ModelRequest) -> ModelRequest:
566
+ """Inject skills documentation into a model request's system prompt.
567
+
568
+ Args:
569
+ request: Model request to modify
570
+
571
+ Returns:
572
+ New model request with skills documentation injected into system prompt
573
+ """
574
+ skills_metadata = request.state.get("skills_metadata", [])
575
+ skills_locations = self._format_skills_locations()
576
+ skills_list = self._format_skills_list(skills_metadata)
577
+
578
+ skills_section = self.system_prompt_template.format(
579
+ skills_locations=skills_locations,
580
+ skills_list=skills_list,
581
+ )
582
+
583
+ if request.system_prompt:
584
+ system_prompt = request.system_prompt + "\n\n" + skills_section
585
+ else:
586
+ system_prompt = skills_section
587
+
588
+ return request.override(system_prompt=system_prompt)
589
+
590
+ def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None:
591
+ """Load skills metadata before agent execution (synchronous).
592
+
593
+ Runs before each agent interaction to discover available skills from all
594
+ configured sources. Re-loads on every call to capture any changes.
595
+
596
+ Skills are loaded in source order with later sources overriding
597
+ earlier ones if they contain skills with the same name (last one wins).
598
+
599
+ Args:
600
+ state: Current agent state.
601
+ runtime: Runtime context.
602
+ config: Runnable config.
603
+
604
+ Returns:
605
+ State update with skills_metadata populated, or None if already present
606
+ """
607
+ # Skip if skills_metadata is already present in state (even if empty)
608
+ if "skills_metadata" in state:
609
+ return None
610
+
611
+ # Resolve backend (supports both direct instances and factory functions)
612
+ backend = self._get_backend(state, runtime, config)
613
+ all_skills: dict[str, SkillMetadata] = {}
614
+
615
+ # Load skills from each source in order
616
+ # Later sources override earlier ones (last one wins)
617
+ for source_path in self.sources:
618
+ source_skills = _list_skills(backend, source_path)
619
+ for skill in source_skills:
620
+ all_skills[skill["name"]] = skill
621
+
622
+ skills = list(all_skills.values())
623
+ return SkillsStateUpdate(skills_metadata=skills)
624
+
625
+ async def abefore_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None:
626
+ """Load skills metadata before agent execution (async).
627
+
628
+ Runs before each agent interaction to discover available skills from all
629
+ configured sources. Re-loads on every call to capture any changes.
630
+
631
+ Skills are loaded in source order with later sources overriding
632
+ earlier ones if they contain skills with the same name (last one wins).
633
+
634
+ Args:
635
+ state: Current agent state.
636
+ runtime: Runtime context.
637
+ config: Runnable config.
638
+
639
+ Returns:
640
+ State update with skills_metadata populated, or None if already present
641
+ """
642
+ # Skip if skills_metadata is already present in state (even if empty)
643
+ if "skills_metadata" in state:
644
+ return None
645
+
646
+ # Resolve backend (supports both direct instances and factory functions)
647
+ backend = self._get_backend(state, runtime, config)
648
+ all_skills: dict[str, SkillMetadata] = {}
649
+
650
+ # Load skills from each source in order
651
+ # Later sources override earlier ones (last one wins)
652
+ for source_path in self.sources:
653
+ source_skills = await _alist_skills(backend, source_path)
654
+ for skill in source_skills:
655
+ all_skills[skill["name"]] = skill
656
+
657
+ skills = list(all_skills.values())
658
+ return SkillsStateUpdate(skills_metadata=skills)
659
+
660
+ def wrap_model_call(
661
+ self,
662
+ request: ModelRequest,
663
+ handler: Callable[[ModelRequest], ModelResponse],
664
+ ) -> ModelResponse:
665
+ """Inject skills documentation into the system prompt.
666
+
667
+ Args:
668
+ request: Model request being processed
669
+ handler: Handler function to call with modified request
670
+
671
+ Returns:
672
+ Model response from handler
673
+ """
674
+ modified_request = self.modify_request(request)
675
+ return handler(modified_request)
676
+
677
+ async def awrap_model_call(
678
+ self,
679
+ request: ModelRequest,
680
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
681
+ ) -> ModelResponse:
682
+ """Inject skills documentation into the system prompt (async version).
683
+
684
+ Args:
685
+ request: Model request being processed
686
+ handler: Async handler function to call with modified request
687
+
688
+ Returns:
689
+ Model response from handler
690
+ """
691
+ modified_request = self.modify_request(request)
692
+ return await handler(modified_request)
693
+
694
+
695
+ __all__ = ["SkillMetadata", "SkillsMiddleware"]