stirrup 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (35) hide show
  1. {stirrup-0.1.1 → stirrup-0.1.2}/PKG-INFO +36 -16
  2. {stirrup-0.1.1 → stirrup-0.1.2}/README.md +34 -14
  3. {stirrup-0.1.1 → stirrup-0.1.2}/pyproject.toml +2 -2
  4. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/core/agent.py +26 -0
  5. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/core/models.py +4 -0
  6. stirrup-0.1.2/src/stirrup/skills/__init__.py +24 -0
  7. stirrup-0.1.2/src/stirrup/skills/skills.py +145 -0
  8. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/code_backends/docker.py +16 -4
  9. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/code_backends/e2b.py +7 -2
  10. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/code_backends/local.py +14 -2
  11. {stirrup-0.1.1 → stirrup-0.1.2}/LICENSE +0 -0
  12. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/__init__.py +0 -0
  13. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/clients/__init__.py +0 -0
  14. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/clients/chat_completions_client.py +0 -0
  15. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/clients/litellm_client.py +0 -0
  16. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/clients/utils.py +0 -0
  17. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/constants.py +0 -0
  18. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/core/__init__.py +0 -0
  19. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/core/exceptions.py +0 -0
  20. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/prompts/__init__.py +0 -0
  21. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/prompts/base_system_prompt.txt +0 -0
  22. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/prompts/message_summarizer.txt +0 -0
  23. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/prompts/message_summarizer_bridge.txt +0 -0
  24. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/py.typed +0 -0
  25. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/__init__.py +0 -0
  26. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/calculator.py +0 -0
  27. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/code_backends/__init__.py +0 -0
  28. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/code_backends/base.py +0 -0
  29. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/finish.py +0 -0
  30. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/mcp.py +0 -0
  31. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/view_image.py +0 -0
  32. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/tools/web.py +0 -0
  33. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/utils/__init__.py +0 -0
  34. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/utils/logging.py +0 -0
  35. {stirrup-0.1.1 → stirrup-0.1.2}/src/stirrup/utils/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: stirrup
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: The lightweight foundation for building agents
5
5
  Keywords: ai,agent,llm,openai,anthropic,tools,framework
6
6
  Author: Artificial Analysis, Inc.
@@ -54,7 +54,7 @@ Requires-Dist: e2b-code-interpreter>=2.3.0 ; extra == 'e2b'
54
54
  Requires-Dist: litellm>=1.79.3 ; extra == 'litellm'
55
55
  Requires-Dist: mcp>=1.9.0 ; extra == 'mcp'
56
56
  Requires-Python: >=3.12
57
- Project-URL: Documentation, https://artificialanalysis.github.io/Stirrup
57
+ Project-URL: Documentation, https://stirrup.artificialanalysis.ai
58
58
  Project-URL: Homepage, https://github.com/ArtificialAnalysis/Stirrup
59
59
  Project-URL: Repository, https://github.com/ArtificialAnalysis/Stirrup
60
60
  Provides-Extra: all
@@ -65,7 +65,7 @@ Provides-Extra: mcp
65
65
  Description-Content-Type: text/markdown
66
66
 
67
67
  <div align="center">
68
- <a href="">
68
+ <a href="https://stirrup.artificialanalysis.ai">
69
69
  <picture>
70
70
  <img alt="Stirrup" src="https://github.com/ArtificialAnalysis/Stirrup/blob/048653717d8662b0b81d152a037995af1c926afc/assets/stirrup-banner.png?raw=true" width="700">
71
71
  </picture>
@@ -79,7 +79,7 @@ Description-Content-Type: text/markdown
79
79
  <p align="center">
80
80
  <a href="https://pypi.python.org/pypi/stirrup"><img src="https://img.shields.io/pypi/v/stirrup" alt="PyPI version" /></a>&nbsp;<!--
81
81
  --><a href="https://github.com/ArtificialAnalysis/Stirrup/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ArtificialAnalysis/Stirrup" alt="License" /></a>&nbsp;<!--
82
- --><a href="https://artificialanalysis.github.io/Stirrup"><img src="https://img.shields.io/badge/MkDocs-4F46E5?logo=materialformkdocs&logoColor=fff" alt="MkDocs" /></a>
82
+ --><a href="https://stirrup.artificialanalysis.ai"><img src="https://img.shields.io/badge/MkDocs-4F46E5?logo=materialformkdocs&logoColor=fff" alt="MkDocs" /></a>
83
83
  </p>
84
84
 
85
85
 
@@ -96,6 +96,7 @@ Stirrup is a lightweight framework, or starting point template, for building age
96
96
  - Code execution (local, Docker container, E2B sandbox)
97
97
  - MCP client
98
98
  - Document input and output
99
+ - **Skills system:** Extend agent capabilities with modular, domain-specific instruction packages
99
100
  - **Flexible tool execution:** A generic `Tool` class allows easy tool definition and extension
100
101
  - **Context management:** Automatically summarizes conversation history when approaching context limits
101
102
  - **Flexible provider support:** Pre-built support for OpenAI-compatible APIs and LiteLLM, or bring your own client
@@ -105,16 +106,16 @@ Stirrup is a lightweight framework, or starting point template, for building age
105
106
 
106
107
  ```bash
107
108
  # Core framework
108
- uv pip install stirrup # or: uv add stirrup
109
+ pip install stirrup # or: uv add stirrup
109
110
 
110
111
  # With all optional components
111
- uv pip install stirrup[all] # or: uv add stirrup[all]
112
+ pip install 'stirrup[all]' # or: uv add 'stirrup[all]'
112
113
 
113
114
  # Individual extras
114
- uv pip install stirrup[litellm] # or: uv add stirrup[litellm]
115
- uv pip install stirrup[docker] # or: uv add stirrup[docker]
116
- uv pip install stirrup[e2b] # or: uv add stirrup[e2b]
117
- uv pip install stirrup[mcp] # or: uv add stirrup[mcp]
115
+ pip install 'stirrup[litellm]' # or: uv add 'stirrup[litellm]'
116
+ pip install 'stirrup[docker]' # or: uv add 'stirrup[docker]'
117
+ pip install 'stirrup[e2b]' # or: uv add 'stirrup[e2b]'
118
+ pip install 'stirrup[mcp]' # or: uv add 'stirrup[mcp]'
118
119
  ```
119
120
 
120
121
  ## Quick Start
@@ -160,6 +161,24 @@ if __name__ == "__main__":
160
161
 
161
162
  > **Note:** This example uses OpenRouter. Set `OPENROUTER_API_KEY` in your environment before running. Web search requires a `BRAVE_API_KEY`. The agent will still work without it, but web search will be unavailable.
162
163
 
164
+ ## Full Customization
165
+
166
+ For using Stirrup as a foundation for your own fully customized agent, you can clone and import Stirrup locally:
167
+
168
+ ```bash
169
+ # Clone the repository
170
+ git clone https://github.com/ArtificialAnalysis/Stirrup.git
171
+ cd stirrup
172
+
173
+ # Install in editable mode
174
+ pip install -e . # or: uv venv && uv pip install -e .
175
+
176
+ # Or with all optional dependencies
177
+ pip install -e '.[all]' # or: uv venv && uv pip install -e '.[all]'
178
+ ```
179
+
180
+ See the [Full Customization guide](https://stirrup.artificialanalysis.ai/extending/full-customization/) for more details.
181
+
163
182
  ## How It Works
164
183
 
165
184
  - **`Agent`** - Configures and runs the agent loop until a finish tool is called or max turns reached
@@ -188,6 +207,7 @@ agent = Agent(client=client, name="deepseek_agent")
188
207
  ### LiteLLM (Anthropic, Google, etc.)
189
208
 
190
209
  ```python
210
+ # Ensure LiteLLM is added with: pip install 'stirrup[litellm]' # or: uv add 'stirrup[litellm]'
191
211
  # Create LiteLLM client for Anthropic Claude
192
212
  # See https://docs.litellm.ai/docs/providers for all supported providers
193
213
  client = LiteLLMClient(
@@ -202,7 +222,7 @@ agent = Agent(
202
222
  )
203
223
  ```
204
224
 
205
- See [LiteLLM Example](https://artificialanalysis.github.io/Stirrup/examples/#litellm-multi-provider-support) or [Deepseek Example](https://artificialanalysis.github.io/Stirrup/examples/#openai-compatible-apis-deepseek-vllm-ollama) for complete examples.
225
+ See [LiteLLM Example](https://stirrup.artificialanalysis.ai/examples/#litellm-multi-provider-support) or [Deepseek Example](https://stirrup.artificialanalysis.ai/examples/#openai-compatible-apis-deepseek-vllm-ollama) for complete examples.
206
226
 
207
227
  ## Default Tools
208
228
 
@@ -285,14 +305,14 @@ agent = Agent(
285
305
 
286
306
  ## Next Steps
287
307
 
288
- - [Getting Started](https://artificialanalysis.github.io/Stirrup/getting-started/) - Installation and first agent tutorial
289
- - [Core Concepts](https://artificialanalysis.github.io/Stirrup/concepts/) - Understand Agent, Tools, and Sessions
290
- - [Examples](https://artificialanalysis.github.io/Stirrup/examples/) - Working examples for common patterns
291
- - [Creating Tools](https://artificialanalysis.github.io/Stirrup/guides/tools/) - Build your own tools
308
+ - [Getting Started](https://stirrup.artificialanalysis.ai/getting-started/) - Installation and first agent tutorial
309
+ - [Core Concepts](https://stirrup.artificialanalysis.ai/concepts/) - Understand Agent, Tools, and Sessions
310
+ - [Examples](https://stirrup.artificialanalysis.ai/examples/) - Working examples for common patterns
311
+ - [Creating Tools](https://stirrup.artificialanalysis.ai/guides/tools/) - Build your own tools
292
312
 
293
313
  ## Documentation
294
314
 
295
- Full documentation: [artificialanalysis.github.io/Stirrup](https://artificialanalysis.github.io/Stirrup)
315
+ Full documentation: [artificialanalysis.github.io/Stirrup](https://stirrup.artificialanalysis.ai)
296
316
 
297
317
  Build and serve locally:
298
318
 
@@ -1,5 +1,5 @@
1
1
  <div align="center">
2
- <a href="">
2
+ <a href="https://stirrup.artificialanalysis.ai">
3
3
  <picture>
4
4
  <img alt="Stirrup" src="https://github.com/ArtificialAnalysis/Stirrup/blob/048653717d8662b0b81d152a037995af1c926afc/assets/stirrup-banner.png?raw=true" width="700">
5
5
  </picture>
@@ -13,7 +13,7 @@
13
13
  <p align="center">
14
14
  <a href="https://pypi.python.org/pypi/stirrup"><img src="https://img.shields.io/pypi/v/stirrup" alt="PyPI version" /></a>&nbsp;<!--
15
15
  --><a href="https://github.com/ArtificialAnalysis/Stirrup/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ArtificialAnalysis/Stirrup" alt="License" /></a>&nbsp;<!--
16
- --><a href="https://artificialanalysis.github.io/Stirrup"><img src="https://img.shields.io/badge/MkDocs-4F46E5?logo=materialformkdocs&logoColor=fff" alt="MkDocs" /></a>
16
+ --><a href="https://stirrup.artificialanalysis.ai"><img src="https://img.shields.io/badge/MkDocs-4F46E5?logo=materialformkdocs&logoColor=fff" alt="MkDocs" /></a>
17
17
  </p>
18
18
 
19
19
 
@@ -30,6 +30,7 @@ Stirrup is a lightweight framework, or starting point template, for building age
30
30
  - Code execution (local, Docker container, E2B sandbox)
31
31
  - MCP client
32
32
  - Document input and output
33
+ - **Skills system:** Extend agent capabilities with modular, domain-specific instruction packages
33
34
  - **Flexible tool execution:** A generic `Tool` class allows easy tool definition and extension
34
35
  - **Context management:** Automatically summarizes conversation history when approaching context limits
35
36
  - **Flexible provider support:** Pre-built support for OpenAI-compatible APIs and LiteLLM, or bring your own client
@@ -39,16 +40,16 @@ Stirrup is a lightweight framework, or starting point template, for building age
39
40
 
40
41
  ```bash
41
42
  # Core framework
42
- uv pip install stirrup # or: uv add stirrup
43
+ pip install stirrup # or: uv add stirrup
43
44
 
44
45
  # With all optional components
45
- uv pip install stirrup[all] # or: uv add stirrup[all]
46
+ pip install 'stirrup[all]' # or: uv add 'stirrup[all]'
46
47
 
47
48
  # Individual extras
48
- uv pip install stirrup[litellm] # or: uv add stirrup[litellm]
49
- uv pip install stirrup[docker] # or: uv add stirrup[docker]
50
- uv pip install stirrup[e2b] # or: uv add stirrup[e2b]
51
- uv pip install stirrup[mcp] # or: uv add stirrup[mcp]
49
+ pip install 'stirrup[litellm]' # or: uv add 'stirrup[litellm]'
50
+ pip install 'stirrup[docker]' # or: uv add 'stirrup[docker]'
51
+ pip install 'stirrup[e2b]' # or: uv add 'stirrup[e2b]'
52
+ pip install 'stirrup[mcp]' # or: uv add 'stirrup[mcp]'
52
53
  ```
53
54
 
54
55
  ## Quick Start
@@ -94,6 +95,24 @@ if __name__ == "__main__":
94
95
 
95
96
  > **Note:** This example uses OpenRouter. Set `OPENROUTER_API_KEY` in your environment before running. Web search requires a `BRAVE_API_KEY`. The agent will still work without it, but web search will be unavailable.
96
97
 
98
+ ## Full Customization
99
+
100
+ For using Stirrup as a foundation for your own fully customized agent, you can clone and import Stirrup locally:
101
+
102
+ ```bash
103
+ # Clone the repository
104
+ git clone https://github.com/ArtificialAnalysis/Stirrup.git
105
+ cd stirrup
106
+
107
+ # Install in editable mode
108
+ pip install -e . # or: uv venv && uv pip install -e .
109
+
110
+ # Or with all optional dependencies
111
+ pip install -e '.[all]' # or: uv venv && uv pip install -e '.[all]'
112
+ ```
113
+
114
+ See the [Full Customization guide](https://stirrup.artificialanalysis.ai/extending/full-customization/) for more details.
115
+
97
116
  ## How It Works
98
117
 
99
118
  - **`Agent`** - Configures and runs the agent loop until a finish tool is called or max turns reached
@@ -122,6 +141,7 @@ agent = Agent(client=client, name="deepseek_agent")
122
141
  ### LiteLLM (Anthropic, Google, etc.)
123
142
 
124
143
  ```python
144
+ # Ensure LiteLLM is added with: pip install 'stirrup[litellm]' # or: uv add 'stirrup[litellm]'
125
145
  # Create LiteLLM client for Anthropic Claude
126
146
  # See https://docs.litellm.ai/docs/providers for all supported providers
127
147
  client = LiteLLMClient(
@@ -136,7 +156,7 @@ agent = Agent(
136
156
  )
137
157
  ```
138
158
 
139
- See [LiteLLM Example](https://artificialanalysis.github.io/Stirrup/examples/#litellm-multi-provider-support) or [Deepseek Example](https://artificialanalysis.github.io/Stirrup/examples/#openai-compatible-apis-deepseek-vllm-ollama) for complete examples.
159
+ See [LiteLLM Example](https://stirrup.artificialanalysis.ai/examples/#litellm-multi-provider-support) or [Deepseek Example](https://stirrup.artificialanalysis.ai/examples/#openai-compatible-apis-deepseek-vllm-ollama) for complete examples.
140
160
 
141
161
  ## Default Tools
142
162
 
@@ -219,14 +239,14 @@ agent = Agent(
219
239
 
220
240
  ## Next Steps
221
241
 
222
- - [Getting Started](https://artificialanalysis.github.io/Stirrup/getting-started/) - Installation and first agent tutorial
223
- - [Core Concepts](https://artificialanalysis.github.io/Stirrup/concepts/) - Understand Agent, Tools, and Sessions
224
- - [Examples](https://artificialanalysis.github.io/Stirrup/examples/) - Working examples for common patterns
225
- - [Creating Tools](https://artificialanalysis.github.io/Stirrup/guides/tools/) - Build your own tools
242
+ - [Getting Started](https://stirrup.artificialanalysis.ai/getting-started/) - Installation and first agent tutorial
243
+ - [Core Concepts](https://stirrup.artificialanalysis.ai/concepts/) - Understand Agent, Tools, and Sessions
244
+ - [Examples](https://stirrup.artificialanalysis.ai/examples/) - Working examples for common patterns
245
+ - [Creating Tools](https://stirrup.artificialanalysis.ai/guides/tools/) - Build your own tools
226
246
 
227
247
  ## Documentation
228
248
 
229
- Full documentation: [artificialanalysis.github.io/Stirrup](https://artificialanalysis.github.io/Stirrup)
249
+ Full documentation: [artificialanalysis.github.io/Stirrup](https://stirrup.artificialanalysis.ai)
230
250
 
231
251
  Build and serve locally:
232
252
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stirrup"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "The lightweight foundation for building agents"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -45,7 +45,7 @@ all = ["stirrup[litellm,e2b,docker,mcp]"]
45
45
 
46
46
  [project.urls]
47
47
  Homepage = "https://github.com/ArtificialAnalysis/Stirrup"
48
- Documentation = "https://artificialanalysis.github.io/Stirrup"
48
+ Documentation = "https://stirrup.artificialanalysis.ai"
49
49
  Repository = "https://github.com/ArtificialAnalysis/Stirrup"
50
50
 
51
51
  [build-system]
@@ -36,6 +36,7 @@ from stirrup.core.models import (
36
36
  UserMessage,
37
37
  )
38
38
  from stirrup.prompts import MESSAGE_SUMMARIZER, MESSAGE_SUMMARIZER_BRIDGE_TEMPLATE
39
+ from stirrup.skills import SkillMetadata, format_skills_section, load_skills_metadata
39
40
  from stirrup.tools import DEFAULT_TOOLS
40
41
  from stirrup.tools.code_backends.base import CodeExecToolProvider
41
42
  from stirrup.tools.code_backends.local import LocalCodeExecToolProvider
@@ -70,6 +71,7 @@ class SessionState:
70
71
  parent_exec_env: CodeExecToolProvider | None = None
71
72
  depth: int = 0
72
73
  uploaded_file_paths: list[str] = field(default_factory=list) # Paths of files uploaded to exec_env
74
+ skills_metadata: list[SkillMetadata] = field(default_factory=list) # Loaded skills metadata
73
75
 
74
76
 
75
77
  _SESSION_STATE: contextvars.ContextVar[SessionState] = contextvars.ContextVar("session_state")
@@ -222,6 +224,7 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
222
224
  # Session configuration (set during session(), used in __aenter__)
223
225
  self._pending_output_dir: Path | None = None
224
226
  self._pending_input_files: str | Path | list[str | Path] | None = None
227
+ self._pending_skills_dir: Path | None = None
225
228
 
226
229
  # Instance-scoped state (populated during __aenter__, isolated per agent instance)
227
230
  self._active_tools: dict[str, Tool] = {}
@@ -258,6 +261,7 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
258
261
  self,
259
262
  output_dir: Path | str | None = None,
260
263
  input_files: str | Path | list[str | Path] | None = None,
264
+ skills_dir: Path | str | None = None,
261
265
  ) -> Self:
262
266
  """Configure a session and return self for use as async context manager.
263
267
 
@@ -270,6 +274,9 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
270
274
  - Glob patterns (e.g., "data/*.csv", "**/*.py")
271
275
  Raises ValueError if no CodeExecToolProvider is configured
272
276
  or if a glob pattern matches no files.
277
+ skills_dir: Directory containing skill definitions to load and make available
278
+ to the agent. Skills are uploaded to the execution environment
279
+ and their metadata is included in the system prompt.
273
280
 
274
281
  Returns:
275
282
  Self, for use with `async with agent.session(...) as session:`
@@ -285,6 +292,7 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
285
292
  """
286
293
  self._pending_output_dir = Path(output_dir) if output_dir else None
287
294
  self._pending_input_files = input_files
295
+ self._pending_skills_dir = Path(skills_dir) if skills_dir else None
288
296
  return self
289
297
 
290
298
  def _resolve_input_files(self, input_files: str | Path | list[str | Path]) -> list[Path]:
@@ -410,6 +418,12 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
410
418
  files_section += f"\n- {file_path}"
411
419
  parts.append(files_section)
412
420
 
421
+ # Skills section (if skills were loaded)
422
+ if state and state.skills_metadata:
423
+ skills_section = format_skills_section(state.skills_metadata)
424
+ if skills_section:
425
+ parts.append(f"\n\n{skills_section}")
426
+
413
427
  # User's custom system prompt (if provided)
414
428
  if self._system_prompt:
415
429
  parts.append(f"\n\nFollow these instructions from the User:\n{self._system_prompt}")
@@ -588,6 +602,18 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
588
602
  raise RuntimeError(f"Failed to upload files: {result.failed}")
589
603
  self._pending_input_files = None # Clear pending state
590
604
 
605
+ # Upload skills directory if it exists and load metadata
606
+ if self._pending_skills_dir:
607
+ skills_path = self._pending_skills_dir
608
+ if skills_path.exists() and skills_path.is_dir():
609
+ if state.exec_env:
610
+ logger.debug("[%s __aenter__] Uploading skills directory: %s", self._name, skills_path)
611
+ await state.exec_env.upload_files(skills_path, dest_dir="skills")
612
+ # Load skills metadata (even if no exec_env, for system prompt)
613
+ state.skills_metadata = load_skills_metadata(skills_path)
614
+ logger.debug("[%s __aenter__] Loaded %d skills", self._name, len(state.skills_metadata))
615
+ self._pending_skills_dir = None # Clear pending state
616
+
591
617
  # Configure and enter logger context
592
618
  self._logger.name = self._name
593
619
  self._logger.model = self._client.model_slug
@@ -430,6 +430,7 @@ class Tool[P: BaseModel, M](BaseModel):
430
430
  (setup/teardown, resource pooling), use a ToolProvider instead.
431
431
 
432
432
  Example with parameters:
433
+ ```python
433
434
  class CalcParams(BaseModel):
434
435
  expression: str
435
436
 
@@ -439,13 +440,16 @@ class Tool[P: BaseModel, M](BaseModel):
439
440
  parameters=CalcParams,
440
441
  executor=lambda p: ToolResult(content=str(eval(p.expression))),
441
442
  )
443
+ ```
442
444
 
443
445
  Example without parameters:
446
+ ```python
444
447
  time_tool = Tool[None, None](
445
448
  name="time",
446
449
  description="Get current time",
447
450
  executor=lambda _: ToolResult(content=datetime.now().isoformat()),
448
451
  )
452
+ ```
449
453
  """
450
454
 
451
455
  name: str
@@ -0,0 +1,24 @@
1
+ """Skills module for agent capabilities.
2
+
3
+ This module provides functionality for loading and managing agent skills.
4
+ Skills are modular packages with instructions and resources that agents
5
+ can discover and use dynamically.
6
+
7
+ Example usage:
8
+ from stirrup.skills import load_skills_metadata, format_skills_section
9
+ from pathlib import Path
10
+
11
+ # Load skills from directory
12
+ skills = load_skills_metadata(Path("skills"))
13
+
14
+ # Format for system prompt
15
+ prompt_section = format_skills_section(skills)
16
+ """
17
+
18
+ from stirrup.skills.skills import SkillMetadata, format_skills_section, load_skills_metadata
19
+
20
+ __all__ = [
21
+ "SkillMetadata",
22
+ "format_skills_section",
23
+ "load_skills_metadata",
24
+ ]
@@ -0,0 +1,145 @@
1
+ """Skills loader for agent capabilities.
2
+
3
+ Skills are modular packages that extend agent capabilities with instructions,
4
+ scripts, and resources. Each skill is a directory containing a SKILL.md file
5
+ with YAML frontmatter (name, description) and detailed instructions.
6
+
7
+ Based on: https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class SkillMetadata:
20
+ """Metadata extracted from a skill's SKILL.md frontmatter."""
21
+
22
+ name: str
23
+ description: str
24
+ path: str # Relative path like "skills/data_analysis"
25
+
26
+
27
+ def parse_frontmatter(content: str) -> dict[str, str]:
28
+ """Parse YAML frontmatter from markdown content.
29
+
30
+ Extracts metadata between --- markers at the start of the file.
31
+
32
+ Args:
33
+ content: Markdown content with optional YAML frontmatter
34
+
35
+ Returns:
36
+ Dictionary of frontmatter key-value pairs, empty if no frontmatter found
37
+
38
+ """
39
+ # Match YAML frontmatter between --- markers
40
+ pattern = r"^---\s*\n(.*?)\n---"
41
+ match = re.match(pattern, content, re.DOTALL)
42
+
43
+ if not match:
44
+ return {}
45
+
46
+ frontmatter_text = match.group(1)
47
+ result: dict[str, str] = {}
48
+
49
+ # Simple YAML parsing for key: value pairs
50
+ for line in frontmatter_text.strip().split("\n"):
51
+ line = line.strip()
52
+ if ":" in line:
53
+ key, value = line.split(":", 1)
54
+ result[key.strip()] = value.strip()
55
+
56
+ return result
57
+
58
+
59
+ def load_skills_metadata(skills_dir: Path) -> list[SkillMetadata]:
60
+ """Scan skills directory for SKILL.md files and extract metadata.
61
+
62
+ Args:
63
+ skills_dir: Path to the skills directory
64
+
65
+ Returns:
66
+ List of SkillMetadata for each valid skill found.
67
+ Returns empty list if skills_dir doesn't exist or has no skills.
68
+
69
+ """
70
+ if not skills_dir.exists():
71
+ logger.debug("Skills directory does not exist: %s", skills_dir)
72
+ return []
73
+
74
+ if not skills_dir.is_dir():
75
+ logger.warning("Skills path is not a directory: %s", skills_dir)
76
+ return []
77
+
78
+ skills: list[SkillMetadata] = []
79
+
80
+ for skill_path in skills_dir.iterdir():
81
+ if not skill_path.is_dir():
82
+ continue
83
+
84
+ skill_md = skill_path / "SKILL.md"
85
+ if not skill_md.exists():
86
+ logger.debug("Skill directory missing SKILL.md: %s", skill_path)
87
+ continue
88
+
89
+ try:
90
+ content = skill_md.read_text(encoding="utf-8")
91
+ metadata = parse_frontmatter(content)
92
+
93
+ name = metadata.get("name")
94
+ description = metadata.get("description")
95
+
96
+ if not name or not description:
97
+ logger.warning(
98
+ "Skill %s missing required frontmatter (name, description)",
99
+ skill_path.name,
100
+ )
101
+ continue
102
+
103
+ skills.append(
104
+ SkillMetadata(
105
+ name=name,
106
+ description=description,
107
+ path=f"skills/{skill_path.name}",
108
+ )
109
+ )
110
+ logger.debug("Loaded skill: %s", name)
111
+
112
+ except Exception as e:
113
+ logger.warning("Failed to load skill %s: %s", skill_path.name, e)
114
+
115
+ return skills
116
+
117
+
118
+ def format_skills_section(skills: list[SkillMetadata]) -> str:
119
+ """Format skills metadata as a system prompt section.
120
+
121
+ Args:
122
+ skills: List of skill metadata to include
123
+
124
+ Returns:
125
+ Formatted string for inclusion in system prompt.
126
+ Returns empty string if no skills provided.
127
+
128
+ """
129
+ if not skills:
130
+ return ""
131
+
132
+ lines = [
133
+ "## Available Skills",
134
+ "",
135
+ "You have access to the following skills located in the `skills/` directory. "
136
+ "Each skill contains a SKILL.md file with detailed instructions and potentially bundled scripts.",
137
+ "",
138
+ "To use a skill:",
139
+ "1. Read the full instructions: `cat <skill_path>/SKILL.md`",
140
+ "2. Follow the instructions and use any bundled resources as described",
141
+ "",
142
+ ]
143
+ lines.extend([f"- **{skill.name}**: {skill.description} (`{skill.path}/SKILL.md`)" for skill in skills])
144
+
145
+ return "\n".join(lines)
@@ -330,7 +330,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
330
330
  context_path = self._dockerfile_context.resolve() if self._dockerfile_context else dockerfile_path.parent
331
331
 
332
332
  # Generate unique tag based on dockerfile path
333
- tag = f"agent001-exec-env-{hashlib.md5(str(dockerfile_path).encode()).hexdigest()[:8]}"
333
+ tag = f"stirrup-exec-env-{hashlib.md5(str(dockerfile_path).encode()).hexdigest()[:8]}"
334
334
 
335
335
  logger.info("Building image from %s with tag %s", dockerfile_path, tag)
336
336
 
@@ -710,8 +710,20 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
710
710
  logger.debug("Uploaded file: %s -> %s", source, container_path)
711
711
 
712
712
  elif source.is_dir():
713
- dest = dest_base / source.name
714
- shutil.copytree(source, dest, dirs_exist_ok=True)
713
+ # If dest_dir was explicitly provided, copy contents directly to dest_base
714
+ # Otherwise, create a subdirectory with the source's name
715
+ if dest_dir:
716
+ dest = dest_base
717
+ # Copy contents of source directory into dest_base
718
+ for item in source.iterdir():
719
+ item_dest = dest / item.name
720
+ if item.is_file():
721
+ shutil.copy2(item, item_dest)
722
+ else:
723
+ shutil.copytree(item, item_dest, dirs_exist_ok=True)
724
+ else:
725
+ dest = dest_base / source.name
726
+ shutil.copytree(source, dest, dirs_exist_ok=True)
715
727
  # Track all individual files uploaded
716
728
  for file_path in source.rglob("*"):
717
729
  if file_path.is_file():
@@ -725,7 +737,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
725
737
  size=file_path.stat().st_size,
726
738
  ),
727
739
  )
728
- logger.debug("Uploaded directory: %s -> %s/%s", source, self._working_dir, source.name)
740
+ logger.debug("Uploaded directory: %s -> %s", source, dest)
729
741
 
730
742
  except Exception as exc:
731
743
  result.failed[str(source)] = str(exc)
@@ -320,10 +320,12 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
320
320
 
321
321
  elif source.is_dir():
322
322
  # Upload all files in directory recursively
323
+ # If dest_dir was explicitly provided, copy contents directly to dest_base
324
+ # Otherwise, create a subdirectory with the source's name
323
325
  for file_path in source.rglob("*"):
324
326
  if file_path.is_file():
325
327
  relative = file_path.relative_to(source)
326
- dest = f"{dest_base}/{source.name}/{relative}"
328
+ dest = f"{dest_base}/{relative}" if dest_dir else f"{dest_base}/{source.name}/{relative}"
327
329
  content = file_path.read_bytes()
328
330
  await self._sbx.files.write(dest, content)
329
331
  result.uploaded.append(
@@ -333,7 +335,10 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
333
335
  size=len(content),
334
336
  ),
335
337
  )
336
- logger.debug("Uploaded directory: %s -> %s/%s", source, dest_base, source.name)
338
+ if dest_dir:
339
+ logger.debug("Uploaded directory contents: %s -> %s", source, dest_base)
340
+ else:
341
+ logger.debug("Uploaded directory: %s -> %s/%s", source, dest_base, source.name)
337
342
 
338
343
  except Exception as exc:
339
344
  result.failed[str(source)] = str(exc)
@@ -440,8 +440,20 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
440
440
  logger.debug("Uploaded file: %s -> %s", source, dest)
441
441
 
442
442
  elif source.is_dir():
443
- dest = dest_base / source.name
444
- shutil.copytree(source, dest, dirs_exist_ok=True)
443
+ # If dest_dir was explicitly provided, copy contents directly to dest_base
444
+ # Otherwise, create a subdirectory with the source's name
445
+ if dest_dir:
446
+ dest = dest_base
447
+ # Copy contents of source directory into dest_base
448
+ for item in source.iterdir():
449
+ item_dest = dest / item.name
450
+ if item.is_file():
451
+ shutil.copy2(item, item_dest)
452
+ else:
453
+ shutil.copytree(item, item_dest, dirs_exist_ok=True)
454
+ else:
455
+ dest = dest_base / source.name
456
+ shutil.copytree(source, dest, dirs_exist_ok=True)
445
457
  # Track all individual files uploaded
446
458
  for file_path in source.rglob("*"):
447
459
  if file_path.is_file():
File without changes
File without changes
File without changes