From 96995afbc2c4512b66d20126e244d09b77a00798 Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Tue, 7 Apr 2026 15:16:14 +0200 Subject: [PATCH] feat(hermes): add native plugin and docs This gives Hermes a first-class fixed-tool SLOP integration that reuses the Python discovery stack. It also documents the install flow, runtime model, and discovery tradeoffs alongside the existing agent integrations. --- docs/api/index.md | 1 + docs/api/python.md | 3 + docs/api/slop-hermes.md | 59 +++++ docs/guides/advanced/hermes.md | 150 ++++++++++++ docs/sdk/discovery.md | 24 +- packages/python/slop-hermes/README.md | 61 +++++ packages/python/slop-hermes/pyproject.toml | 39 +++ .../python/slop-hermes/pyrightconfig.json | 7 + .../slop-hermes/src/slop_hermes/__init__.py | 5 + .../slop-hermes/src/slop_hermes/actions.py | 129 ++++++++++ .../slop-hermes/src/slop_hermes/plugin.py | 231 ++++++++++++++++++ .../slop-hermes/src/slop_hermes/render.py | 129 ++++++++++ .../slop-hermes/src/slop_hermes/runtime.py | 213 ++++++++++++++++ packages/python/slop-hermes/tests/conftest.py | 13 + .../python/slop-hermes/tests/test_actions.py | 101 ++++++++ .../python/slop-hermes/tests/test_plugin.py | 32 +++ .../python/slop-hermes/tests/test_render.py | 98 ++++++++ website/docs/content-manifest.mjs | 11 + website/docs/src/content/docs/api/index.md | 1 + website/docs/src/content/docs/api/python.md | 3 + .../docs/src/content/docs/api/slop-hermes.md | 61 +++++ .../content/docs/guides/advanced/hermes.md | 152 ++++++++++++ .../docs/src/content/docs/sdk/discovery.md | 24 +- 23 files changed, 1543 insertions(+), 4 deletions(-) create mode 100644 docs/api/slop-hermes.md create mode 100644 docs/guides/advanced/hermes.md create mode 100644 packages/python/slop-hermes/README.md create mode 100644 packages/python/slop-hermes/pyproject.toml create mode 100644 packages/python/slop-hermes/pyrightconfig.json create mode 100644 packages/python/slop-hermes/src/slop_hermes/__init__.py create mode 100644 packages/python/slop-hermes/src/slop_hermes/actions.py create mode 100644 packages/python/slop-hermes/src/slop_hermes/plugin.py create mode 100644 packages/python/slop-hermes/src/slop_hermes/render.py create mode 100644 packages/python/slop-hermes/src/slop_hermes/runtime.py create mode 100644 packages/python/slop-hermes/tests/conftest.py create mode 100644 packages/python/slop-hermes/tests/test_actions.py create mode 100644 packages/python/slop-hermes/tests/test_plugin.py create mode 100644 packages/python/slop-hermes/tests/test_render.py create mode 100644 website/docs/src/content/docs/api/slop-hermes.md create mode 100644 website/docs/src/content/docs/guides/advanced/hermes.md diff --git a/docs/api/index.md b/docs/api/index.md index 75b5dba..2984e65 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -24,6 +24,7 @@ This page maps every published package in the repo to its primary use case and t | Package | Use it for | Install | Docs | | --- | --- | --- | --- | | `slop-ai` for Python | FastAPI, services, local tools, Python consumers | `pip install slop-ai[websocket]` | [API](/api/python), [Guide](/guides/python) | +| `slop-hermes` | Hermes Agent integration | `pip install slop-hermes` | [API](/api/slop-hermes), [Guide](/guides/advanced/hermes) | | `slop-ai` for Go | `net/http` services, daemons, CLI tools, Go consumers | `go get github.com/devteapot/slop/packages/go/slop-ai` | [API](/api/go), [Guide](/guides/go) | | `slop-ai` for Rust | Axum apps, services, daemons, CLI tools, Rust consumers | `cargo add slop-ai` | [API](/api/rust), [Guide](/guides/rust) | diff --git a/docs/api/python.md b/docs/api/python.md index e7fb208..50d7684 100644 --- a/docs/api/python.md +++ b/docs/api/python.md @@ -15,6 +15,7 @@ from slop_ai.transports.asgi import SlopMiddleware - provider APIs via `SlopServer` - consumer APIs via `SlopConsumer` +- discovery helpers via `slop_ai.discovery` for provider scanning, bridge relay, lazy connect, and AI-facing tool helpers - transport modules for ASGI, WebSocket, Unix socket, stdio, and matching client transports - scaling helpers such as `prepare_tree()` and `truncate_tree()` - LLM tool helpers such as `affordances_to_tools()` and `format_tree()` @@ -30,3 +31,5 @@ from slop_ai.transports.asgi import SlopMiddleware - [Python guide](/guides/python) - [Consumer guide](/guides/consumer) +- [Discovery & Bridge](/sdk/discovery) +- [slop-hermes package](/api/slop-hermes) — Hermes Agent integration built on the Python discovery layer diff --git a/docs/api/slop-hermes.md b/docs/api/slop-hermes.md new file mode 100644 index 0000000..226bdb4 --- /dev/null +++ b/docs/api/slop-hermes.md @@ -0,0 +1,59 @@ +# slop-hermes + +`slop-hermes` is a Python package that installs a Hermes plugin for SLOP app discovery and control. + +It exposes five tools: + +- `list_apps` — list available apps +- `connect_app` — connect and inspect an app +- `disconnect_app` — stop tracking an app +- `app_action` — perform a single action +- `app_action_batch` — perform multiple actions in one call + +## Install + +```bash +pip install slop-hermes +``` + +For local development from this repo: + +```bash +pip install -e /path/to/slop/packages/python/slop-hermes +``` + +Install it into the same Python environment as Hermes. + +## How it works + +The package registers a `hermes_agent.plugins` entry point named `slop`. + +At runtime it: + +- starts a background `slop_ai.discovery.DiscoveryService` +- discovers local and browser-backed SLOP providers +- injects connected provider state through Hermes' `pre_llm_call` hook +- exposes a stable five-tool surface for app lifecycle and actions + +## Discovery sources + +- `~/.slop/providers/*.json` +- `/tmp/slop/providers/*.json` +- browser extension bridge at `ws://127.0.0.1:9339/slop-bridge` + +## Environment variables + +- `SLOP_HERMES_MAX_NODES` — max nodes included in injected state trees. Default: `160` +- `SLOP_HERMES_MIN_SALIENCE` — optional salience threshold for injected trees + +## Operational notes + +- Designed first for local CLI / single-user Hermes +- Uses stable meta-tools rather than per-affordance dynamic tools +- Builds on the mirrored Python discovery layer in `slop_ai.discovery` + +## Related pages + +- [Hermes integration guide](/guides/advanced/hermes) +- [Python SDK API](/api/python) +- [Discovery & Bridge](/sdk/discovery) diff --git a/docs/guides/advanced/hermes.md b/docs/guides/advanced/hermes.md new file mode 100644 index 0000000..4efb5b4 --- /dev/null +++ b/docs/guides/advanced/hermes.md @@ -0,0 +1,150 @@ +# Hermes Integration + +SLOP ships a Hermes plugin at `packages/python/slop-hermes`. + +It gives Hermes five stable tools for discovering and controlling SLOP-enabled applications: + +- `list_apps` +- `connect_app` +- `disconnect_app` +- `app_action` +- `app_action_batch` + +## What it does + +- **Discovers** SLOP apps from local provider descriptors and the browser extension bridge through `slop_ai.discovery` +- **Injects live state** into each Hermes turn through the plugin `pre_llm_call` hook +- **Returns an immediate snapshot** through `connect_app`, including the current state tree and action summary +- **Acts through stable meta-tools** so Hermes always sees the same tool catalog +- **Supports multiple apps** connected simultaneously + +## Install + +Install `slop-hermes` into the same Python environment as Hermes: + +```bash +pip install slop-hermes +``` + +From a local checkout: + +```bash +pip install -e /path/to/slop/packages/python/slop-hermes +``` + +Verify that Hermes sees the plugin: + +```bash +hermes plugins list +``` + +You should see a `slop` plugin entry. + +If you explicitly manage toolsets, make sure the plugin toolset is enabled: + +```bash +hermes tools +``` + +Or include it directly when starting chat: + +```bash +hermes chat --toolsets slop,web,terminal +``` + +## How it works + +### Plugin loading + +`slop-hermes` is a pip-installed Hermes plugin. It registers itself through the `hermes_agent.plugins` entry point and exposes a `slop` toolset. + +### In-process discovery runtime + +The plugin starts a background asyncio runtime on first use. That runtime owns a `slop_ai.discovery.DiscoveryService`, which handles: + +- `~/.slop/providers/*.json` descriptor discovery +- `/tmp/slop/providers/*.json` descriptor discovery +- browser extension bridge discovery at `ws://127.0.0.1:9339/slop-bridge` +- connection management for Unix socket, WebSocket, and relay-backed providers + +### Hook-based state injection + +On every Hermes turn, the plugin's `pre_llm_call` hook injects a compact summary of connected and available apps into the current user message: + +````text +## SLOP Apps + +1 app(s) connected. Read the state trees below before acting... + +### Kanban (kanban) + +``` +[root] kanban: Kanban Board actions: {add_card(title: string)} + [collection] backlog "12 cards" +``` + +### Available (not connected) + +- **Calendar** (id: `calendar`, ws, bridge) +```` + +Hermes can answer from injected state directly, or use the five SLOP tools when it needs to connect, refresh, or act. + +### State compaction + +The injected tree is compacted before formatting so prompt usage stays bounded. + +Available knobs: + +- `SLOP_HERMES_MAX_NODES` — max nodes included in injected trees. Default: `160` +- `SLOP_HERMES_MIN_SALIENCE` — optional salience threshold before rendering + +## Tools + +| Tool | Purpose | +| --- | --- | +| `list_apps` | List all discovered SLOP-enabled apps and show which are already connected | +| `connect_app` | Connect to an app, return its current state tree plus action summary, and enroll it in injected context | +| `disconnect_app` | Disconnect from an app | +| `app_action` | Invoke one affordance on a node | +| `app_action_batch` | Invoke multiple affordances in a single call | + +## Why the Hermes plugin uses meta-tools + +The Hermes plugin currently uses the same fixed-tool model as the Codex and OpenClaw integrations: + +- Hermes plugin tools are registered once during plugin initialization +- prompt injection gives Hermes live app state before every turn +- actions still go through `app_action` and `app_action_batch` + +This keeps the integration simple and predictable while matching Hermes' plugin model. + +## Operational scope + +This first version is designed primarily for **local CLI / single-user Hermes**. + +The discovery runtime is process-global, which is a good fit for a single local Hermes instance controlling apps on the same machine. If you need stricter multi-user isolation or remote-host integration, the MCP route is still the safer boundary. + +## Example interaction + +```text +User: What apps are available? +→ Hermes calls list_apps + +User: Connect to the kanban board +→ Hermes calls connect_app("kanban") + +User: Add three cards to backlog +→ Hermes reads the injected Kanban tree from context, then calls app_action_batch with three add_card actions + +User: Disconnect from kanban +→ Hermes calls disconnect_app("kanban") +``` + +## Related + +- [slop-hermes package API](/api/slop-hermes) +- [Discovery & Bridge](/sdk/discovery) — shared discovery model used by the plugin +- [Python SDK API](/api/python) — underlying `slop_ai.discovery` implementation +- [Codex integration](/guides/advanced/codex) — another fixed-tool integration +- [OpenClaw integration](/guides/advanced/openclaw) — similar meta-tool pattern diff --git a/docs/sdk/discovery.md b/docs/sdk/discovery.md index 415bb76..9ce0945 100644 --- a/docs/sdk/discovery.md +++ b/docs/sdk/discovery.md @@ -366,11 +366,11 @@ Host-specific wrappers, tool-helper layers, and prompt injection are intentional ## Integrations -Both the Claude Code and OpenClaw plugins follow the same design principles: +The Codex, Claude Code, OpenClaw, and Hermes integrations all follow the same design principles: - **State injection** — Provider state is injected into the model's context before each turn, not fetched via tool calls - **Minimal tool usage** — Tools are used only for connecting to apps and performing actions, never for reading state -- **Shared discovery** — Both build on `@slop-ai/discovery/service` for provider scanning and connection orchestration +- **Shared discovery contract** — TypeScript hosts build on `@slop-ai/discovery/service`; Hermes uses the mirrored Python implementation in `slop_ai.discovery` Where they differ is **action dispatch**, due to host platform limitations. @@ -397,6 +397,7 @@ Dynamic tools have proper parameter schemas from the provider's affordance defin | Codex | No (current plugin) | Stable MCP tools + `UserPromptSubmit` hook-based state injection | No runtime tool registration; actions still go through meta-tools | | Claude Code (MCP) | Yes | `notifications/tools/list_changed` — server notifies client when tool list changes | None | | OpenClaw | No | `api.registerTool()` is one-time during `register()` | No runtime tool registration API; tools must be declared in the plugin manifest | +| Hermes | No | `register_tool()` + `pre_llm_call` hook-based state injection | Plugin tools are registered once; current package targets local CLI / single-user use | Hosts without dynamic tool support fall back to the **meta-tool pattern**: stable tools (`app_action`, `app_action_batch`) that resolve actions at runtime. Depending on the host, the model learns the exact paths and action names from prompt-time state injection or from an explicit `connect_app` inspection step. @@ -452,6 +453,24 @@ Design details: See [OpenClaw guide](/guides/advanced/openclaw) for setup and usage. +### Hermes plugin (`slop-hermes`) + +| Component | Purpose | +|---|---| +| **Tools** | `list_apps`, `connect_app`, `disconnect_app`, `app_action`, `app_action_batch` | +| **Hook** (`pre_llm_call`) | Injects connected providers' state trees into the current Hermes turn | +| **Runtime** | Background Python discovery service using `slop_ai.discovery` | + +Design details: + +- **Fixed tool surface** — The Hermes plugin registers the same stable five-tool catalog as the Codex and OpenClaw integrations. +- **In-process state injection** — The `pre_llm_call` hook injects fresh `## SLOP Apps` markdown directly into the current turn. No file-based IPC is needed. +- **Python discovery parity** — Uses `slop_ai.discovery` for local descriptor watching, bridge support, lazy connect, reconnect, and idle disconnect behavior. +- **Bounded injection** — Connected trees are compacted with `prepare_tree(max_nodes=...)` before rendering so prompt growth stays controlled. +- **Operational scope** — The current package is aimed at local CLI / single-user Hermes. Connected-provider state is process-global. + +See [Hermes guide](/guides/advanced/hermes) for setup and usage. + ## Related - [Consumer SDK API](/api/consumer) — protocol client reference @@ -460,3 +479,4 @@ See [OpenClaw guide](/guides/advanced/openclaw) for setup and usage. - [Consumer guide](/guides/consumer) — usage patterns and example workflows - [Codex guide](/guides/advanced/codex) — Codex plugin setup and usage - [Claude Code guide](/guides/advanced/claude-code) — Claude Code plugin setup and usage +- [Hermes guide](/guides/advanced/hermes) — Hermes plugin setup and usage diff --git a/packages/python/slop-hermes/README.md b/packages/python/slop-hermes/README.md new file mode 100644 index 0000000..374f5b8 --- /dev/null +++ b/packages/python/slop-hermes/README.md @@ -0,0 +1,61 @@ +# `slop-hermes` + +Hermes Agent plugin for the [SLOP protocol](https://slopai.dev). + +This package adds a fixed 5-tool SLOP integration to Hermes: + +- `list_apps` +- `connect_app` +- `disconnect_app` +- `app_action` +- `app_action_batch` + +It also injects connected SLOP app state into each Hermes turn so the model can +see live state before calling actions. + +## Install + +Install into the same Python environment as Hermes: + +```bash +pip install slop-hermes +``` + +For local development from this repo: + +```bash +pip install -e /path/to/slop/packages/python/slop-hermes +``` + +Hermes discovers the plugin through the `hermes_agent.plugins` entry point. + +Verify the plugin is visible: + +```bash +hermes plugins list +``` + +If you explicitly manage toolsets, enable `slop` in `hermes tools` or include it in your session: + +```bash +hermes chat --toolsets slop,web,terminal +``` + +## How it works + +- starts a background `slop_ai.discovery.DiscoveryService` +- discovers local providers from `~/.slop/providers/*.json` and `/tmp/slop/providers/*.json` +- connects to browser-backed providers through the extension bridge at `ws://127.0.0.1:9339/slop-bridge` +- injects connected app state into Hermes through the `pre_llm_call` hook +- performs actions through the fixed `app_action` and `app_action_batch` tools + +## Environment variables + +- `SLOP_HERMES_MAX_NODES` — max nodes included in injected trees. Default: `160` +- `SLOP_HERMES_MIN_SALIENCE` — optional salience threshold for injected trees + +## Notes + +- This first version is designed primarily for local CLI / single-user Hermes. +- Browser bridge and direct WebSocket discovery use `slop-ai[websocket]`, which + is installed automatically with this package. diff --git a/packages/python/slop-hermes/pyproject.toml b/packages/python/slop-hermes/pyproject.toml new file mode 100644 index 0000000..f25e2ed --- /dev/null +++ b/packages/python/slop-hermes/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "slop-hermes" +version = "0.1.0" +description = "Hermes Agent plugin for discovering and controlling SLOP applications" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +dependencies = ["slop-ai[websocket]"] +keywords = ["slop", "hermes", "plugin", "agent", "ai"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.optional-dependencies] +test = ["pytest>=9.0"] + +[project.entry-points."hermes_agent.plugins"] +slop = "slop_hermes:register" + +[project.urls] +Homepage = "https://slopai.dev" +Repository = "https://github.com/devteapot/slop" +Documentation = "https://docs.slopai.dev" + +[tool.hatch.build.targets.wheel] +packages = ["src/slop_hermes"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/python/slop-hermes/pyrightconfig.json b/packages/python/slop-hermes/pyrightconfig.json new file mode 100644 index 0000000..3525aa4 --- /dev/null +++ b/packages/python/slop-hermes/pyrightconfig.json @@ -0,0 +1,7 @@ +{ + "include": ["src", "tests"], + "extraPaths": [ + "src", + "../slop-ai/src" + ] +} diff --git a/packages/python/slop-hermes/src/slop_hermes/__init__.py b/packages/python/slop-hermes/src/slop_hermes/__init__.py new file mode 100644 index 0000000..105b0eb --- /dev/null +++ b/packages/python/slop-hermes/src/slop_hermes/__init__.py @@ -0,0 +1,5 @@ +"""Hermes plugin entrypoint for SLOP app control.""" + +from .plugin import register + +__all__ = ["register"] diff --git a/packages/python/slop-hermes/src/slop_hermes/actions.py b/packages/python/slop-hermes/src/slop_hermes/actions.py new file mode 100644 index 0000000..40e62d0 --- /dev/null +++ b/packages/python/slop-hermes/src/slop_hermes/actions.py @@ -0,0 +1,129 @@ +"""App action helpers for the Hermes SLOP plugin.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Protocol + + +class _ActionConsumer(Protocol): + async def invoke( + self, + path: str, + action: str, + params: dict[str, Any], + ) -> dict[str, Any]: ... + + +class _ActionProvider(Protocol): + consumer: _ActionConsumer + + +class _ActionService(Protocol): + async def ensure_connected(self, id_or_name: str) -> _ActionProvider | None: ... + + +@dataclass(slots=True) +class ActionResult: + """Action outcome rendered for Hermes tool responses.""" + + text: str + is_error: bool = False + + +async def execute_action( + service: _ActionService, + *, + app: str, + path: str, + action: str, + params: dict[str, Any] | None = None, +) -> ActionResult: + """Invoke a single affordance through the discovery service.""" + provider = await service.ensure_connected(app) + if provider is None: + return ActionResult( + text=f'App "{app}" not found or could not connect.', + is_error=True, + ) + + try: + result = await provider.consumer.invoke(path, action, params or {}) + except Exception as exc: + return ActionResult(text=f"Error: {exc}", is_error=True) + + status = result.get("status") + if status == "ok": + message = f"Done. {action} on {path} succeeded." + if result.get("data") is not None: + message += " Result: " + json.dumps(result["data"], ensure_ascii=False) + return ActionResult(text=message) + + if status == "accepted": + message = f"Accepted. {action} on {path} started successfully." + if result.get("data") is not None: + message += " Result: " + json.dumps(result["data"], ensure_ascii=False) + return ActionResult(text=message) + + error = result.get("error") or {} + code = error.get("code", "unknown") + message = error.get("message", "Unknown error") + return ActionResult( + text=f"Action failed: [{code}] {message}", + is_error=True, + ) + + +async def execute_action_batch( + service: _ActionService, + *, + app: str, + actions: list[dict[str, Any]], +) -> ActionResult: + """Invoke multiple affordances through the discovery service.""" + provider = await service.ensure_connected(app) + if provider is None: + return ActionResult( + text=f'App "{app}" not found or could not connect.', + is_error=True, + ) + + lines: list[str] = [] + failed = 0 + + for item in actions: + path = str(item.get("path") or "/") + action = str(item.get("action") or "") + params = item.get("params") + try: + result = await provider.consumer.invoke(path, action, params or {}) + except Exception as exc: + failed += 1 + lines.append(f"ERROR: {action} on {path} - {exc}") + continue + + status = result.get("status") + if status == "ok": + lines.append(f"OK: {action} on {path}") + continue + if status == "accepted": + lines.append(f"ACCEPTED: {action} on {path}") + continue + + failed += 1 + error = result.get("error") or {} + lines.append( + "FAIL: " + f"{action} on {path} - [{error.get('code', 'unknown')}] " + f"{error.get('message', 'Unknown error')}" + ) + + succeeded = len(actions) - failed + return ActionResult( + text=( + f"Batch complete: {succeeded}/{len(actions)} succeeded.\n" + + "\n".join(lines) + ), + is_error=failed > 0, + ) diff --git a/packages/python/slop-hermes/src/slop_hermes/plugin.py b/packages/python/slop-hermes/src/slop_hermes/plugin.py new file mode 100644 index 0000000..e03a03c --- /dev/null +++ b/packages/python/slop-hermes/src/slop_hermes/plugin.py @@ -0,0 +1,231 @@ +"""Hermes plugin registration for SLOP app control.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from .actions import ActionResult +from .runtime import get_runtime + +logger = logging.getLogger(__name__) + +_TOOLSET = "slop" + +_EMPTY_PARAMS = { + "type": "object", + "properties": {}, + "additionalProperties": False, +} + + +def register(ctx: Any) -> None: + """Register tools and hooks with Hermes.""" + runtime = get_runtime() + + ctx.register_tool( + name="list_apps", + toolset=_TOOLSET, + schema={ + "name": "list_apps", + "description": "List the applications currently available on this computer and whether they are already connected.", + "parameters": _EMPTY_PARAMS, + }, + handler=lambda _args, **_kwargs: _serialize_tool_result(runtime.list_apps()), + ) + + ctx.register_tool( + name="connect_app", + toolset=_TOOLSET, + schema={ + "name": "connect_app", + "description": ( + "Connect to an application and see its full state tree and every action you can perform. " + "State for already-connected apps is injected into context automatically - " + "use this to connect a new app or refresh detailed state." + ), + "parameters": { + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "App name or ID to connect and inspect.", + } + }, + "required": ["app"], + "additionalProperties": False, + }, + }, + handler=lambda args, **_kwargs: _serialize_tool_result( + runtime.connect_app(str(args.get("app") or "")) + ), + ) + + ctx.register_tool( + name="disconnect_app", + toolset=_TOOLSET, + schema={ + "name": "disconnect_app", + "description": "Disconnect from an application. Stops state updates. Use when you're done interacting with an app.", + "parameters": { + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "App name or ID to disconnect from.", + } + }, + "required": ["app"], + "additionalProperties": False, + }, + }, + handler=lambda args, **_kwargs: _serialize_tool_result( + runtime.disconnect_app(str(args.get("app") or "")) + ), + ) + + ctx.register_tool( + name="app_action", + toolset=_TOOLSET, + schema={ + "name": "app_action", + "description": ( + "Perform an action on an application - add items, edit content, toggle state, " + "delete entries, move things around, start or stop processes, and more. " + "Use the exact paths, action names, and parameter values from the application state shown in context." + ), + "parameters": { + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "App name or ID (from connect_app or context)", + }, + "path": { + "type": "string", + "description": "Path to the item to act on, e.g. '/' or '/todos/todo-1'", + }, + "action": { + "type": "string", + "description": "Action to perform, e.g. 'add_card', 'toggle', 'delete'", + }, + "params": { + "type": "object", + "description": "Action parameters as key-value pairs", + "additionalProperties": True, + }, + }, + "required": ["app", "path", "action"], + "additionalProperties": False, + }, + }, + handler=lambda args, **_kwargs: _serialize_action_result( + runtime.app_action( + app=str(args.get("app") or ""), + path=str(args.get("path") or "/"), + action=str(args.get("action") or ""), + params=args.get("params") + if isinstance(args.get("params"), dict) + else None, + ) + ), + ) + + ctx.register_tool( + name="app_action_batch", + toolset=_TOOLSET, + schema={ + "name": "app_action_batch", + "description": ( + "Perform multiple actions on an application in a single call. Much faster than calling app_action repeatedly. " + "Use this when you need to add multiple items, make several changes, or perform any repeated sequence of actions." + ), + "parameters": { + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "App name or ID (from connect_app or context)", + }, + "actions": { + "type": "array", + "description": "Array of actions to perform sequentially", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to act on", + }, + "action": { + "type": "string", + "description": "Action to perform", + }, + "params": { + "type": "object", + "description": "Action parameters", + "additionalProperties": True, + }, + }, + "required": ["path", "action"], + "additionalProperties": False, + }, + }, + }, + "required": ["app", "actions"], + "additionalProperties": False, + }, + }, + handler=lambda args, **_kwargs: _serialize_action_result( + runtime.app_action_batch( + app=str(args.get("app") or ""), + actions=args.get("actions") + if isinstance(args.get("actions"), list) + else [], + ) + ), + ) + + ctx.register_hook("on_session_start", _warm_runtime) + ctx.register_hook("pre_llm_call", _inject_context) + + +def _warm_runtime(**_kwargs: Any) -> None: + try: + get_runtime().ensure_started() + except Exception: + logger.warning("Failed to start SLOP Hermes runtime", exc_info=True) + + +def _inject_context(**_kwargs: Any) -> dict[str, str] | None: + try: + context = get_runtime().build_context() + except Exception: + logger.warning("Failed to build SLOP context for Hermes", exc_info=True) + return None + if not context: + return None + return {"context": context} + + +def _serialize_tool_result(result: Any) -> str: + text = _tool_result_text(result) + if getattr(result, "is_error", False): + return json.dumps({"error": text}, ensure_ascii=False) + return json.dumps({"result": text}, ensure_ascii=False) + + +def _serialize_action_result(result: ActionResult) -> str: + if result.is_error: + return json.dumps({"error": result.text}, ensure_ascii=False) + return json.dumps({"result": result.text}, ensure_ascii=False) + + +def _tool_result_text(result: Any) -> str: + parts: list[str] = [] + for block in getattr(result, "content", []): + text = block.get("text") if isinstance(block, dict) else None + if isinstance(text, str) and text: + parts.append(text) + return "\n".join(parts) diff --git a/packages/python/slop-hermes/src/slop_hermes/render.py b/packages/python/slop-hermes/src/slop_hermes/render.py new file mode 100644 index 0000000..730869b --- /dev/null +++ b/packages/python/slop-hermes/src/slop_hermes/render.py @@ -0,0 +1,129 @@ +# pyright: reportMissingImports=false + +"""Prompt injection helpers for the Hermes SLOP plugin.""" + +from __future__ import annotations + +import os +from typing import Any, Protocol + +from slop_ai import OutputTreeOptions, format_tree, prepare_tree + + +class _RenderConsumer(Protocol): + def get_tree(self, subscription_id: str) -> Any: ... + + +class _RenderProvider(Protocol): + id: str + name: str + subscription_id: str + consumer: _RenderConsumer + + +class _RenderDescriptorTransport(Protocol): + type: str + + +class _RenderDescriptor(Protocol): + id: str + name: str + source: str + transport: _RenderDescriptorTransport + + +class _RenderService(Protocol): + def get_providers(self) -> list[_RenderProvider]: ... + def get_discovered(self) -> list[_RenderDescriptor]: ... + + +_DEFAULT_MAX_NODES = 160 + + +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + if value is None: + return default + try: + parsed = int(value) + except ValueError: + return default + return parsed if parsed > 0 else default + + +def _env_float(name: str) -> float | None: + value = os.getenv(name) + if value is None or not value.strip(): + return None + try: + return float(value) + except ValueError: + return None + + +def render_state_context( + service: _RenderService, + *, + max_nodes: int | None = None, + min_salience: float | None = None, +) -> str | None: + """Render the current discovery state for Hermes prompt injection.""" + connected = list(service.get_providers()) + discovered = list(service.get_discovered()) + connected_ids = {provider.id for provider in connected} + available = [desc for desc in discovered if desc.id not in connected_ids] + + if not connected and not available: + return None + + effective_max_nodes = max_nodes or _env_int( + "SLOP_HERMES_MAX_NODES", _DEFAULT_MAX_NODES + ) + effective_min_salience = ( + min_salience + if min_salience is not None + else _env_float("SLOP_HERMES_MIN_SALIENCE") + ) + + lines = ["## SLOP Apps", ""] + + if connected: + lines.append( + f"{len(connected)} app(s) connected. Read the state trees below before acting. " + "Use app_action or app_action_batch to invoke affordances, and call connect_app " + "only when you need to connect a new app or force a refresh." + ) + lines.append("") + + for provider in connected: + tree = provider.consumer.get_tree(provider.subscription_id) + lines.append(f"### {provider.name} ({provider.id})") + lines.append("") + if tree is None: + lines.append("(awaiting state snapshot)") + lines.append("") + continue + + prepared = prepare_tree( + tree, + OutputTreeOptions( + max_nodes=effective_max_nodes, + min_salience=effective_min_salience, + ), + ) + lines.append("```") + lines.append(format_tree(prepared)) + lines.append("```") + lines.append("") + + if available: + lines.append("### Available (not connected)") + lines.append("") + for desc in available: + lines.append( + f"- **{desc.name}** (id: `{desc.id}`, {desc.transport.type}, {desc.source})" + ) + lines.append("") + lines.append("Call connect_app with an app name to connect it.") + + return "\n".join(lines).strip() diff --git a/packages/python/slop-hermes/src/slop_hermes/runtime.py b/packages/python/slop-hermes/src/slop_hermes/runtime.py new file mode 100644 index 0000000..08dcb3d --- /dev/null +++ b/packages/python/slop-hermes/src/slop_hermes/runtime.py @@ -0,0 +1,213 @@ +# pyright: reportMissingImports=false + +"""Background discovery runtime for the Hermes SLOP plugin.""" + +from __future__ import annotations + +import asyncio +import atexit +import contextlib +import logging +import threading +from concurrent.futures import TimeoutError as FutureTimeoutError +from typing import Any + +from slop_ai.discovery import ( + DiscoveryService, + DiscoveryOptions, + ToolHandlers, + ToolResult, + create_discovery_service, + create_tool_handlers, +) + +from .actions import ActionResult, execute_action, execute_action_batch +from .render import render_state_context + +logger = logging.getLogger(__name__) + + +class SlopHermesRuntime: + """Owns a background asyncio loop and discovery service.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._loop_ready = threading.Event() + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._service: DiscoveryService | None = None + self._handlers: ToolHandlers | None = None + self._started = False + self._stopping = False + atexit.register(self.stop) + + def ensure_started(self) -> None: + """Start the background runtime if needed.""" + with self._lock: + if self._started: + return + if self._thread is None or not self._thread.is_alive(): + self._loop_ready.clear() + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._run_loop, + name="slop-hermes-runtime", + daemon=True, + ) + self._thread.start() + + if not self._loop_ready.wait(timeout=5.0): + self.stop() + raise RuntimeError("Timed out starting the SLOP Hermes runtime loop") + + try: + self._run_coro(self._async_start(), timeout=15.0) + except Exception: + self.stop() + raise + + with self._lock: + self._started = True + + def stop(self) -> None: + """Stop discovery and tear down the background loop.""" + with self._lock: + if self._stopping: + return + self._stopping = True + loop = self._loop + thread = self._thread + + try: + if loop is not None and loop.is_running(): + with contextlib.suppress(Exception): + self._run_coro(self._async_stop(), timeout=10.0) + loop.call_soon_threadsafe(loop.stop) + if thread is not None and thread.is_alive(): + thread.join(timeout=5.0) + finally: + with self._lock: + self._service = None + self._handlers = None + self._loop = None + self._thread = None + self._started = False + self._stopping = False + + def list_apps(self) -> ToolResult: + self.ensure_started() + return self._run_coro(self._require_handlers().list_apps()) + + def connect_app(self, app: str) -> ToolResult: + self.ensure_started() + return self._run_coro(self._require_handlers().connect_app(app)) + + def disconnect_app(self, app: str) -> ToolResult: + self.ensure_started() + return self._run_coro(self._require_handlers().disconnect_app(app)) + + def app_action( + self, + *, + app: str, + path: str, + action: str, + params: dict[str, Any] | None = None, + ) -> ActionResult: + self.ensure_started() + return self._run_coro( + execute_action( + self._require_service(), + app=app, + path=path, + action=action, + params=params, + ) + ) + + def app_action_batch( + self, + *, + app: str, + actions: list[dict[str, Any]], + ) -> ActionResult: + self.ensure_started() + return self._run_coro( + execute_action_batch(self._require_service(), app=app, actions=actions) + ) + + def build_context(self) -> str | None: + self.ensure_started() + return self._run_coro(self._render_context()) + + async def _async_start(self) -> None: + if self._service is not None and self._handlers is not None: + return + service = create_discovery_service(DiscoveryOptions(logger=logger)) + handlers = create_tool_handlers(service) + await service.start() + self._service = service + self._handlers = handlers + + async def _async_stop(self) -> None: + if self._service is None: + return + await self._service.stop() + self._service = None + self._handlers = None + + async def _render_context(self) -> str | None: + return render_state_context(self._require_service()) + + def _run_loop(self) -> None: + loop = self._loop + assert loop is not None + asyncio.set_event_loop(loop) + self._loop_ready.set() + try: + loop.run_forever() + finally: + pending = [task for task in asyncio.all_tasks(loop) if not task.done()] + for task in pending: + task.cancel() + if pending: + with contextlib.suppress(Exception): + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + with contextlib.suppress(Exception): + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + def _run_coro(self, coro: Any, *, timeout: float = 10.0) -> Any: + with self._lock: + loop = self._loop + if loop is None or not loop.is_running(): + raise RuntimeError("SLOP Hermes runtime is not running") + future = asyncio.run_coroutine_threadsafe(coro, loop) + try: + return future.result(timeout=timeout) + except FutureTimeoutError as exc: + future.cancel() + raise TimeoutError("Timed out waiting for SLOP Hermes runtime") from exc + + def _require_service(self) -> DiscoveryService: + if self._service is None: + raise RuntimeError("SLOP discovery service is not ready") + return self._service + + def _require_handlers(self) -> ToolHandlers: + if self._handlers is None: + raise RuntimeError("SLOP discovery handlers are not ready") + return self._handlers + + +_runtime: SlopHermesRuntime | None = None + + +def get_runtime() -> SlopHermesRuntime: + """Return the process-global plugin runtime.""" + global _runtime + if _runtime is None: + _runtime = SlopHermesRuntime() + return _runtime diff --git a/packages/python/slop-hermes/tests/conftest.py b/packages/python/slop-hermes/tests/conftest.py new file mode 100644 index 0000000..84847a7 --- /dev/null +++ b/packages/python/slop-hermes/tests/conftest.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SDK_SRC = ROOT.parent / "slop-ai" / "src" +PLUGIN_SRC = ROOT / "src" + +for path in (str(PLUGIN_SRC), str(SDK_SRC)): + if path not in sys.path: + sys.path.insert(0, path) diff --git a/packages/python/slop-hermes/tests/test_actions.py b/packages/python/slop-hermes/tests/test_actions.py new file mode 100644 index 0000000..c554c76 --- /dev/null +++ b/packages/python/slop-hermes/tests/test_actions.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio + +from slop_hermes.actions import execute_action, execute_action_batch + + +def test_execute_action_ok_result_includes_data() -> None: + async def _run() -> None: + service = _FakeService({"status": "ok", "data": {"id": "todo-1"}}) + result = await execute_action( + service, + app="todos", + path="/todos", + action="create", + params={"title": "Ship plugin"}, + ) + + assert result.is_error is False + assert "create on /todos succeeded" in result.text + assert '"id": "todo-1"' in result.text + + asyncio.run(_run()) + + +def test_execute_action_error_result_uses_protocol_error() -> None: + async def _run() -> None: + service = _FakeService( + { + "status": "error", + "error": {"code": "forbidden", "message": "Nope"}, + } + ) + result = await execute_action( + service, + app="todos", + path="/todos/item-1", + action="delete", + ) + + assert result.is_error is True + assert result.text == "Action failed: [forbidden] Nope" + + asyncio.run(_run()) + + +def test_execute_action_batch_reports_partial_failure() -> None: + async def _run() -> None: + service = _FakeService( + [ + {"status": "ok"}, + {"status": "accepted"}, + { + "status": "error", + "error": {"code": "bad_request", "message": "Missing title"}, + }, + ] + ) + result = await execute_action_batch( + service, + app="todos", + actions=[ + {"path": "/todos", "action": "create", "params": {"title": "A"}}, + {"path": "/todos", "action": "sync"}, + {"path": "/todos", "action": "create", "params": {}}, + ], + ) + + assert result.is_error is True + assert result.text.startswith("Batch complete: 2/3 succeeded.") + assert "OK: create on /todos" in result.text + assert "ACCEPTED: sync on /todos" in result.text + assert "FAIL: create on /todos - [bad_request] Missing title" in result.text + + asyncio.run(_run()) + + +class _FakeService: + def __init__(self, invoke_result): + self._provider = _FakeProvider(invoke_result) + + async def ensure_connected(self, app: str): + if app == "missing": + return None + return self._provider + + +class _FakeProvider: + def __init__(self, invoke_result): + self.consumer = _FakeConsumer(invoke_result) + + +class _FakeConsumer: + def __init__(self, invoke_result): + if isinstance(invoke_result, list): + self._results = list(invoke_result) + else: + self._results = [invoke_result] + + async def invoke(self, path, action, params): + return self._results.pop(0) diff --git a/packages/python/slop-hermes/tests/test_plugin.py b/packages/python/slop-hermes/tests/test_plugin.py new file mode 100644 index 0000000..8d5cf7b --- /dev/null +++ b/packages/python/slop-hermes/tests/test_plugin.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from slop_hermes.plugin import register + + +def test_register_adds_tools_and_hooks() -> None: + ctx = _FakeContext() + + register(ctx) + + assert sorted(ctx.tools) == [ + "app_action", + "app_action_batch", + "connect_app", + "disconnect_app", + "list_apps", + ] + assert sorted(ctx.hooks) == ["on_session_start", "pre_llm_call"] + assert all(toolset == "slop" for toolset in ctx.tools.values()) + + +class _FakeContext: + def __init__(self) -> None: + self.tools: dict[str, str] = {} + self.hooks: dict[str, object] = {} + + def register_tool(self, *, name, toolset, schema, handler, **_kwargs) -> None: + assert schema["name"] == name + self.tools[name] = toolset + + def register_hook(self, name, callback) -> None: + self.hooks[name] = callback diff --git a/packages/python/slop-hermes/tests/test_render.py b/packages/python/slop-hermes/tests/test_render.py new file mode 100644 index 0000000..96e3fdd --- /dev/null +++ b/packages/python/slop-hermes/tests/test_render.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from slop_ai.discovery import ProviderDescriptor +from slop_ai.discovery.models import TransportDescriptor +from slop_ai.types import Affordance, NodeMeta, SlopNode +from slop_hermes.render import render_state_context + + +def test_render_state_context_shows_connected_and_available_apps() -> None: + tree = SlopNode( + id="kanban", + type="root", + properties={"label": "Kanban Board"}, + children=[ + SlopNode( + id="cards", + type="collection", + meta=NodeMeta(summary="2 cards"), + children=[ + SlopNode( + id="card-1", + type="item", + properties={"label": "Ship Hermes plugin"}, + affordances=[Affordance(action="done")], + ) + ], + ) + ], + affordances=[Affordance(action="add_card")], + ) + service = _FakeService( + providers=[_FakeProvider("kanban", "Kanban", tree)], + discovered=[ + _descriptor("kanban", "Kanban", "unix", source="local"), + _descriptor("calendar", "Calendar", "ws", source="bridge"), + ], + ) + + rendered = render_state_context(service, max_nodes=32) + + assert rendered is not None + assert "## SLOP Apps" in rendered + assert "1 app(s) connected." in rendered + assert "### Kanban (kanban)" in rendered + assert "add_card" in rendered + assert "### Available (not connected)" in rendered + assert "**Calendar** (id: `calendar`, ws, bridge)" in rendered + + +def test_render_state_context_returns_none_when_empty() -> None: + service = _FakeService(providers=[], discovered=[]) + assert render_state_context(service) is None + + +class _FakeService: + def __init__(self, *, providers, discovered) -> None: + self._providers = providers + self._discovered = discovered + + def get_providers(self): + return list(self._providers) + + def get_discovered(self): + return list(self._discovered) + + +class _FakeProvider: + def __init__(self, provider_id: str, name: str, tree: SlopNode | None) -> None: + self.id = provider_id + self.name = name + self.subscription_id = "sub-1" + self.consumer = _FakeConsumer(tree) + + +class _FakeConsumer: + def __init__(self, tree: SlopNode | None) -> None: + self._tree = tree + + def get_tree(self, subscription_id: str) -> SlopNode | None: + assert subscription_id == "sub-1" + return self._tree + + +def _descriptor( + provider_id: str, + name: str, + transport_type: str, + *, + source: str, +) -> ProviderDescriptor: + return ProviderDescriptor( + id=provider_id, + name=name, + slop_version="0.1", + transport=TransportDescriptor(type=transport_type), + capabilities=["state", "affordances"], + source=source, + ) diff --git a/website/docs/content-manifest.mjs b/website/docs/content-manifest.mjs index 53d67a9..d9a7e67 100644 --- a/website/docs/content-manifest.mjs +++ b/website/docs/content-manifest.mjs @@ -79,6 +79,11 @@ export const docsPages = [ description: "Connect Codex to SLOP-enabled applications with injected live state and a fixed MCP tool surface", redirects: ["/guides-advanced/codex"], }), + page("docs/guides/advanced/hermes.md", "guides/advanced/hermes.md", { + label: "Hermes Integration", + description: "Connect Hermes Agent to SLOP-enabled applications with injected live state and a fixed tool surface", + redirects: ["/guides-advanced/hermes"], + }), page("docs/guides/advanced/openclaw.md", "guides/advanced/openclaw.md", { label: "OpenClaw Integration", description: "Control SLOP-enabled applications through OpenClaw", @@ -137,6 +142,10 @@ export const docsPages = [ label: "@slop-ai/openclaw-plugin", description: "OpenClaw plugin for discovering and controlling SLOP-enabled applications", }), + page("docs/api/slop-hermes.md", "api/slop-hermes.md", { + label: "slop-hermes", + description: "Hermes Agent plugin for discovering and controlling SLOP-enabled applications", + }), page("docs/api/python.md", "api/python.md", { label: "slop-ai (Python)", description: "Python package reference for SLOP providers, consumers, and transports", @@ -243,6 +252,7 @@ export const docsSidebar = [ pageItem("api/consumer"), pageItem("api/tanstack-start"), pageItem("api/openclaw-plugin"), + pageItem("api/slop-hermes"), pageItem("api/python", "slop-ai (Python)"), pageItem("api/go", "slop-ai (Go)"), pageItem("api/rust", "slop-ai (Rust)"), @@ -275,6 +285,7 @@ export const docsSidebar = [ pageItem("guides/advanced/agent-scaffolding", "Agent-Assisted Integration"), pageItem("guides/advanced/claude-code", "Claude Code Integration"), pageItem("guides/advanced/codex", "Codex Integration"), + pageItem("guides/advanced/hermes", "Hermes Integration"), pageItem("guides/advanced/openclaw", "OpenClaw Integration"), pageItem("guides/advanced/benchmarks", "Benchmarks: MCP vs SLOP"), ], diff --git a/website/docs/src/content/docs/api/index.md b/website/docs/src/content/docs/api/index.md index 0b0748e..97f5bb6 100644 --- a/website/docs/src/content/docs/api/index.md +++ b/website/docs/src/content/docs/api/index.md @@ -26,6 +26,7 @@ This page maps every published package in the repo to its primary use case and t | Package | Use it for | Install | Docs | | --- | --- | --- | --- | | `slop-ai` for Python | FastAPI, services, local tools, Python consumers | `pip install slop-ai[websocket]` | [API](/api/python), [Guide](/guides/python) | +| `slop-hermes` | Hermes Agent integration | `pip install slop-hermes` | [API](/api/slop-hermes), [Guide](/guides/advanced/hermes) | | `slop-ai` for Go | `net/http` services, daemons, CLI tools, Go consumers | `go get github.com/devteapot/slop/packages/go/slop-ai` | [API](/api/go), [Guide](/guides/go) | | `slop-ai` for Rust | Axum apps, services, daemons, CLI tools, Rust consumers | `cargo add slop-ai` | [API](/api/rust), [Guide](/guides/rust) | diff --git a/website/docs/src/content/docs/api/python.md b/website/docs/src/content/docs/api/python.md index a389fa9..a1266d2 100644 --- a/website/docs/src/content/docs/api/python.md +++ b/website/docs/src/content/docs/api/python.md @@ -17,6 +17,7 @@ from slop_ai.transports.asgi import SlopMiddleware - provider APIs via `SlopServer` - consumer APIs via `SlopConsumer` +- discovery helpers via `slop_ai.discovery` for provider scanning, bridge relay, lazy connect, and AI-facing tool helpers - transport modules for ASGI, WebSocket, Unix socket, stdio, and matching client transports - scaling helpers such as `prepare_tree()` and `truncate_tree()` - LLM tool helpers such as `affordances_to_tools()` and `format_tree()` @@ -32,3 +33,5 @@ from slop_ai.transports.asgi import SlopMiddleware - [Python guide](/guides/python) - [Consumer guide](/guides/consumer) +- [Discovery & Bridge](/sdk/discovery) +- [slop-hermes package](/api/slop-hermes) — Hermes Agent integration built on the Python discovery layer diff --git a/website/docs/src/content/docs/api/slop-hermes.md b/website/docs/src/content/docs/api/slop-hermes.md new file mode 100644 index 0000000..5bf9399 --- /dev/null +++ b/website/docs/src/content/docs/api/slop-hermes.md @@ -0,0 +1,61 @@ +--- +title: "slop-hermes" +description: "Hermes Agent plugin for discovering and controlling SLOP-enabled applications" +--- +`slop-hermes` is a Python package that installs a Hermes plugin for SLOP app discovery and control. + +It exposes five tools: + +- `list_apps` — list available apps +- `connect_app` — connect and inspect an app +- `disconnect_app` — stop tracking an app +- `app_action` — perform a single action +- `app_action_batch` — perform multiple actions in one call + +## Install + +```bash +pip install slop-hermes +``` + +For local development from this repo: + +```bash +pip install -e /path/to/slop/packages/python/slop-hermes +``` + +Install it into the same Python environment as Hermes. + +## How it works + +The package registers a `hermes_agent.plugins` entry point named `slop`. + +At runtime it: + +- starts a background `slop_ai.discovery.DiscoveryService` +- discovers local and browser-backed SLOP providers +- injects connected provider state through Hermes' `pre_llm_call` hook +- exposes a stable five-tool surface for app lifecycle and actions + +## Discovery sources + +- `~/.slop/providers/*.json` +- `/tmp/slop/providers/*.json` +- browser extension bridge at `ws://127.0.0.1:9339/slop-bridge` + +## Environment variables + +- `SLOP_HERMES_MAX_NODES` — max nodes included in injected state trees. Default: `160` +- `SLOP_HERMES_MIN_SALIENCE` — optional salience threshold for injected trees + +## Operational notes + +- Designed first for local CLI / single-user Hermes +- Uses stable meta-tools rather than per-affordance dynamic tools +- Builds on the mirrored Python discovery layer in `slop_ai.discovery` + +## Related pages + +- [Hermes integration guide](/guides/advanced/hermes) +- [Python SDK API](/api/python) +- [Discovery & Bridge](/sdk/discovery) diff --git a/website/docs/src/content/docs/guides/advanced/hermes.md b/website/docs/src/content/docs/guides/advanced/hermes.md new file mode 100644 index 0000000..701b7e1 --- /dev/null +++ b/website/docs/src/content/docs/guides/advanced/hermes.md @@ -0,0 +1,152 @@ +--- +title: "Hermes Integration" +description: "Connect Hermes Agent to SLOP-enabled applications with injected live state and a fixed tool surface" +--- +SLOP ships a Hermes plugin at `packages/python/slop-hermes`. + +It gives Hermes five stable tools for discovering and controlling SLOP-enabled applications: + +- `list_apps` +- `connect_app` +- `disconnect_app` +- `app_action` +- `app_action_batch` + +## What it does + +- **Discovers** SLOP apps from local provider descriptors and the browser extension bridge through `slop_ai.discovery` +- **Injects live state** into each Hermes turn through the plugin `pre_llm_call` hook +- **Returns an immediate snapshot** through `connect_app`, including the current state tree and action summary +- **Acts through stable meta-tools** so Hermes always sees the same tool catalog +- **Supports multiple apps** connected simultaneously + +## Install + +Install `slop-hermes` into the same Python environment as Hermes: + +```bash +pip install slop-hermes +``` + +From a local checkout: + +```bash +pip install -e /path/to/slop/packages/python/slop-hermes +``` + +Verify that Hermes sees the plugin: + +```bash +hermes plugins list +``` + +You should see a `slop` plugin entry. + +If you explicitly manage toolsets, make sure the plugin toolset is enabled: + +```bash +hermes tools +``` + +Or include it directly when starting chat: + +```bash +hermes chat --toolsets slop,web,terminal +``` + +## How it works + +### Plugin loading + +`slop-hermes` is a pip-installed Hermes plugin. It registers itself through the `hermes_agent.plugins` entry point and exposes a `slop` toolset. + +### In-process discovery runtime + +The plugin starts a background asyncio runtime on first use. That runtime owns a `slop_ai.discovery.DiscoveryService`, which handles: + +- `~/.slop/providers/*.json` descriptor discovery +- `/tmp/slop/providers/*.json` descriptor discovery +- browser extension bridge discovery at `ws://127.0.0.1:9339/slop-bridge` +- connection management for Unix socket, WebSocket, and relay-backed providers + +### Hook-based state injection + +On every Hermes turn, the plugin's `pre_llm_call` hook injects a compact summary of connected and available apps into the current user message: + +````text +## SLOP Apps + +1 app(s) connected. Read the state trees below before acting... + +### Kanban (kanban) + +``` +[root] kanban: Kanban Board actions: {add_card(title: string)} + [collection] backlog "12 cards" +``` + +### Available (not connected) + +- **Calendar** (id: `calendar`, ws, bridge) +```` + +Hermes can answer from injected state directly, or use the five SLOP tools when it needs to connect, refresh, or act. + +### State compaction + +The injected tree is compacted before formatting so prompt usage stays bounded. + +Available knobs: + +- `SLOP_HERMES_MAX_NODES` — max nodes included in injected trees. Default: `160` +- `SLOP_HERMES_MIN_SALIENCE` — optional salience threshold before rendering + +## Tools + +| Tool | Purpose | +| --- | --- | +| `list_apps` | List all discovered SLOP-enabled apps and show which are already connected | +| `connect_app` | Connect to an app, return its current state tree plus action summary, and enroll it in injected context | +| `disconnect_app` | Disconnect from an app | +| `app_action` | Invoke one affordance on a node | +| `app_action_batch` | Invoke multiple affordances in a single call | + +## Why the Hermes plugin uses meta-tools + +The Hermes plugin currently uses the same fixed-tool model as the Codex and OpenClaw integrations: + +- Hermes plugin tools are registered once during plugin initialization +- prompt injection gives Hermes live app state before every turn +- actions still go through `app_action` and `app_action_batch` + +This keeps the integration simple and predictable while matching Hermes' plugin model. + +## Operational scope + +This first version is designed primarily for **local CLI / single-user Hermes**. + +The discovery runtime is process-global, which is a good fit for a single local Hermes instance controlling apps on the same machine. If you need stricter multi-user isolation or remote-host integration, the MCP route is still the safer boundary. + +## Example interaction + +```text +User: What apps are available? +→ Hermes calls list_apps + +User: Connect to the kanban board +→ Hermes calls connect_app("kanban") + +User: Add three cards to backlog +→ Hermes reads the injected Kanban tree from context, then calls app_action_batch with three add_card actions + +User: Disconnect from kanban +→ Hermes calls disconnect_app("kanban") +``` + +## Related + +- [slop-hermes package API](/api/slop-hermes) +- [Discovery & Bridge](/sdk/discovery) — shared discovery model used by the plugin +- [Python SDK API](/api/python) — underlying `slop_ai.discovery` implementation +- [Codex integration](/guides/advanced/codex) — another fixed-tool integration +- [OpenClaw integration](/guides/advanced/openclaw) — similar meta-tool pattern diff --git a/website/docs/src/content/docs/sdk/discovery.md b/website/docs/src/content/docs/sdk/discovery.md index 8a69021..19d4dd4 100644 --- a/website/docs/src/content/docs/sdk/discovery.md +++ b/website/docs/src/content/docs/sdk/discovery.md @@ -368,11 +368,11 @@ Host-specific wrappers, tool-helper layers, and prompt injection are intentional ## Integrations -Both the Claude Code and OpenClaw plugins follow the same design principles: +The Codex, Claude Code, OpenClaw, and Hermes integrations all follow the same design principles: - **State injection** — Provider state is injected into the model's context before each turn, not fetched via tool calls - **Minimal tool usage** — Tools are used only for connecting to apps and performing actions, never for reading state -- **Shared discovery** — Both build on `@slop-ai/discovery/service` for provider scanning and connection orchestration +- **Shared discovery contract** — TypeScript hosts build on `@slop-ai/discovery/service`; Hermes uses the mirrored Python implementation in `slop_ai.discovery` Where they differ is **action dispatch**, due to host platform limitations. @@ -399,6 +399,7 @@ Dynamic tools have proper parameter schemas from the provider's affordance defin | Codex | No (current plugin) | Stable MCP tools + `UserPromptSubmit` hook-based state injection | No runtime tool registration; actions still go through meta-tools | | Claude Code (MCP) | Yes | `notifications/tools/list_changed` — server notifies client when tool list changes | None | | OpenClaw | No | `api.registerTool()` is one-time during `register()` | No runtime tool registration API; tools must be declared in the plugin manifest | +| Hermes | No | `register_tool()` + `pre_llm_call` hook-based state injection | Plugin tools are registered once; current package targets local CLI / single-user use | Hosts without dynamic tool support fall back to the **meta-tool pattern**: stable tools (`app_action`, `app_action_batch`) that resolve actions at runtime. Depending on the host, the model learns the exact paths and action names from prompt-time state injection or from an explicit `connect_app` inspection step. @@ -454,6 +455,24 @@ Design details: See [OpenClaw guide](/guides/advanced/openclaw) for setup and usage. +### Hermes plugin (`slop-hermes`) + +| Component | Purpose | +|---|---| +| **Tools** | `list_apps`, `connect_app`, `disconnect_app`, `app_action`, `app_action_batch` | +| **Hook** (`pre_llm_call`) | Injects connected providers' state trees into the current Hermes turn | +| **Runtime** | Background Python discovery service using `slop_ai.discovery` | + +Design details: + +- **Fixed tool surface** — The Hermes plugin registers the same stable five-tool catalog as the Codex and OpenClaw integrations. +- **In-process state injection** — The `pre_llm_call` hook injects fresh `## SLOP Apps` markdown directly into the current turn. No file-based IPC is needed. +- **Python discovery parity** — Uses `slop_ai.discovery` for local descriptor watching, bridge support, lazy connect, reconnect, and idle disconnect behavior. +- **Bounded injection** — Connected trees are compacted with `prepare_tree(max_nodes=...)` before rendering so prompt growth stays controlled. +- **Operational scope** — The current package is aimed at local CLI / single-user Hermes. Connected-provider state is process-global. + +See [Hermes guide](/guides/advanced/hermes) for setup and usage. + ## Related - [Consumer SDK API](/api/consumer) — protocol client reference @@ -462,3 +481,4 @@ See [OpenClaw guide](/guides/advanced/openclaw) for setup and usage. - [Consumer guide](/guides/consumer) — usage patterns and example workflows - [Codex guide](/guides/advanced/codex) — Codex plugin setup and usage - [Claude Code guide](/guides/advanced/claude-code) — Claude Code plugin setup and usage +- [Hermes guide](/guides/advanced/hermes) — Hermes plugin setup and usage