agentskills-http 0.3.0__tar.gz → 0.4.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentskills-http
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: HTTP-based skill providers for the Agent Skills format (https://agentskills.io)
5
5
  License: MIT
6
6
  Author: Pratik Panda
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Software Development :: Libraries
16
- Requires-Dist: agentskills-core (>=0.3.0,<1.0)
16
+ Requires-Dist: agentskills-core (>=0.4.0,<1.0)
17
17
  Requires-Dist: httpx (>=0.27,<1.0)
18
18
  Requires-Dist: pyyaml (>=6.0,<7.0)
19
19
  Project-URL: Homepage, https://agentskills.io
@@ -108,6 +108,7 @@ provider = HTTPStaticFileSkillProvider("https://cdn.example.com/skills", client=
108
108
  | `max_response_bytes` | `int` | `10_485_760` | Maximum allowed response size in bytes |
109
109
  | `revalidate` | `bool` | `False` | Re-check cached `SKILL.md` on every access with `If-None-Match` / `If-Modified-Since` |
110
110
  | `resource_manifest` | `bool` | `False` | Enable `list_resources()` by reading a per-skill `index.json` |
111
+ | `skill_manifest` | `bool` | `False` | Enable `discover()` by reading a root `index.json` |
111
112
  | `timeout` | `float` | `30.0` | Request timeout in seconds (ignored when you supply `client`) |
112
113
  | `max_retries` | `int` | `2` | Retries after the initial attempt, for retryable failures only |
113
114
  | `retry_backoff` | `float` | `0.5` | Base delay in seconds for exponential backoff |
@@ -123,6 +124,7 @@ provider = HTTPStaticFileSkillProvider("https://cdn.example.com/skills", client=
123
124
  | `get_asset(skill_id, name)` | `bytes` | Raw asset content |
124
125
  | `get_reference(skill_id, name)` | `bytes` | Raw reference content |
125
126
  | `list_resources(skill_id)` | `dict[str, list[str]]` | Resource names from `index.json` (requires `resource_manifest=True`) |
127
+ | `discover()` | `list[str]` | Skill IDs from the root `index.json` (requires `skill_manifest=True`) |
126
128
  | `invalidate(skill_id=None)` | `None` | Drop cached `SKILL.md` content for one skill, or all skills |
127
129
  | `aclose()` | `None` | Close the HTTP client (if owned by the provider) |
128
130
 
@@ -151,6 +153,27 @@ listing = await provider.list_resources("incident-response")
151
153
 
152
154
  Missing categories default to empty lists. A manifest is host-supplied data whose entries are later interpolated into URLs, so names failing the identifier-safety check are dropped. If a given skill has no `index.json`, `list_resources()` raises `ResourceListingNotSupportedError` for that skill — again, not an empty result.
153
155
 
156
+ ## Skill Discovery
157
+
158
+ The same problem one level up: nothing on a static host says which skills exist. Publish the same
159
+ file at the root, listing skills instead of resources:
160
+
161
+ ```json
162
+ { "skills": ["incident-response", "api-style-guide"] }
163
+ ```
164
+
165
+ Then opt in and register the whole host at once:
166
+
167
+ ```python
168
+ async with HTTPStaticFileSkillProvider(BASE, skill_manifest=True) as provider:
169
+ await registry.register_all(provider)
170
+ ```
171
+
172
+ One filename and one shape — an object mapping a category to a list of names — at two depths,
173
+ rather than two manifest formats to keep in step. Unsafe and duplicate IDs are dropped as above.
174
+ Without `skill_manifest=True`, or when the root publishes no manifest, `discover()` raises
175
+ `DiscoveryNotSupportedError`.
176
+
154
177
  ## Caching
155
178
 
156
179
  `SKILL.md` responses are cached per provider instance. Without it a single skill costs up to five round-trips per agent session — twice during registration, once per catalog build, and again on each tool call. Scripts, assets and references are not cached.
@@ -86,6 +86,7 @@ provider = HTTPStaticFileSkillProvider("https://cdn.example.com/skills", client=
86
86
  | `max_response_bytes` | `int` | `10_485_760` | Maximum allowed response size in bytes |
87
87
  | `revalidate` | `bool` | `False` | Re-check cached `SKILL.md` on every access with `If-None-Match` / `If-Modified-Since` |
88
88
  | `resource_manifest` | `bool` | `False` | Enable `list_resources()` by reading a per-skill `index.json` |
89
+ | `skill_manifest` | `bool` | `False` | Enable `discover()` by reading a root `index.json` |
89
90
  | `timeout` | `float` | `30.0` | Request timeout in seconds (ignored when you supply `client`) |
90
91
  | `max_retries` | `int` | `2` | Retries after the initial attempt, for retryable failures only |
91
92
  | `retry_backoff` | `float` | `0.5` | Base delay in seconds for exponential backoff |
@@ -101,6 +102,7 @@ provider = HTTPStaticFileSkillProvider("https://cdn.example.com/skills", client=
101
102
  | `get_asset(skill_id, name)` | `bytes` | Raw asset content |
102
103
  | `get_reference(skill_id, name)` | `bytes` | Raw reference content |
103
104
  | `list_resources(skill_id)` | `dict[str, list[str]]` | Resource names from `index.json` (requires `resource_manifest=True`) |
105
+ | `discover()` | `list[str]` | Skill IDs from the root `index.json` (requires `skill_manifest=True`) |
104
106
  | `invalidate(skill_id=None)` | `None` | Drop cached `SKILL.md` content for one skill, or all skills |
105
107
  | `aclose()` | `None` | Close the HTTP client (if owned by the provider) |
106
108
 
@@ -129,6 +131,27 @@ listing = await provider.list_resources("incident-response")
129
131
 
130
132
  Missing categories default to empty lists. A manifest is host-supplied data whose entries are later interpolated into URLs, so names failing the identifier-safety check are dropped. If a given skill has no `index.json`, `list_resources()` raises `ResourceListingNotSupportedError` for that skill — again, not an empty result.
131
133
 
134
+ ## Skill Discovery
135
+
136
+ The same problem one level up: nothing on a static host says which skills exist. Publish the same
137
+ file at the root, listing skills instead of resources:
138
+
139
+ ```json
140
+ { "skills": ["incident-response", "api-style-guide"] }
141
+ ```
142
+
143
+ Then opt in and register the whole host at once:
144
+
145
+ ```python
146
+ async with HTTPStaticFileSkillProvider(BASE, skill_manifest=True) as provider:
147
+ await registry.register_all(provider)
148
+ ```
149
+
150
+ One filename and one shape — an object mapping a category to a list of names — at two depths,
151
+ rather than two manifest formats to keep in step. Unsafe and duplicate IDs are dropped as above.
152
+ Without `skill_manifest=True`, or when the root publishes no manifest, `discover()` raises
153
+ `DiscoveryNotSupportedError`.
154
+
132
155
  ## Caching
133
156
 
134
157
  `SKILL.md` responses are cached per provider instance. Without it a single skill costs up to five round-trips per agent session — twice during registration, once per catalog build, and again on each tool call. Scripts, assets and references are not cached.
@@ -16,11 +16,15 @@ Expected URL layout::
16
16
  └── another-skill/
17
17
  └── SKILL.md
18
18
 
19
- The provider is a pure content accessor — it does not enumerate or
20
- discover skills. Registration is handled explicitly by the application
21
- via :meth:`SkillRegistry.register <agentskills_core.SkillRegistry.register>`.
22
- Resource names (scripts, assets, references) are discovered by the agent
23
- from the skill body rather than from a manifest.
19
+ The provider is a pure content accessor by default — a plain static host
20
+ cannot be enumerated, so registration is explicit via
21
+ :meth:`SkillRegistry.register <agentskills_core.SkillRegistry.register>` and
22
+ resource names come from the skill body. A host that publishes
23
+ ``index.json`` manifests can enable both optional capabilities: a manifest
24
+ at ``{base_url}/index.json`` lists the skills, and one at
25
+ ``{base_url}/{skill_id}/index.json`` lists that skill's resources. Same
26
+ filename, same shape — a JSON object mapping a category to a list of names
27
+ — at two depths, rather than two manifest formats to keep in step.
24
28
 
25
29
  All methods are ``async`` and use `httpx <https://www.python-httpx.org/>`_
26
30
  for non-blocking HTTP requests.
@@ -44,6 +48,7 @@ import httpx
44
48
  from agentskills_core import (
45
49
  RESOURCE_KINDS,
46
50
  AgentSkillsError,
51
+ DiscoveryNotSupportedError,
47
52
  ResourceListingNotSupportedError,
48
53
  ResourceNotFoundError,
49
54
  SkillNotFoundError,
@@ -83,8 +88,12 @@ _NOT_FOUND_STATUS_CODES: frozenset[int] = frozenset({404, 410})
83
88
  #: Non-5xx statuses worth retrying.
84
89
  _RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({408, 425, 429})
85
90
 
86
- #: Per-skill manifest filename used for resource listing.
87
- RESOURCE_MANIFEST_NAME: str = "index.json"
91
+ #: Manifest filename. Served at the root it lists skills; served inside a
92
+ #: skill it lists that skill's resources.
93
+ MANIFEST_NAME: str = "index.json"
94
+
95
+ #: Manifest key holding skill IDs in a root manifest.
96
+ SKILLS_MANIFEST_KEY: str = "skills"
88
97
 
89
98
 
90
99
  def _parse_retry_after(value: str | None) -> float | None:
@@ -120,7 +129,9 @@ class HTTPStaticFileSkillProvider(SkillProvider):
120
129
  GitHub Pages, etc.) that hosts skill files at predictable URL paths.
121
130
  Resource names (scripts, assets, references) are discovered by the
122
131
  agent from the skill body, or from an optional per-skill
123
- ``index.json`` manifest -- see *resource_manifest*.
132
+ ``index.json`` manifest -- see *resource_manifest*. The set of
133
+ skills is likewise unknowable over plain HTTP unless the host
134
+ publishes a root ``index.json`` -- see *skill_manifest*.
124
135
 
125
136
  The provider owns an :class:`httpx.AsyncClient` for connection
126
137
  pooling. If you supply your own client the provider will use it
@@ -158,6 +169,13 @@ class HTTPStaticFileSkillProvider(SkillProvider):
158
169
  plain static host cannot be enumerated and claiming
159
170
  otherwise would make missing manifests look like skills with
160
171
  no resources.
172
+ skill_manifest: Set ``True`` if the host publishes a root
173
+ ``index.json`` with a ``"skills"`` list. Enables
174
+ :meth:`discover`, and so
175
+ :meth:`SkillRegistry.register_all
176
+ <agentskills_core.SkillRegistry.register_all>`. Defaults to
177
+ ``False`` for the same reason: without a manifest, "no
178
+ skills found" would be a lie.
161
179
  timeout: Request timeout in seconds. Ignored when you supply
162
180
  your own *client*.
163
181
  max_retries: Retries after the initial attempt, for retryable
@@ -199,6 +217,7 @@ class HTTPStaticFileSkillProvider(SkillProvider):
199
217
  max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
200
218
  revalidate: bool = False,
201
219
  resource_manifest: bool = False,
220
+ skill_manifest: bool = False,
202
221
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
203
222
  max_retries: int = DEFAULT_MAX_RETRIES,
204
223
  retry_backoff: float = DEFAULT_RETRY_BACKOFF_SECONDS,
@@ -237,6 +256,7 @@ class HTTPStaticFileSkillProvider(SkillProvider):
237
256
  self._revalidate = revalidate
238
257
  self._skill_md_cache: dict[str, _CachedSkillMd] = {}
239
258
  self.supports_resource_listing = resource_manifest
259
+ self.supports_discovery = skill_manifest
240
260
  self._max_retries = max_retries
241
261
  self._retry_backoff = retry_backoff
242
262
  self._max_retry_delay = max_retry_delay
@@ -277,14 +297,20 @@ class HTTPStaticFileSkillProvider(SkillProvider):
277
297
  # ------------------------------------------------------------------
278
298
 
279
299
  @staticmethod
280
- def _validate_identifier(value: str, label: str) -> None:
281
- """Raise :class:`ValueError` if *value* is not a safe URL path segment.
300
+ def _validate_identifier(value: str, label: str, error: type[AgentSkillsError]) -> None:
301
+ """Raise *error* if *value* is not a safe URL path segment.
282
302
 
283
303
  Prevents path-traversal attacks (e.g. ``../``) and other
284
304
  injection via ``skill_id`` or resource ``name``.
305
+
306
+ *error* is the exception the calling method documents — a
307
+ rejected identifier is indistinguishable from a missing one from
308
+ the caller's side, and raising something outside the
309
+ :class:`~agentskills_core.AgentSkillsError` hierarchy would force
310
+ callers to special-case this provider.
285
311
  """
286
312
  if not _SAFE_IDENTIFIER_RE.match(value):
287
- raise ValueError(
313
+ raise error(
288
314
  f"Invalid {label}: {value!r} — must start with an "
289
315
  f"alphanumeric character and contain only alphanumeric "
290
316
  f"characters, hyphens, dots, and underscores"
@@ -413,58 +439,121 @@ class HTTPStaticFileSkillProvider(SkillProvider):
413
439
  ResourceListingNotSupportedError: If the provider was built
414
440
  without ``resource_manifest=True``, or the skill has no
415
441
  published manifest.
442
+ SkillNotFoundError: If the skill itself does not exist.
416
443
  AgentSkillsError: If the manifest is not a JSON object.
417
444
  """
418
445
  if not self.supports_resource_listing:
419
446
  raise ResourceListingNotSupportedError(
420
447
  "This provider was not configured with a resource manifest. "
421
448
  "A static HTTP host cannot be enumerated. Pass "
422
- "resource_manifest=True if the host publishes "
423
- f"{RESOURCE_MANIFEST_NAME} per skill, otherwise take resource "
424
- "names from the skill body."
449
+ f"resource_manifest=True if the host publishes {MANIFEST_NAME} "
450
+ "per skill, otherwise take resource names from the skill body."
425
451
  )
426
452
 
427
- self._validate_identifier(skill_id, "skill_id")
428
- url = f"{self._base_url}/{quote(skill_id, safe='')}/{RESOURCE_MANIFEST_NAME}"
453
+ self._validate_identifier(skill_id, "skill_id", SkillNotFoundError)
454
+ url = f"{self._base_url}/{quote(skill_id, safe='')}/{MANIFEST_NAME}"
455
+ subject = f"Resource manifest for skill {skill_id!r}"
429
456
  try:
430
- raw = await self._get_bytes(url)
457
+ manifest = await self._fetch_manifest(url, subject)
431
458
  except ResourceNotFoundError as exc:
459
+ # A 404 here means either "this skill publishes no manifest" or
460
+ # "there is no such skill", and only SKILL.md can tell them
461
+ # apart. The extra request is on the failure path, and its
462
+ # result is cached for the caller who asks next.
463
+ await self._get_skill_md(skill_id)
432
464
  raise ResourceListingNotSupportedError(
433
- f"No {RESOURCE_MANIFEST_NAME} manifest published for skill "
465
+ f"No {MANIFEST_NAME} manifest published for skill "
434
466
  f"{skill_id!r}. Take resource names from the skill body instead."
435
467
  ) from exc
436
468
 
437
- try:
438
- manifest = json.loads(raw)
439
- except (UnicodeDecodeError, json.JSONDecodeError) as exc:
440
- raise AgentSkillsError(
441
- f"Resource manifest for skill {skill_id!r} is not valid JSON"
442
- ) from exc
469
+ return {kind: self._manifest_names(manifest, kind, subject) for kind in RESOURCE_KINDS}
443
470
 
444
- if not isinstance(manifest, dict):
445
- raise AgentSkillsError(
446
- f"Resource manifest for skill {skill_id!r} must be a JSON object"
447
- )
471
+ # ------------------------------------------------------------------
472
+ # Skill discovery
473
+ # ------------------------------------------------------------------
448
474
 
449
- listing: dict[str, list[str]] = {}
450
- for kind in RESOURCE_KINDS:
451
- entries = manifest.get(kind) or []
452
- if not isinstance(entries, list):
453
- raise AgentSkillsError(
454
- f"Resource manifest for skill {skill_id!r} has a non-list value for {kind!r}"
455
- )
456
- listing[kind] = sorted(
457
- name
458
- for name in entries
459
- if isinstance(name, str) and _SAFE_IDENTIFIER_RE.match(name)
475
+ async def discover(self) -> list[str]:
476
+ """List the host's skills from the root ``index.json`` manifest.
477
+
478
+ Requires ``skill_manifest=True``. The manifest is a JSON object
479
+ at ``{base_url}/index.json`` with a ``"skills"`` list::
480
+
481
+ {"skills": ["incident-response", "api-style-guide"]}
482
+
483
+ It is the same file format as the per-skill resource manifest,
484
+ one level up, so a host publishing both has one shape to
485
+ generate rather than two. Other keys are ignored, and entries
486
+ that are not valid skill IDs are dropped -- a manifest is
487
+ host-supplied data and an ID is later interpolated into a URL.
488
+
489
+ Returns:
490
+ Sorted skill IDs. Empty when the manifest lists none.
491
+
492
+ Raises:
493
+ DiscoveryNotSupportedError: If the provider was built
494
+ without ``skill_manifest=True``, or the host publishes
495
+ no root manifest.
496
+ AgentSkillsError: If the manifest is not a JSON object.
497
+ """
498
+ if not self.supports_discovery:
499
+ raise DiscoveryNotSupportedError(
500
+ "This provider was not configured with a skill manifest. "
501
+ "A static HTTP host cannot be enumerated. Pass "
502
+ f"skill_manifest=True if the host publishes a root {MANIFEST_NAME}, "
503
+ "otherwise register skills explicitly."
460
504
  )
461
505
 
462
- return listing
506
+ url = f"{self._base_url}/{MANIFEST_NAME}"
507
+ subject = "Skill manifest"
508
+ try:
509
+ manifest = await self._fetch_manifest(url, subject)
510
+ except ResourceNotFoundError as exc:
511
+ raise DiscoveryNotSupportedError(
512
+ f"No {MANIFEST_NAME} manifest published at {self._describe(url)}. "
513
+ "Register skills explicitly instead."
514
+ ) from exc
515
+
516
+ skill_ids = self._manifest_names(manifest, SKILLS_MANIFEST_KEY, subject)
517
+ _logger.debug("Discovered %d skills from %s", len(skill_ids), self._describe(url))
518
+ return skill_ids
463
519
 
464
520
  # ------------------------------------------------------------------
465
521
  # Internal helpers
466
522
  # ------------------------------------------------------------------
467
523
 
524
+ async def _fetch_manifest(self, url: str, subject: str) -> dict[str, Any]:
525
+ """Fetch and parse an ``index.json`` manifest.
526
+
527
+ Raises:
528
+ ResourceNotFoundError: If no manifest is published there.
529
+ Callers translate this into the "cannot enumerate"
530
+ error their own capability documents.
531
+ AgentSkillsError: If the manifest is not a JSON object.
532
+ """
533
+ raw = await self._get_bytes(url)
534
+ try:
535
+ manifest = json.loads(raw)
536
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
537
+ raise AgentSkillsError(f"{subject} is not valid JSON") from exc
538
+ if not isinstance(manifest, dict):
539
+ raise AgentSkillsError(f"{subject} must be a JSON object")
540
+ return manifest
541
+
542
+ @staticmethod
543
+ def _manifest_names(manifest: dict[str, Any], key: str, subject: str) -> list[str]:
544
+ """Return the safe, sorted, de-duplicated names under *key*.
545
+
546
+ A missing key means an empty list. Unsafe entries are dropped
547
+ rather than raising: one bad name in a host-supplied manifest
548
+ should not make the whole listing unavailable.
549
+ """
550
+ entries = manifest.get(key) or []
551
+ if not isinstance(entries, list):
552
+ raise AgentSkillsError(f"{subject} has a non-list value for {key!r}")
553
+ return sorted(
554
+ {name for name in entries if isinstance(name, str) and _SAFE_IDENTIFIER_RE.match(name)}
555
+ )
556
+
468
557
  def _describe(self, url: str) -> str:
469
558
  """Describe *url* for an error message or log record without leaking secrets.
470
559
 
@@ -611,7 +700,7 @@ class HTTPStaticFileSkillProvider(SkillProvider):
611
700
 
612
701
  async def _get_skill_md(self, skill_id: str) -> str:
613
702
  """Fetch a skill's ``SKILL.md``, serving from cache when possible."""
614
- self._validate_identifier(skill_id, "skill_id")
703
+ self._validate_identifier(skill_id, "skill_id", SkillNotFoundError)
615
704
  url = f"{self._base_url}/{quote(skill_id, safe='')}/SKILL.md"
616
705
 
617
706
  cached = self._skill_md_cache.get(skill_id)
@@ -644,7 +733,7 @@ class HTTPStaticFileSkillProvider(SkillProvider):
644
733
 
645
734
  async def _get_resource(self, skill_id: str, subdir: str, name: str) -> bytes:
646
735
  """Fetch a single resource file from a skill subdirectory."""
647
- self._validate_identifier(skill_id, "skill_id")
648
- self._validate_identifier(name, "resource name")
736
+ self._validate_identifier(skill_id, "skill_id", SkillNotFoundError)
737
+ self._validate_identifier(name, "resource name", ResourceNotFoundError)
649
738
  url = f"{self._base_url}/{quote(skill_id, safe='')}/{subdir}/{quote(name, safe='')}"
650
739
  return await self._get_bytes(url)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agentskills-http"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "HTTP-based skill providers for the Agent Skills format (https://agentskills.io)"
5
5
  license = "MIT"
6
6
  authors = ["Pratik Panda"]
@@ -19,7 +19,7 @@ classifiers = [
19
19
 
20
20
  [tool.poetry.dependencies]
21
21
  python = ">=3.12,<4.0"
22
- agentskills-core = ">=0.3.0,<1.0"
22
+ agentskills-core = ">=0.4.0,<1.0"
23
23
  httpx = ">=0.27,<1.0"
24
24
  pyyaml = ">=6.0,<7.0"
25
25