diff --git a/README.md b/README.md index 2d22f05..f42aef9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ These are **not bundled with `hermes-agent`**. The core repo ships only the plug | Plugin | Surface | Demonstrates | |---|---|---| +| [`plugin-tool-example`](./plugin-tool-example) | `ctx.register_tool()` | A model-callable tool — JSON Schema, argument validation, structured `tool_result()` / `tool_error()` responses | | [`plugin-llm-example`](./plugin-llm-example) | `ctx.llm.complete_structured()` | Host-owned structured LLM calls — typed text/image input, JSON Schema validation, trust-gate config | | [`plugin-llm-async-example`](./plugin-llm-async-example) | `ctx.llm.acomplete()` + `asyncio.gather()` | Async LLM lane — concurrent forward + sentiment + back-translation pass for `/translate` | | [`example-dashboard`](./example-dashboard) | `dashboard/manifest.json` | Bare-minimum dashboard plugin — a tab, a slot injection, a backend route | @@ -21,12 +22,14 @@ Each directory is a self-contained plugin. To run one in your own Hermes Agent s git clone https://github.com/NousResearch/hermes-example-plugins.git # pick whichever you want +cp -r hermes-example-plugins/plugin-tool-example ~/.hermes/plugins/ cp -r hermes-example-plugins/plugin-llm-example ~/.hermes/plugins/ cp -r hermes-example-plugins/plugin-llm-async-example ~/.hermes/plugins/ cp -r hermes-example-plugins/example-dashboard ~/.hermes/plugins/ cp -r hermes-example-plugins/strike-freedom-cockpit ~/.hermes/plugins/ -# enable any with a slash command surface +# enable whichever examples you copied +hermes plugins enable plugin-tool-example hermes plugins enable plugin-llm-example hermes plugins enable plugin-llm-async-example ``` @@ -41,6 +44,7 @@ Pair each plugin in this repo with its docs page: | Plugin here | Docs page | |---|---| +| `plugin-tool-example` | [Build a Hermes Plugin](https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin) | | `plugin-llm-example` | [Plugin LLM Access](https://hermes-agent.nousresearch.com/docs/developer-guide/plugin-llm-access) | | `plugin-llm-async-example` | [Plugin LLM Access](https://hermes-agent.nousresearch.com/docs/developer-guide/plugin-llm-access) | | `example-dashboard` | [Extending the Dashboard](https://hermes-agent.nousresearch.com/docs/user-guide/features/extending-the-dashboard) | diff --git a/plugin-tool-example/README.md b/plugin-tool-example/README.md new file mode 100644 index 0000000..7d77a2a --- /dev/null +++ b/plugin-tool-example/README.md @@ -0,0 +1,67 @@ +# plugin-tool-example + +Reference plugin showing the smallest production-shaped `ctx.register_tool()` +surface: one deterministic tool, one JSON Schema, one handler. + +## What it does + +Registers a model-callable `text_stats` tool that summarizes a text snippet: + +```json +{ + "success": true, + "characters": 43, + "lines": 2, + "words": 8, + "unique_words": 7, + "top_words": [ + {"word": "hermes", "count": 2}, + {"word": "agent", "count": 1} + ] +} +``` + +This is intentionally plain Python. The example is about the plugin boundary, +not the analysis itself: + +- the tool schema tells the model when and how to call it, +- the handler validates user-supplied arguments, +- `tool_result()` and `tool_error()` keep responses machine-readable, +- `register(ctx)` exposes the tool under its own `tool_example` toolset. + +## How it works + +```python +ctx.register_tool( + name="text_stats", + toolset="tool_example", + schema=TEXT_STATS_SCHEMA, + handler=_handle_text_stats, + description="Count lines, words, and common tokens in a text snippet.", +) +``` + +The handler accepts the same `args: dict, **kwargs` shape used by bundled +Hermes tools. It rejects empty or oversized payloads, clamps `top_n`, and +returns stable JSON so the model can use the result without reparsing prose. + +## Try it + +```bash +git clone https://github.com/NousResearch/hermes-example-plugins.git +cp -r hermes-example-plugins/plugin-tool-example ~/.hermes/plugins/ +hermes plugins enable plugin-tool-example +``` + +Then start a Hermes session and ask for text statistics. The model can call +`text_stats` when the `tool_example` toolset is available. + +## Files + +| File | Purpose | +|---|---| +| `__init__.py` | Tool schema, handler, and `register(ctx)` entry point | +| `plugin.yaml` | Plugin manifest | + +For a full multi-file tutorial that also covers hooks, CLI commands, and data +files, see [Build a Hermes Plugin](https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin). diff --git a/plugin-tool-example/__init__.py b/plugin-tool-example/__init__.py new file mode 100644 index 0000000..8ca9e1b --- /dev/null +++ b/plugin-tool-example/__init__.py @@ -0,0 +1,103 @@ +""" +plugin-tool-example - minimal reference plugin for ``ctx.register_tool()``. + +Demonstrates the smallest production-shaped general-tool plugin: + +* one JSON Schema that tells the model what the tool accepts, +* one deterministic handler with argument validation, +* structured JSON responses via ``tool_result`` / ``tool_error``, +* one ``ctx.register_tool`` call in ``register(ctx)``. +""" + +from __future__ import annotations + +import logging +import re +from collections import Counter +from typing import Any + +from tools.registry import tool_error, tool_result + +logger = logging.getLogger(__name__) + +_MAX_TEXT_CHARS = 20_000 +_WORD_RE = re.compile(r"[A-Za-z0-9]+(?:['-][A-Za-z0-9]+)?") + + +TEXT_STATS_SCHEMA = { + "description": ( + "Count lines, words, unique words, and common tokens in a text snippet. " + "Use this when the user asks for lightweight text statistics rather than " + "semantic analysis." + ), + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to analyze, up to 20,000 characters.", + }, + "top_n": { + "type": "integer", + "description": "How many common words to return (1-10). Defaults to 5.", + "minimum": 1, + "maximum": 10, + }, + }, + "required": ["text"], + "additionalProperties": False, + }, +} + + +def _coerce_top_n(raw: Any) -> int: + try: + value = int(raw) + except (TypeError, ValueError): + value = 5 + return max(1, min(10, value)) + + +def _handle_text_stats(args: dict, **kwargs: Any) -> str: + """Return deterministic text metrics for a caller-provided snippet.""" + del kwargs + + text = args.get("text") + if not isinstance(text, str) or not text.strip(): + return tool_error("text must be a non-empty string") + if len(text) > _MAX_TEXT_CHARS: + return tool_error( + "text exceeds the 20,000 character limit", + max_chars=_MAX_TEXT_CHARS, + ) + + top_n = _coerce_top_n(args.get("top_n", 5)) + words = [match.group(0).casefold() for match in _WORD_RE.finditer(text)] + counts = Counter(words) + top_words = [ + {"word": word, "count": count} + for word, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))[ + :top_n + ] + ] + + return tool_result({ + "success": True, + "characters": len(text), + "lines": len(text.splitlines()) or 1, + "words": len(words), + "unique_words": len(counts), + "top_words": top_words, + }) + + +def register(ctx: Any) -> None: + """Plugin entry point - expose one model-callable tool.""" + ctx.register_tool( + name="text_stats", + toolset="tool_example", + schema=TEXT_STATS_SCHEMA, + handler=_handle_text_stats, + description="Count lines, words, and common tokens in a text snippet.", + ) + logger.debug("plugin-tool-example: registered text_stats") diff --git a/plugin-tool-example/plugin.yaml b/plugin-tool-example/plugin.yaml new file mode 100644 index 0000000..7b17990 --- /dev/null +++ b/plugin-tool-example/plugin.yaml @@ -0,0 +1,7 @@ +name: plugin-tool-example +version: 1.0.0 +description: "Reference plugin for ctx.register_tool() - registers one deterministic text_stats tool with JSON Schema validation and structured responses." +author: NousResearch +hooks: [] +provides_tools: + - text_stats