agentskills-testing 0.4.0__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.
- agentskills_testing/__init__.py +60 -0
- agentskills_testing/conformance.py +326 -0
- agentskills_testing/fixtures.py +45 -0
- agentskills_testing/memory.py +242 -0
- agentskills_testing/py.typed +0 -0
- agentskills_testing-0.4.0.dist-info/METADATA +191 -0
- agentskills_testing-0.4.0.dist-info/RECORD +9 -0
- agentskills_testing-0.4.0.dist-info/WHEEL +4 -0
- agentskills_testing-0.4.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Conformance suite and test doubles for Agent Skills providers.
|
|
2
|
+
|
|
3
|
+
Two things live here:
|
|
4
|
+
|
|
5
|
+
* :class:`ProviderConformanceSuite` — subclass it, supply a ``provider``
|
|
6
|
+
fixture, and pytest runs the whole provider contract against your
|
|
7
|
+
implementation.
|
|
8
|
+
* :class:`InMemorySkillProvider` — a real, spec-compliant provider
|
|
9
|
+
backed by a dict, for tests that need a skill but not a disk.
|
|
10
|
+
|
|
11
|
+
See https://github.com/pratikxpanda/agentskills-sdk for the guide.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from agentskills_testing.conformance import (
|
|
15
|
+
ASSET_BYTES,
|
|
16
|
+
ASSET_NAME,
|
|
17
|
+
CONTRACT,
|
|
18
|
+
MISSING_ID,
|
|
19
|
+
MISSING_RESOURCE,
|
|
20
|
+
REFERENCE_BYTES,
|
|
21
|
+
REFERENCE_NAME,
|
|
22
|
+
SCRIPT_BYTES,
|
|
23
|
+
SCRIPT_NAME,
|
|
24
|
+
SKILL_ID,
|
|
25
|
+
TRAVERSAL_IDENTIFIERS,
|
|
26
|
+
ContentLimitConformanceSuite,
|
|
27
|
+
ProviderConformanceSuite,
|
|
28
|
+
)
|
|
29
|
+
from agentskills_testing.memory import (
|
|
30
|
+
DEFAULT_BODY,
|
|
31
|
+
DEFAULT_DESCRIPTION,
|
|
32
|
+
DEFAULT_SKILL_ID,
|
|
33
|
+
InMemorySkill,
|
|
34
|
+
InMemorySkillProvider,
|
|
35
|
+
build_skill,
|
|
36
|
+
render_skill_md,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ASSET_BYTES",
|
|
41
|
+
"ASSET_NAME",
|
|
42
|
+
"CONTRACT",
|
|
43
|
+
"DEFAULT_BODY",
|
|
44
|
+
"DEFAULT_DESCRIPTION",
|
|
45
|
+
"DEFAULT_SKILL_ID",
|
|
46
|
+
"MISSING_ID",
|
|
47
|
+
"MISSING_RESOURCE",
|
|
48
|
+
"REFERENCE_BYTES",
|
|
49
|
+
"REFERENCE_NAME",
|
|
50
|
+
"SCRIPT_BYTES",
|
|
51
|
+
"SCRIPT_NAME",
|
|
52
|
+
"SKILL_ID",
|
|
53
|
+
"TRAVERSAL_IDENTIFIERS",
|
|
54
|
+
"ContentLimitConformanceSuite",
|
|
55
|
+
"InMemorySkill",
|
|
56
|
+
"InMemorySkillProvider",
|
|
57
|
+
"ProviderConformanceSuite",
|
|
58
|
+
"build_skill",
|
|
59
|
+
"render_skill_md",
|
|
60
|
+
]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""The provider conformance suite.
|
|
2
|
+
|
|
3
|
+
``SkillProvider`` is an ABC, which enforces that five methods exist and
|
|
4
|
+
nothing about what they do. The requirements that actually matter are
|
|
5
|
+
the ones an ABC cannot express: that an unknown ID raises the documented
|
|
6
|
+
exception rather than returning ``None``, that a traversal-shaped
|
|
7
|
+
identifier is refused rather than resolved, and that a provider
|
|
8
|
+
advertising resource listing or discovery can actually deliver it.
|
|
9
|
+
|
|
10
|
+
Subclass :class:`ProviderConformanceSuite`, supply a ``provider``
|
|
11
|
+
fixture populated per :data:`CONTRACT`, and pytest collects the whole
|
|
12
|
+
suite against your implementation.
|
|
13
|
+
|
|
14
|
+
Every test is marked ``asyncio`` explicitly so the suite runs under
|
|
15
|
+
``pytest-asyncio`` in either strict or auto mode — a kit that only works
|
|
16
|
+
under one project's configuration is not a kit.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from agentskills_core import (
|
|
26
|
+
RESOURCE_KINDS,
|
|
27
|
+
AgentSkillsError,
|
|
28
|
+
DiscoveryNotSupportedError,
|
|
29
|
+
ResourceListingNotSupportedError,
|
|
30
|
+
ResourceNotFoundError,
|
|
31
|
+
SkillNotFoundError,
|
|
32
|
+
SkillProvider,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: The skill a conforming ``provider`` fixture must expose.
|
|
36
|
+
SKILL_ID = "conformance-skill"
|
|
37
|
+
|
|
38
|
+
#: The reference document that skill must contain, and its exact bytes.
|
|
39
|
+
REFERENCE_NAME = "notes.md"
|
|
40
|
+
REFERENCE_BYTES = b"# Notes\n\nA reference document.\n"
|
|
41
|
+
|
|
42
|
+
#: The script that skill must contain, and its exact bytes.
|
|
43
|
+
SCRIPT_NAME = "run.sh"
|
|
44
|
+
SCRIPT_BYTES = b"#!/bin/sh\necho conformance\n"
|
|
45
|
+
|
|
46
|
+
#: The asset that skill must contain, and its exact bytes.
|
|
47
|
+
ASSET_NAME = "diagram.svg"
|
|
48
|
+
ASSET_BYTES = b"<svg></svg>\n"
|
|
49
|
+
|
|
50
|
+
#: An ID no conforming fixture may define.
|
|
51
|
+
MISSING_ID = "no-such-skill-anywhere"
|
|
52
|
+
|
|
53
|
+
#: A resource name no conforming fixture may define.
|
|
54
|
+
MISSING_RESOURCE = "no-such-resource.txt"
|
|
55
|
+
|
|
56
|
+
CONTRACT = f"""\
|
|
57
|
+
The `provider` fixture must expose exactly one skill:
|
|
58
|
+
|
|
59
|
+
id {SKILL_ID}
|
|
60
|
+
metadata name == {SKILL_ID!r}, plus a non-empty description
|
|
61
|
+
body non-empty markdown
|
|
62
|
+
references/ {REFERENCE_NAME} containing {REFERENCE_BYTES!r}
|
|
63
|
+
scripts/ {SCRIPT_NAME} containing {SCRIPT_BYTES!r}
|
|
64
|
+
assets/ {ASSET_NAME} containing {ASSET_BYTES!r}
|
|
65
|
+
|
|
66
|
+
It must not define a skill called {MISSING_ID!r} or any resource called
|
|
67
|
+
{MISSING_RESOURCE!r}. A provider that sets `supports_discovery` must
|
|
68
|
+
report exactly one skill, {SKILL_ID!r}.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
#: Identifiers a provider must refuse rather than resolve. Each is a
|
|
72
|
+
#: real technique: parent traversal, an absolute path, a Windows path, a
|
|
73
|
+
#: percent-encoded traversal that a URL-building backend may decode, and
|
|
74
|
+
#: a NUL byte that truncates a C string.
|
|
75
|
+
TRAVERSAL_IDENTIFIERS = (
|
|
76
|
+
"../etc/passwd",
|
|
77
|
+
"..",
|
|
78
|
+
"a/../../b",
|
|
79
|
+
"/etc/passwd",
|
|
80
|
+
"..\\..\\windows\\system32",
|
|
81
|
+
"%2e%2e%2fetc%2fpasswd",
|
|
82
|
+
"ok\x00.md",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_RESOURCE_GETTERS = {
|
|
86
|
+
"references": "get_reference",
|
|
87
|
+
"scripts": "get_script",
|
|
88
|
+
"assets": "get_asset",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_EXPECTED_RESOURCES = {
|
|
92
|
+
"references": (REFERENCE_NAME, REFERENCE_BYTES),
|
|
93
|
+
"scripts": (SCRIPT_NAME, SCRIPT_BYTES),
|
|
94
|
+
"assets": (ASSET_NAME, ASSET_BYTES),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ProviderConformanceSuite:
|
|
99
|
+
"""Assertions every :class:`~agentskills_core.SkillProvider` must satisfy.
|
|
100
|
+
|
|
101
|
+
Subclass it and provide a ``provider`` fixture::
|
|
102
|
+
|
|
103
|
+
class TestMyProvider(ProviderConformanceSuite):
|
|
104
|
+
@pytest.fixture
|
|
105
|
+
def provider(self):
|
|
106
|
+
return MyProvider(...)
|
|
107
|
+
|
|
108
|
+
The fixture must be populated per :data:`CONTRACT`. Nothing here is
|
|
109
|
+
opt-out: a provider that cannot pass these is not a provider, it is
|
|
110
|
+
a class with the right method names.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
@pytest.fixture
|
|
114
|
+
def provider(self) -> SkillProvider:
|
|
115
|
+
"""Return the provider under test, populated per :data:`CONTRACT`."""
|
|
116
|
+
raise NotImplementedError(f"Override the 'provider' fixture.\n\n{CONTRACT}")
|
|
117
|
+
|
|
118
|
+
# -- Identity -------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def test_is_a_skill_provider(self, provider: SkillProvider) -> None:
|
|
121
|
+
assert isinstance(provider, SkillProvider)
|
|
122
|
+
|
|
123
|
+
# -- Metadata and body ---------------------------------------------
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_metadata_has_the_required_fields(self, provider: SkillProvider) -> None:
|
|
127
|
+
metadata = await provider.get_metadata(SKILL_ID)
|
|
128
|
+
|
|
129
|
+
assert isinstance(metadata, dict)
|
|
130
|
+
assert metadata.get("name") == SKILL_ID
|
|
131
|
+
assert isinstance(metadata.get("description"), str)
|
|
132
|
+
assert metadata["description"].strip()
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_metadata_excludes_the_body(self, provider: SkillProvider) -> None:
|
|
136
|
+
metadata = await provider.get_metadata(SKILL_ID)
|
|
137
|
+
body = await provider.get_body(SKILL_ID)
|
|
138
|
+
|
|
139
|
+
assert body.strip()
|
|
140
|
+
# Progressive disclosure is the whole point: a metadata call that
|
|
141
|
+
# carries the body has already spent the tokens it exists to save.
|
|
142
|
+
assert all(value != body for value in metadata.values())
|
|
143
|
+
|
|
144
|
+
@pytest.mark.asyncio
|
|
145
|
+
async def test_metadata_is_not_shared_mutable_state(self, provider: SkillProvider) -> None:
|
|
146
|
+
first = await provider.get_metadata(SKILL_ID)
|
|
147
|
+
first["description"] = "mutated by a caller"
|
|
148
|
+
|
|
149
|
+
second = await provider.get_metadata(SKILL_ID)
|
|
150
|
+
|
|
151
|
+
assert second["description"] != "mutated by a caller"
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_repeated_reads_agree(self, provider: SkillProvider) -> None:
|
|
155
|
+
# A provider that streams without buffering passes once and then
|
|
156
|
+
# returns empty. Caching bugs surface the same way.
|
|
157
|
+
assert await provider.get_body(SKILL_ID) == await provider.get_body(SKILL_ID)
|
|
158
|
+
assert await provider.get_metadata(SKILL_ID) == await provider.get_metadata(SKILL_ID)
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio
|
|
161
|
+
async def test_unknown_skill_raises_skill_not_found(self, provider: SkillProvider) -> None:
|
|
162
|
+
with pytest.raises(SkillNotFoundError):
|
|
163
|
+
await provider.get_metadata(MISSING_ID)
|
|
164
|
+
with pytest.raises(SkillNotFoundError):
|
|
165
|
+
await provider.get_body(MISSING_ID)
|
|
166
|
+
|
|
167
|
+
# -- Resources ------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("kind", RESOURCE_KINDS)
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_resource_returns_exact_bytes(self, provider: SkillProvider, kind: str) -> None:
|
|
172
|
+
name, expected = _EXPECTED_RESOURCES[kind]
|
|
173
|
+
|
|
174
|
+
data = await getattr(provider, _RESOURCE_GETTERS[kind])(SKILL_ID, name)
|
|
175
|
+
|
|
176
|
+
# bytes, not str: a resource may be a PNG, and decoding it on the
|
|
177
|
+
# way out makes that unreachable.
|
|
178
|
+
assert isinstance(data, bytes)
|
|
179
|
+
assert data == expected
|
|
180
|
+
|
|
181
|
+
@pytest.mark.parametrize("kind", RESOURCE_KINDS)
|
|
182
|
+
@pytest.mark.asyncio
|
|
183
|
+
async def test_unknown_resource_raises_resource_not_found(
|
|
184
|
+
self, provider: SkillProvider, kind: str
|
|
185
|
+
) -> None:
|
|
186
|
+
with pytest.raises(ResourceNotFoundError):
|
|
187
|
+
await getattr(provider, _RESOURCE_GETTERS[kind])(SKILL_ID, MISSING_RESOURCE)
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize("kind", RESOURCE_KINDS)
|
|
190
|
+
@pytest.mark.asyncio
|
|
191
|
+
async def test_resource_of_unknown_skill_is_refused(
|
|
192
|
+
self, provider: SkillProvider, kind: str
|
|
193
|
+
) -> None:
|
|
194
|
+
name, _ = _EXPECTED_RESOURCES[kind]
|
|
195
|
+
|
|
196
|
+
with pytest.raises((SkillNotFoundError, ResourceNotFoundError)):
|
|
197
|
+
await getattr(provider, _RESOURCE_GETTERS[kind])(MISSING_ID, name)
|
|
198
|
+
|
|
199
|
+
# -- Resource listing ----------------------------------------------
|
|
200
|
+
|
|
201
|
+
@pytest.mark.asyncio
|
|
202
|
+
async def test_listing_matches_the_declared_capability(self, provider: SkillProvider) -> None:
|
|
203
|
+
# The flag is what callers branch on, so a provider whose flag
|
|
204
|
+
# and behaviour disagree breaks them whichever way it lies.
|
|
205
|
+
if provider.supports_resource_listing:
|
|
206
|
+
try:
|
|
207
|
+
listing = await provider.list_resources(SKILL_ID)
|
|
208
|
+
except ResourceListingNotSupportedError as exc:
|
|
209
|
+
pytest.fail(
|
|
210
|
+
f"supports_resource_listing is True but list_resources() refused to list: {exc}"
|
|
211
|
+
)
|
|
212
|
+
assert set(listing) == set(RESOURCE_KINDS)
|
|
213
|
+
for kind in RESOURCE_KINDS:
|
|
214
|
+
assert listing[kind] == sorted(listing[kind])
|
|
215
|
+
expected_name, _ = _EXPECTED_RESOURCES[kind]
|
|
216
|
+
assert expected_name in listing[kind]
|
|
217
|
+
else:
|
|
218
|
+
with pytest.raises(ResourceListingNotSupportedError):
|
|
219
|
+
await provider.list_resources(SKILL_ID)
|
|
220
|
+
|
|
221
|
+
@pytest.mark.asyncio
|
|
222
|
+
async def test_listing_of_unknown_skill_is_refused(self, provider: SkillProvider) -> None:
|
|
223
|
+
if not provider.supports_resource_listing:
|
|
224
|
+
pytest.skip("provider does not support resource listing")
|
|
225
|
+
|
|
226
|
+
with pytest.raises(SkillNotFoundError):
|
|
227
|
+
await provider.list_resources(MISSING_ID)
|
|
228
|
+
|
|
229
|
+
# -- Skill discovery ------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_discovery_matches_the_declared_capability(self, provider: SkillProvider) -> None:
|
|
233
|
+
if provider.supports_discovery:
|
|
234
|
+
try:
|
|
235
|
+
skill_ids = await provider.discover()
|
|
236
|
+
except DiscoveryNotSupportedError as exc:
|
|
237
|
+
pytest.fail(f"supports_discovery is True but discover() refused: {exc}")
|
|
238
|
+
assert isinstance(skill_ids, list)
|
|
239
|
+
# The contract fixture holds exactly one skill, so this also
|
|
240
|
+
# pins sortedness and the absence of duplicates.
|
|
241
|
+
assert skill_ids == [SKILL_ID]
|
|
242
|
+
else:
|
|
243
|
+
with pytest.raises(DiscoveryNotSupportedError):
|
|
244
|
+
await provider.discover()
|
|
245
|
+
|
|
246
|
+
@pytest.mark.asyncio
|
|
247
|
+
async def test_discovered_skills_are_readable(self, provider: SkillProvider) -> None:
|
|
248
|
+
if not provider.supports_discovery:
|
|
249
|
+
pytest.skip("provider does not support discovery")
|
|
250
|
+
|
|
251
|
+
# register_all() validates everything discover() returns, so an ID
|
|
252
|
+
# that cannot be read poisons the whole registration.
|
|
253
|
+
for skill_id in await provider.discover():
|
|
254
|
+
assert await provider.get_metadata(skill_id)
|
|
255
|
+
|
|
256
|
+
# -- Security -------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
@pytest.mark.parametrize("identifier", TRAVERSAL_IDENTIFIERS)
|
|
259
|
+
@pytest.mark.asyncio
|
|
260
|
+
async def test_traversal_skill_id_is_refused(
|
|
261
|
+
self, provider: SkillProvider, identifier: str
|
|
262
|
+
) -> None:
|
|
263
|
+
with pytest.raises(SkillNotFoundError):
|
|
264
|
+
await provider.get_metadata(identifier)
|
|
265
|
+
|
|
266
|
+
@pytest.mark.parametrize("identifier", TRAVERSAL_IDENTIFIERS)
|
|
267
|
+
@pytest.mark.parametrize("kind", RESOURCE_KINDS)
|
|
268
|
+
@pytest.mark.asyncio
|
|
269
|
+
async def test_traversal_resource_name_is_refused(
|
|
270
|
+
self, provider: SkillProvider, kind: str, identifier: str
|
|
271
|
+
) -> None:
|
|
272
|
+
with pytest.raises(ResourceNotFoundError):
|
|
273
|
+
await getattr(provider, _RESOURCE_GETTERS[kind])(SKILL_ID, identifier)
|
|
274
|
+
|
|
275
|
+
# -- Concurrency ----------------------------------------------------
|
|
276
|
+
|
|
277
|
+
@pytest.mark.asyncio
|
|
278
|
+
async def test_reads_are_concurrency_safe(self, provider: SkillProvider) -> None:
|
|
279
|
+
# Twenty overlapping reads through one provider instance. A
|
|
280
|
+
# provider holding per-call state on self returns the wrong
|
|
281
|
+
# skill's content here and nowhere else.
|
|
282
|
+
bodies = await asyncio.gather(*(provider.get_body(SKILL_ID) for _ in range(20)))
|
|
283
|
+
|
|
284
|
+
assert len(set(bodies)) == 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class ContentLimitConformanceSuite:
|
|
288
|
+
"""Assertions for providers that read bytes they did not author.
|
|
289
|
+
|
|
290
|
+
A size limit is not part of the universal contract — an in-memory
|
|
291
|
+
provider has nothing to bound, and demanding one would be asserting a
|
|
292
|
+
filesystem's constraints against a dict. It *is* required of any
|
|
293
|
+
provider reading from disk, a network, or anything else a caller can
|
|
294
|
+
grow without asking.
|
|
295
|
+
|
|
296
|
+
Supply a ``limited_provider`` fixture whose limit is small enough
|
|
297
|
+
that the fixture's own content exceeds it::
|
|
298
|
+
|
|
299
|
+
class TestMyProviderLimits(ContentLimitConformanceSuite):
|
|
300
|
+
@pytest.fixture
|
|
301
|
+
def limited_provider(self):
|
|
302
|
+
return MyProvider(..., max_bytes=8)
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
@pytest.fixture
|
|
306
|
+
def limited_provider(self) -> SkillProvider:
|
|
307
|
+
"""Return a provider whose size limit the contract skill exceeds."""
|
|
308
|
+
raise NotImplementedError(
|
|
309
|
+
"Override the 'limited_provider' fixture with a provider whose "
|
|
310
|
+
"size limit is smaller than the contract skill."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@pytest.mark.asyncio
|
|
314
|
+
async def test_oversized_skill_md_is_refused(self, limited_provider: SkillProvider) -> None:
|
|
315
|
+
with pytest.raises(AgentSkillsError):
|
|
316
|
+
await limited_provider.get_metadata(SKILL_ID)
|
|
317
|
+
|
|
318
|
+
@pytest.mark.parametrize("kind", RESOURCE_KINDS)
|
|
319
|
+
@pytest.mark.asyncio
|
|
320
|
+
async def test_oversized_resource_is_refused(
|
|
321
|
+
self, limited_provider: SkillProvider, kind: str
|
|
322
|
+
) -> None:
|
|
323
|
+
name, _ = _EXPECTED_RESOURCES[kind]
|
|
324
|
+
|
|
325
|
+
with pytest.raises(AgentSkillsError):
|
|
326
|
+
await getattr(limited_provider, _RESOURCE_GETTERS[kind])(SKILL_ID, name)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Pytest fixtures, registered automatically as a plugin.
|
|
2
|
+
|
|
3
|
+
Installing ``agentskills-testing`` makes these available in any test
|
|
4
|
+
without an import or a ``conftest.py`` entry, via the ``pytest11``
|
|
5
|
+
entry point.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import pytest_asyncio
|
|
12
|
+
|
|
13
|
+
from agentskills_core import SkillRegistry
|
|
14
|
+
from agentskills_testing.memory import (
|
|
15
|
+
DEFAULT_SKILL_ID,
|
|
16
|
+
InMemorySkill,
|
|
17
|
+
InMemorySkillProvider,
|
|
18
|
+
build_skill,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def sample_skill() -> InMemorySkill:
|
|
24
|
+
"""A single valid skill with one resource of each kind."""
|
|
25
|
+
return build_skill(
|
|
26
|
+
DEFAULT_SKILL_ID,
|
|
27
|
+
version="1.0.0",
|
|
28
|
+
references={"severity-levels.md": b"# Severity\n\nSEV1 is customer-visible.\n"},
|
|
29
|
+
scripts={"page-oncall.sh": b"#!/bin/sh\necho paging\n"},
|
|
30
|
+
assets={"flowchart.mermaid": b"graph TD; A-->B\n"},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def skill_provider(sample_skill: InMemorySkill) -> InMemorySkillProvider:
|
|
36
|
+
"""An :class:`InMemorySkillProvider` holding :func:`sample_skill`."""
|
|
37
|
+
return InMemorySkillProvider({DEFAULT_SKILL_ID: sample_skill})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest_asyncio.fixture
|
|
41
|
+
async def skill_registry(skill_provider: InMemorySkillProvider) -> SkillRegistry:
|
|
42
|
+
"""A registry with :func:`skill_provider`'s skill already registered."""
|
|
43
|
+
registry = SkillRegistry()
|
|
44
|
+
await registry.register(DEFAULT_SKILL_ID, skill_provider)
|
|
45
|
+
return registry
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""In-memory skill provider and content helpers.
|
|
2
|
+
|
|
3
|
+
A test that needs a skill should not need a directory or an HTTP server,
|
|
4
|
+
and it should not need an ``AsyncMock`` either: a mock agrees with
|
|
5
|
+
whatever the test asserts, including the assertions that are wrong.
|
|
6
|
+
:class:`InMemorySkillProvider` is a real provider that passes the same
|
|
7
|
+
conformance suite the filesystem and HTTP providers do.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from agentskills_core import (
|
|
19
|
+
RESOURCE_KINDS,
|
|
20
|
+
DiscoveryNotSupportedError,
|
|
21
|
+
ResourceListingNotSupportedError,
|
|
22
|
+
ResourceNotFoundError,
|
|
23
|
+
SkillNotFoundError,
|
|
24
|
+
SkillProvider,
|
|
25
|
+
get_logger,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_SKILL_ID = "incident-response"
|
|
31
|
+
DEFAULT_DESCRIPTION = "Diagnose and mitigate a production incident."
|
|
32
|
+
DEFAULT_BODY = "# Incident Response\n\nPage the on-call engineer, then open a channel.\n"
|
|
33
|
+
|
|
34
|
+
# A provider is addressed by skill ID and resource name, and both reach a
|
|
35
|
+
# backend that may treat them as a path segment. Anything that could
|
|
36
|
+
# escape one is refused before it gets that far.
|
|
37
|
+
_UNSAFE = ("..", "/", "\\", "\x00")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class InMemorySkill:
|
|
42
|
+
"""One skill's content, held in memory.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
metadata: Parsed frontmatter. Must carry ``name`` and
|
|
46
|
+
``description`` to satisfy the specification.
|
|
47
|
+
body: Markdown instructions.
|
|
48
|
+
references: Reference documents by filename.
|
|
49
|
+
scripts: Scripts by filename.
|
|
50
|
+
assets: Assets by filename.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
metadata: dict[str, Any]
|
|
54
|
+
body: str = DEFAULT_BODY
|
|
55
|
+
references: dict[str, bytes] = field(default_factory=dict)
|
|
56
|
+
scripts: dict[str, bytes] = field(default_factory=dict)
|
|
57
|
+
assets: dict[str, bytes] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
def resources(self, kind: str) -> dict[str, bytes]:
|
|
60
|
+
"""Return the resource mapping for *kind*."""
|
|
61
|
+
return {"references": self.references, "scripts": self.scripts, "assets": self.assets}[kind]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_skill(
|
|
65
|
+
skill_id: str = DEFAULT_SKILL_ID,
|
|
66
|
+
*,
|
|
67
|
+
description: str = DEFAULT_DESCRIPTION,
|
|
68
|
+
body: str = DEFAULT_BODY,
|
|
69
|
+
version: str | None = None,
|
|
70
|
+
metadata: Mapping[str, Any] | None = None,
|
|
71
|
+
references: Mapping[str, bytes] | None = None,
|
|
72
|
+
scripts: Mapping[str, bytes] | None = None,
|
|
73
|
+
assets: Mapping[str, bytes] | None = None,
|
|
74
|
+
) -> InMemorySkill:
|
|
75
|
+
"""Build a valid :class:`InMemorySkill`.
|
|
76
|
+
|
|
77
|
+
The defaults produce a skill that passes ``validate_skill()``, so a
|
|
78
|
+
test that does not care about content does not have to invent any.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
skill_id: Becomes the ``name`` in the frontmatter.
|
|
82
|
+
description: Frontmatter description.
|
|
83
|
+
body: Markdown instructions.
|
|
84
|
+
version: Optional semver string.
|
|
85
|
+
metadata: Extra frontmatter, merged over the generated fields.
|
|
86
|
+
references: Reference documents by filename.
|
|
87
|
+
scripts: Scripts by filename.
|
|
88
|
+
assets: Assets by filename.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A skill ready to hand to :class:`InMemorySkillProvider`.
|
|
92
|
+
"""
|
|
93
|
+
frontmatter: dict[str, Any] = {"name": skill_id, "description": description}
|
|
94
|
+
if version is not None:
|
|
95
|
+
frontmatter["version"] = version
|
|
96
|
+
if metadata:
|
|
97
|
+
frontmatter.update(metadata)
|
|
98
|
+
|
|
99
|
+
return InMemorySkill(
|
|
100
|
+
metadata=frontmatter,
|
|
101
|
+
body=body,
|
|
102
|
+
references=dict(references or {}),
|
|
103
|
+
scripts=dict(scripts or {}),
|
|
104
|
+
assets=dict(assets or {}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_skill_md(skill: InMemorySkill) -> str:
|
|
109
|
+
"""Render *skill* as the ``SKILL.md`` text a file-backed provider would hold.
|
|
110
|
+
|
|
111
|
+
Useful for populating a temporary directory when a test needs the
|
|
112
|
+
filesystem provider rather than this one.
|
|
113
|
+
"""
|
|
114
|
+
frontmatter = yaml.safe_dump(skill.metadata, sort_keys=False).strip()
|
|
115
|
+
return f"---\n{frontmatter}\n---\n\n{skill.body}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class InMemorySkillProvider(SkillProvider):
|
|
119
|
+
"""A spec-compliant :class:`~agentskills_core.SkillProvider` backed by a dict.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
skills: Skills by ID. A plain string value is taken as the body
|
|
123
|
+
of a default skill, so the common case stays short.
|
|
124
|
+
supports_resource_listing: Set ``False`` to emulate a backend
|
|
125
|
+
that cannot enumerate, which is what the HTTP provider does
|
|
126
|
+
without a manifest.
|
|
127
|
+
supports_discovery: Set ``False`` to emulate a backend that
|
|
128
|
+
cannot list the skills it holds.
|
|
129
|
+
|
|
130
|
+
Example::
|
|
131
|
+
|
|
132
|
+
provider = InMemorySkillProvider({"incident-response": build_skill()})
|
|
133
|
+
registry = SkillRegistry()
|
|
134
|
+
await registry.register("incident-response", provider)
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
skills: Mapping[str, InMemorySkill | str] | None = None,
|
|
140
|
+
*,
|
|
141
|
+
supports_resource_listing: bool = True,
|
|
142
|
+
supports_discovery: bool = True,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._skills: dict[str, InMemorySkill] = {}
|
|
145
|
+
self.supports_resource_listing = supports_resource_listing
|
|
146
|
+
self.supports_discovery = supports_discovery
|
|
147
|
+
for skill_id, skill in (skills or {}).items():
|
|
148
|
+
self.add(skill_id, skill)
|
|
149
|
+
|
|
150
|
+
def add(self, skill_id: str, skill: InMemorySkill | str | None = None) -> InMemorySkill:
|
|
151
|
+
"""Add or replace a skill.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
skill_id: ID to register the skill under.
|
|
155
|
+
skill: The skill, or a string taken as its body. Omit it for
|
|
156
|
+
a default skill named after *skill_id*.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The stored skill.
|
|
160
|
+
"""
|
|
161
|
+
if skill is None:
|
|
162
|
+
skill = build_skill(skill_id)
|
|
163
|
+
elif isinstance(skill, str):
|
|
164
|
+
skill = build_skill(skill_id, body=skill)
|
|
165
|
+
self._skills[skill_id] = skill
|
|
166
|
+
_logger.debug("Added in-memory skill %r", skill_id)
|
|
167
|
+
return skill
|
|
168
|
+
|
|
169
|
+
def skill_ids(self) -> list[str]:
|
|
170
|
+
"""Return the registered skill IDs, sorted."""
|
|
171
|
+
return sorted(self._skills)
|
|
172
|
+
|
|
173
|
+
async def get_metadata(self, skill_id: str) -> dict[str, Any]:
|
|
174
|
+
"""Return a copy of the skill's frontmatter."""
|
|
175
|
+
return dict(self._get(skill_id).metadata)
|
|
176
|
+
|
|
177
|
+
async def get_body(self, skill_id: str) -> str:
|
|
178
|
+
"""Return the skill's markdown body."""
|
|
179
|
+
return self._get(skill_id).body
|
|
180
|
+
|
|
181
|
+
async def get_reference(self, skill_id: str, name: str) -> bytes:
|
|
182
|
+
"""Return a reference document."""
|
|
183
|
+
return self._resource(skill_id, "references", name)
|
|
184
|
+
|
|
185
|
+
async def get_script(self, skill_id: str, name: str) -> bytes:
|
|
186
|
+
"""Return a script."""
|
|
187
|
+
return self._resource(skill_id, "scripts", name)
|
|
188
|
+
|
|
189
|
+
async def get_asset(self, skill_id: str, name: str) -> bytes:
|
|
190
|
+
"""Return an asset."""
|
|
191
|
+
return self._resource(skill_id, "assets", name)
|
|
192
|
+
|
|
193
|
+
async def list_resources(self, skill_id: str) -> dict[str, list[str]]:
|
|
194
|
+
"""Return the skill's resource names, grouped by kind and sorted.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ResourceListingNotSupportedError: If the provider was built
|
|
198
|
+
with ``supports_resource_listing=False``.
|
|
199
|
+
SkillNotFoundError: If the skill is unknown.
|
|
200
|
+
"""
|
|
201
|
+
if not self.supports_resource_listing:
|
|
202
|
+
raise ResourceListingNotSupportedError(
|
|
203
|
+
"InMemorySkillProvider was built with resource listing disabled."
|
|
204
|
+
)
|
|
205
|
+
skill = self._get(skill_id)
|
|
206
|
+
return {kind: sorted(skill.resources(kind)) for kind in RESOURCE_KINDS}
|
|
207
|
+
|
|
208
|
+
async def discover(self) -> list[str]:
|
|
209
|
+
"""Return the stored skill IDs, sorted.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
DiscoveryNotSupportedError: If the provider was built with
|
|
213
|
+
``supports_discovery=False``.
|
|
214
|
+
"""
|
|
215
|
+
if not self.supports_discovery:
|
|
216
|
+
raise DiscoveryNotSupportedError(
|
|
217
|
+
"InMemorySkillProvider was built with discovery disabled."
|
|
218
|
+
)
|
|
219
|
+
return self.skill_ids()
|
|
220
|
+
|
|
221
|
+
def _get(self, skill_id: str) -> InMemorySkill:
|
|
222
|
+
_reject_unsafe(skill_id, SkillNotFoundError, "skill_id")
|
|
223
|
+
try:
|
|
224
|
+
return self._skills[skill_id]
|
|
225
|
+
except KeyError:
|
|
226
|
+
raise SkillNotFoundError(f"Skill not found: {skill_id!r}") from None
|
|
227
|
+
|
|
228
|
+
def _resource(self, skill_id: str, kind: str, name: str) -> bytes:
|
|
229
|
+
skill = self._get(skill_id)
|
|
230
|
+
_reject_unsafe(name, ResourceNotFoundError, "resource name")
|
|
231
|
+
try:
|
|
232
|
+
return skill.resources(kind)[name]
|
|
233
|
+
except KeyError:
|
|
234
|
+
raise ResourceNotFoundError(
|
|
235
|
+
f"Resource {name!r} not found in {kind}/ for skill {skill_id!r}"
|
|
236
|
+
) from None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _reject_unsafe(value: str, error: type[Exception], label: str) -> None:
|
|
240
|
+
"""Refuse an identifier that could escape its container."""
|
|
241
|
+
if not value or any(part in value for part in _UNSAFE):
|
|
242
|
+
raise error(f"Invalid {label}: {value!r}")
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentskills-testing
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Conformance suite and test doubles for Agent Skills providers (https://agentskills.io)
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Pratik Panda
|
|
7
|
+
Requires-Python: >=3.12,<4.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: Pytest
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Dist: agentskills-core (>=0.4.0,<1.0)
|
|
19
|
+
Requires-Dist: pytest (>=8.0)
|
|
20
|
+
Requires-Dist: pytest-asyncio (>=0.24)
|
|
21
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
22
|
+
Project-URL: Homepage, https://agentskills.io
|
|
23
|
+
Project-URL: Repository, https://github.com/pratikxpanda/agentskills-sdk
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# agentskills-testing
|
|
27
|
+
|
|
28
|
+
Conformance suite and test doubles for [Agent Skills](https://agentskills.io) providers.
|
|
29
|
+
|
|
30
|
+
`SkillProvider` is an abstract base class, which enforces that five methods exist
|
|
31
|
+
and nothing whatsoever about what they do. The requirements that actually matter
|
|
32
|
+
are the ones an ABC cannot express: that an unknown skill ID raises
|
|
33
|
+
`SkillNotFoundError` rather than returning `{}`, that `../../etc/passwd` is
|
|
34
|
+
refused rather than resolved, and that a provider advertising resource listing
|
|
35
|
+
can actually list. This package turns those into tests you inherit.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install agentskills-testing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Conformance suite
|
|
42
|
+
|
|
43
|
+
Subclass `ProviderConformanceSuite`, supply a `provider` fixture, and pytest
|
|
44
|
+
collects the entire provider contract against your implementation:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import pytest
|
|
48
|
+
|
|
49
|
+
from agentskills_testing import ProviderConformanceSuite
|
|
50
|
+
|
|
51
|
+
from my_package import MyProvider
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestMyProvider(ProviderConformanceSuite):
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def provider(self):
|
|
57
|
+
return MyProvider(...)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Fixture contract
|
|
61
|
+
|
|
62
|
+
The `provider` fixture must expose exactly one skill:
|
|
63
|
+
|
|
64
|
+
| | |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| id | `conformance-skill` |
|
|
67
|
+
| metadata | `name == "conformance-skill"`, plus a non-empty `description` |
|
|
68
|
+
| body | non-empty markdown |
|
|
69
|
+
| `references/` | `notes.md` containing `b"# Notes\n\nA reference document.\n"` |
|
|
70
|
+
| `scripts/` | `run.sh` containing `b"#!/bin/sh\necho conformance\n"` |
|
|
71
|
+
| `assets/` | `diagram.svg` containing `b"<svg></svg>\n"` |
|
|
72
|
+
|
|
73
|
+
It must not define a skill called `no-such-skill-anywhere`, or any resource
|
|
74
|
+
called `no-such-resource.txt`.
|
|
75
|
+
|
|
76
|
+
Every name and byte string above is exported as a constant (`SKILL_ID`,
|
|
77
|
+
`REFERENCE_NAME`, `REFERENCE_BYTES`, and so on), so a fixture can be built from
|
|
78
|
+
them rather than from copied literals. `CONTRACT` holds the same description as
|
|
79
|
+
a string, and is what the default fixture prints when you forget to override it.
|
|
80
|
+
|
|
81
|
+
### What it checks
|
|
82
|
+
|
|
83
|
+
- Metadata carries `name` and a non-empty `description`, and does **not** carry
|
|
84
|
+
the body — a metadata call that includes the body has already spent the tokens
|
|
85
|
+
progressive disclosure exists to save.
|
|
86
|
+
- Metadata is not shared mutable state: one caller mutating the returned dict
|
|
87
|
+
must not affect the next.
|
|
88
|
+
- Repeated reads agree. A provider that streams without buffering passes the
|
|
89
|
+
first read and returns empty on the second; caching bugs look the same.
|
|
90
|
+
- Each resource getter returns exact `bytes`, not `str`. A resource may be a
|
|
91
|
+
PNG, and decoding on the way out makes that unreachable.
|
|
92
|
+
- Unknown skills raise `SkillNotFoundError`; unknown resources raise
|
|
93
|
+
`ResourceNotFoundError`.
|
|
94
|
+
- `list_resources()` agrees with `supports_resource_listing`. Callers branch on
|
|
95
|
+
that flag, so a provider whose flag and behaviour disagree breaks them
|
|
96
|
+
whichever way it lies.
|
|
97
|
+
- `discover()` agrees with `supports_discovery`, and everything it reports can
|
|
98
|
+
actually be read. `register_all()` validates the whole list, so one phantom ID
|
|
99
|
+
fails the entire registration.
|
|
100
|
+
- **Traversal identifiers are refused** — parent traversal, absolute paths,
|
|
101
|
+
Windows separators, percent-encoded traversal, and embedded NUL bytes, as both
|
|
102
|
+
skill IDs and resource names. These are not opt-out.
|
|
103
|
+
- Concurrent reads through a single instance return consistent content, which
|
|
104
|
+
catches per-call state stored on `self`.
|
|
105
|
+
|
|
106
|
+
### Size limits
|
|
107
|
+
|
|
108
|
+
`ContentLimitConformanceSuite` is separate and opt-in. A size limit is not part
|
|
109
|
+
of the universal contract — an in-memory provider has no external source to
|
|
110
|
+
bound, and demanding one would assert a filesystem's constraints against a dict.
|
|
111
|
+
It **is** required of any provider that reads bytes it did not author: from
|
|
112
|
+
disk, from a network, from anywhere a caller can grow without asking.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from agentskills_testing import ContentLimitConformanceSuite
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestMyProviderLimits(ContentLimitConformanceSuite):
|
|
119
|
+
@pytest.fixture
|
|
120
|
+
def limited_provider(self):
|
|
121
|
+
return MyProvider(..., max_bytes=8)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The fixture holds the same skill, with a limit small enough that the skill
|
|
125
|
+
exceeds it.
|
|
126
|
+
|
|
127
|
+
## Test doubles
|
|
128
|
+
|
|
129
|
+
`InMemorySkillProvider` is a real, spec-compliant provider backed by a dict — it
|
|
130
|
+
passes the conformance suite above. Prefer it to an `AsyncMock`: a mock agrees
|
|
131
|
+
with whatever the test asserts, including the assertions that are wrong.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from agentskills_core import SkillRegistry
|
|
135
|
+
from agentskills_testing import InMemorySkillProvider, build_skill
|
|
136
|
+
|
|
137
|
+
provider = InMemorySkillProvider(
|
|
138
|
+
{
|
|
139
|
+
"incident-response": build_skill(
|
|
140
|
+
"incident-response",
|
|
141
|
+
description="Diagnose and mitigate a production incident.",
|
|
142
|
+
body="# Incident Response\n\nPage the on-call engineer.\n",
|
|
143
|
+
references={"severity-levels.md": b"SEV1 is customer-visible.\n"},
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
registry = SkillRegistry()
|
|
149
|
+
await registry.register_all(provider)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A string value is taken as the body, for the common case where the content does
|
|
153
|
+
not matter:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
provider = InMemorySkillProvider({"a": "body of a", "b": "body of b"})
|
|
157
|
+
provider.add("c") # a default skill named "c"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`build_skill()` produces frontmatter that passes `validate_skill()`, so a test
|
|
161
|
+
that does not care about metadata does not have to invent any.
|
|
162
|
+
`render_skill_md(skill)` renders one back to `SKILL.md` text, which is how you
|
|
163
|
+
populate a temporary directory for the filesystem provider.
|
|
164
|
+
|
|
165
|
+
To emulate a backend that cannot enumerate — a static HTTP host without a
|
|
166
|
+
manifest, for instance — pass `supports_resource_listing=False`, or
|
|
167
|
+
`supports_discovery=False`, or both.
|
|
168
|
+
|
|
169
|
+
## Fixtures
|
|
170
|
+
|
|
171
|
+
Installing the package registers a pytest plugin, so these are available with no
|
|
172
|
+
import and no `conftest.py` entry:
|
|
173
|
+
|
|
174
|
+
| Fixture | What you get |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| `sample_skill` | An `InMemorySkill` with one reference, one script, and one asset |
|
|
177
|
+
| `skill_provider` | An `InMemorySkillProvider` serving `sample_skill` |
|
|
178
|
+
| `skill_registry` | A `SkillRegistry` with that skill already registered |
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
async def test_my_agent(skill_registry):
|
|
182
|
+
catalog = await skill_registry.get_skills_catalog()
|
|
183
|
+
assert "incident-response" in catalog
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT — see [LICENSE](https://github.com/pratikxpanda/agentskills-sdk/blob/main/LICENSE).
|
|
189
|
+
|
|
190
|
+
Part of the [Agent Skills SDK](https://github.com/pratikxpanda/agentskills-sdk).
|
|
191
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
agentskills_testing/__init__.py,sha256=fGjXcSFSFAU_TOrRpHXdoVs6UWmRApJx1R9Y3QvQrjw,1433
|
|
2
|
+
agentskills_testing/conformance.py,sha256=gnxgFnlIlaLisdq6LUxPksaSooD1mvBEfdFLgBnRdsE,12968
|
|
3
|
+
agentskills_testing/fixtures.py,sha256=xoYu1RuCC4fRv4Zsi5WCLEDpmyqUuU4Lnvw3jKHvquM,1401
|
|
4
|
+
agentskills_testing/memory.py,sha256=bwZUHRjY_0r2VgGh5a9cXIAjx_jX-RLj-qvKHj_1Xgs,8800
|
|
5
|
+
agentskills_testing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
agentskills_testing-0.4.0.dist-info/METADATA,sha256=CR-kTsh38fsuOI0BJ9ZQFquL3sedAANXdPQGFOxmF5g,7292
|
|
7
|
+
agentskills_testing-0.4.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
8
|
+
agentskills_testing-0.4.0.dist-info/entry_points.txt,sha256=kXFwN-oLaNClC3yOwIEjYIaM1fRi6KgIfFGVV8w1yx4,61
|
|
9
|
+
agentskills_testing-0.4.0.dist-info/RECORD,,
|