diff --git a/README.md b/README.md index 89b1ece..a5865cf 100644 --- a/README.md +++ b/README.md @@ -582,7 +582,9 @@ reader = plugin_reference_tool(skill, toolset="temporal-awareness") Called with no `file_path`, it returns every file under `references_dir` (recursively, as relative POSIX paths). Called with `file_path` set to a -path relative to `references_dir`, it returns that file's content. A +path relative to `references_dir`, it returns that file's content. It also +accepts the equivalent skill-relative `references/` form commonly +authored in `SKILL.md` files. A `file_path` that resolves outside `references_dir` — including through a symlink, an absolute path, or a `../` chain — is rejected rather than followed; a non-string `file_path` is rejected with a clean error instead of diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index a23559d..8da2893 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -1381,8 +1381,9 @@ def plugin_reference_tool( Call with no arguments (or ``file_path=None``) to list every file under ``references_dir``. Call with ``file_path`` set to a path relative to ``references_dir`` to read that file's - content; a path that resolves outside ``references_dir`` (including via a symlink) is - rejected. + content. Skill-relative paths beginning with ``references/`` are accepted too, matching the + conventional paths authored in ``SKILL.md`` files. A path that resolves outside + ``references_dir`` (including via a symlink) is rejected. Returns a ready-to-register tool function -- include it in the plugin's own declarations passed to ``register_plugin``. Raises :class:`ValueError` immediately if ``skill`` has no @@ -1405,7 +1406,8 @@ def plugin_reference_tool( tool_name = name or _default_reference_tool_name(skill.name) tool_description = description or ( f"List or read companion reference files for the {skill.name!r} skill. Omit file_path " - "to list every available file; pass file_path to read one file's content." + "to list every available file; pass either a listed path or its skill-relative " + "references/ form to read one file's content." ) def _read_plugin_reference(args: dict, **_: Any) -> dict: @@ -1422,7 +1424,11 @@ def _read_plugin_reference(args: dict, **_: Any) -> dict: if not isinstance(file_path, str): raise TypeError(f"file_path must be a string, got {type(file_path).__name__}") - candidate = (references_dir / file_path).resolve() + path = Path(file_path) + if path.parts[:1] == ("references",): + path = Path(*path.parts[1:]) + + candidate = (references_dir / path).resolve() try: candidate.relative_to(resolved_root) except ValueError: diff --git a/skills/hermes-plugins/references/plugin-kit.md b/skills/hermes-plugins/references/plugin-kit.md index 98a4206..c9d201b 100644 --- a/skills/hermes-plugins/references/plugin-kit.md +++ b/skills/hermes-plugins/references/plugin-kit.md @@ -28,7 +28,7 @@ guidance, not a second implementation specification. | Request or execution middleware | `@middleware`, `MiddlewareKind` | `register_plugin` | Callback is synchronous; request phases replace payloads, execution phases call single-use `next_call`. | | Lifecycle hook | `@hook` | `register_plugin` | Hermes kwargs and return values pass through; exceptions are re-raised for Hermes isolation. | | Plugin-owned skill | `plugin_skill` | `register_plugin(..., skills=...)` | Hermes adds the plugin namespace; missing required skills fail, optional skills warn and skip. Optional `references_dir` (a companion reference-files directory) is forward-compatible groundwork only — no released host surfaces it yet; see [`README.md`](../../../README.md#commands-middleware-hooks-and-plugin-skills). | -| Agent-visible skill reference files (today) | `plugin_reference_tool` | `register_plugin` (its return value is an ordinary `@tool` function — add it to the plugin's declarations) | Sidesteps `register_skill` entirely by exposing `references_dir` as a normal tool call; rejects any `file_path` that resolves outside `references_dir` (symlink, absolute path, or `../`) and any non-string `file_path`. `references_dir` itself must be inside the skill's own directory — enforced by `plugin_skill` and re-checked here. | +| Agent-visible skill reference files (today) | `plugin_reference_tool` | `register_plugin` (its return value is an ordinary `@tool` function — add it to the plugin's declarations) | Sidesteps `register_skill` entirely by exposing `references_dir` as a normal tool call; accepts both paths listed by the tool and skill-relative `references/` forms, while rejecting any path that resolves outside `references_dir` (symlink, absolute path, or `../`) and any non-string `file_path`. `references_dir` itself must be inside the skill's own directory — enforced by `plugin_skill` and re-checked here. | | Context engine | Hermes `ContextEngine` instance | `register_plugin(..., context_engine=...)` | Singular native engine registration; schemas and recovery dispatch stay in `get_tool_schemas()` / `handle_tool_call()`, never duplicated with `@tool`. | | Host-managed call | `invoke_host_tool` | None | Use for supported non-registry capabilities such as `send_message`; pre/post-tool hooks remain active. | | Local media delivery | `MediaPayload`, `MediaType`, `deliver_media` | Consumer registers suppression hooks | File must be absolute, present, and non-empty; `origin` resolves from task-local Hermes context. | diff --git a/tests/test_kit.py b/tests/test_kit.py index ade52ec..ea6ae57 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -2318,6 +2318,25 @@ def test_reads_a_specific_file(self) -> None: self.assertTrue(payload["success"]) self.assertEqual("hello world", payload["data"]["content"]) + def test_reads_skill_relative_paths_with_references_prefix(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + skill = self._skill_with_references( + tmp, + **{ + "a.md": "top-level", + "nested/b.md": "nested", + }, + ) + reader = hpk.plugin_reference_tool(skill, toolset="sample") + + top_level = json.loads(reader({"file_path": "references/a.md"})) + nested = json.loads(reader({"file_path": "references/nested/b.md"})) + + self.assertTrue(top_level["success"]) + self.assertEqual("top-level", top_level["data"]["content"]) + self.assertTrue(nested["success"]) + self.assertEqual("nested", nested["data"]["content"]) + def test_accepts_custom_name_and_description(self) -> None: with tempfile.TemporaryDirectory() as tmp: skill = self._skill_with_references(tmp, **{"a.md": "hi"})