Skip to content

Commit cc5bcbf

Browse files
authored
Merge pull request #57 from ggozad/feature/skill-extras
Add "extras" to skills
2 parents 2efd5a7 + 936e771 commit cc5bcbf

File tree

4 files changed

+97
-2
lines changed

4 files changed

+97
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- **`SkillsCapability`**: New pydantic-ai capability wrapping `SkillToolset` + system prompt. Provides a single-line integration path via `Agent(capabilities=[SkillsCapability(...)])`. `SkillToolset` remains available for advanced use cases.
88
- **Skill thinking configuration**: Skills can specify `thinking` effort level (`True`, `'low'`, `'medium'`, `'high'`, etc.) to configure reasoning on their sub-agents. Supported across providers via pydantic-ai's unified thinking setting.
9+
- **Skill extras**: Skills can carry arbitrary non-tool data via `extras: dict[str, Any]`. Useful for exposing utility functions or other resources that the consuming app needs but that aren't agent tools.
910

1011
### Fixed
1112

docs/skills.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,32 @@ Skills automatically discover resource files — any non-script, non-Python file
142142
- Resolved paths must stay within the skill directory (traversal defense).
143143
- Files must be text — binary files raise an error.
144144

145+
## Extras
146+
147+
Skills can carry arbitrary non-tool data via `extras`. This is useful for exposing utility functions or other resources that the consuming app needs but that aren't agent tools:
148+
149+
```python
150+
def calculate_calories(ingredient: str, grams: float) -> float:
151+
...
152+
153+
skill = Skill(
154+
metadata=SkillMetadata(name="recipes", description="Recipe search."),
155+
instructions="...",
156+
tools=[...],
157+
extras={"calculate_calories": calculate_calories},
158+
)
159+
```
160+
161+
The app discovers skills via [entrypoints](tutorial.md#entrypoint-skills) and accesses extras by name:
162+
163+
```python
164+
from haiku.skills.discovery import discover_from_entrypoints
165+
166+
skills = {s.metadata.name: s for s in discover_from_entrypoints()}
167+
skill = skills["recipes"]
168+
calories = skill.extras["calculate_calories"]("flour", 200)
169+
```
170+
145171
## Per-skill state
146172

147173
Entrypoint skills can declare a Pydantic state model. State is passed to tool functions via `RunContext[SkillRunDeps]` and tracked per namespace on the toolset:

haiku/skills/models.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class Skill(BaseModel):
8585
_toolsets: list[AbstractToolset[Any]] = PrivateAttr(default_factory=list)
8686
_state_type: type[BaseModel] | None = PrivateAttr(default=None)
8787
_state_namespace: str | None = PrivateAttr(default=None)
88+
_extras: dict[str, Any] = PrivateAttr(default_factory=dict)
8889
_thinking: ThinkingLevel | None = PrivateAttr(default=None)
8990
_factory: Callable[..., "Skill"] | None = PrivateAttr(default=None)
9091

@@ -95,6 +96,7 @@ def __init__(
9596
toolsets: Sequence[AbstractToolset[Any]] | None = None,
9697
state_type: type[BaseModel] | None = None,
9798
state_namespace: str | None = None,
99+
extras: dict[str, Any] | None = None,
98100
thinking: ThinkingLevel | None = None,
99101
**data: Any,
100102
) -> None:
@@ -103,6 +105,7 @@ def __init__(
103105
self._toolsets = list(toolsets) if toolsets else []
104106
self._state_type = state_type
105107
self._state_namespace = state_namespace
108+
self._extras = dict(extras) if extras else {}
106109
self._thinking = thinking
107110

108111
@property
@@ -137,6 +140,14 @@ def state_namespace(self) -> str | None:
137140
def state_namespace(self, value: str | None) -> None:
138141
self._state_namespace = value
139142

143+
@property
144+
def extras(self) -> dict[str, Any]:
145+
return self._extras
146+
147+
@extras.setter
148+
def extras(self, value: dict[str, Any]) -> None:
149+
self._extras = value
150+
140151
@property
141152
def thinking(self) -> ThinkingLevel | None:
142153
return self._thinking
@@ -148,8 +159,8 @@ def thinking(self, value: ThinkingLevel | None) -> None:
148159
def reconfigure(self, **kwargs: Any) -> None:
149160
"""Re-create this skill with new factory arguments.
150161
151-
Calls the stored factory with the given kwargs and copies
152-
tools, toolsets, state_type, state_namespace, and model from the result.
162+
Calls the stored factory with the given kwargs and copies all
163+
private attributes and model from the result.
153164
"""
154165
if self._factory is None:
155166
raise RuntimeError("Skill has no factory — cannot reconfigure")
@@ -158,6 +169,7 @@ def reconfigure(self, **kwargs: Any) -> None:
158169
self._toolsets = new_skill._toolsets
159170
self._state_type = new_skill._state_type
160171
self._state_namespace = new_skill._state_namespace
172+
self._extras = new_skill._extras
161173
self._thinking = new_skill._thinking
162174
self.model = new_skill.model
163175

tests/test_models.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,40 @@ def test_state_metadata_partial_namespace_without_type(self):
326326
)
327327
assert skill.state_metadata() is None
328328

329+
def test_extras(self):
330+
async def visualize(chunk_id: str) -> list:
331+
return []
332+
333+
meta = SkillMetadata(name="test", description="Test skill.")
334+
skill = Skill(
335+
metadata=meta,
336+
source=SkillSource.FILESYSTEM,
337+
extras={"visualize_chunk": visualize},
338+
)
339+
assert skill.extras["visualize_chunk"] is visualize
340+
341+
def test_extras_default_empty(self):
342+
meta = SkillMetadata(name="test", description="Test skill.")
343+
skill = Skill(metadata=meta, source=SkillSource.FILESYSTEM)
344+
assert skill.extras == {}
345+
346+
def test_extras_setter(self):
347+
meta = SkillMetadata(name="test", description="Test skill.")
348+
skill = Skill(metadata=meta, source=SkillSource.FILESYSTEM)
349+
new_extras = {"key": 42}
350+
skill.extras = new_extras
351+
assert skill.extras == {"key": 42}
352+
353+
def test_extras_excluded_from_serialization(self):
354+
meta = SkillMetadata(name="test", description="Test skill.")
355+
skill = Skill(
356+
metadata=meta,
357+
source=SkillSource.FILESYSTEM,
358+
extras={"fn": lambda: None},
359+
)
360+
data = skill.model_dump()
361+
assert "extras" not in data
362+
329363

330364
class TestSkillFactory:
331365
def test_factory_default_none(self):
@@ -415,6 +449,28 @@ def factory(use_b: bool = False) -> Skill:
415449
assert skill.state_type is StateB
416450
assert skill.state_namespace == "b"
417451

452+
def test_reconfigure_replaces_extras(self):
453+
extras_a = {"helper": "a"}
454+
extras_b = {"helper": "b", "extra": True}
455+
456+
def factory(use_b: bool = False) -> Skill:
457+
return Skill(
458+
metadata=SkillMetadata(name="test", description="Test."),
459+
source=SkillSource.ENTRYPOINT,
460+
extras=extras_b if use_b else extras_a,
461+
)
462+
463+
skill = Skill(
464+
metadata=SkillMetadata(name="test", description="Test."),
465+
source=SkillSource.ENTRYPOINT,
466+
extras=extras_a,
467+
)
468+
skill._factory = factory
469+
assert skill.extras == {"helper": "a"}
470+
471+
skill.reconfigure(use_b=True)
472+
assert skill.extras == {"helper": "b", "extra": True}
473+
418474
def test_reconfigure_replaces_model(self):
419475
def factory(use_custom: bool = False) -> Skill:
420476
m = "custom-model" if use_custom else "default-model"

0 commit comments

Comments
 (0)