Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 77 additions & 16 deletions hooks/hermes/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# RTK Plugin for Hermes

Rewrites Hermes `terminal` tool commands to RTK equivalents before execution, so Hermes receives compact command output without changing your workflow.
Integrates RTK into [Hermes Agent](https://github.com/NousResearch/hermes-agent) via two hooks:

1. **`pre_tool_call`** — rewrites bare terminal commands to their `rtk` equivalents before execution.
2. **`transform_tool_result`** — filters high-token tool outputs before they enter the conversation context.

## Installation

Expand All @@ -12,32 +15,90 @@ The installer writes the plugin to `~/.hermes/plugins/rtk-rewrite/` and enables

## Development

Run the Hermes plugin tests from the repository root:
Run the plugin tests from the repository root:

```bash
python3 -m unittest discover -s hooks/hermes
python3 -m pytest hooks/hermes/tests/ -v
```

## How it works
---

Hermes loads plugins from Python, so the plugin entrypoint is Python. The Python code is only a thin Hermes adapter. It reads the Hermes terminal tool payload, calls `rtk rewrite` for the actual command decision, then mutates the terminal tool `command` before Hermes executes it.
## Hook 1 — `pre_tool_call`: command rewriting

All rewrite rules stay in Rust inside `rtk rewrite`. When RTK adds or changes command rewrite behavior, the Hermes plugin picks up that behavior by delegating to the RTK binary.
When a Hermes worker calls the `terminal` tool with a bare command like `find`, `grep`, `cat`, or `ls`, this hook rewrites it to its `rtk` equivalent before execution — identical to the Claude Code `PreToolUse` hook, but applied inside Hermes so all backends (local, Docker, SSH, Modal) benefit without requiring the model to prefix commands manually.

## Fail-open behavior
### How it works

The plugin reads the Hermes `terminal` tool payload, calls `rtk rewrite <command>`, and mutates the `command` field if RTK provides a rewrite. All rewrite rules stay in Rust inside `rtk rewrite` — when RTK adds or changes rewrite behavior, the Hermes plugin picks it up automatically.

### Examples

| Worker command | Executed as |
|---|---|
| `find /home -name '*.py'` | `rtk find /home -name '*.py'` |
| `grep -rn foo /src` | `rtk grep -rn foo /src` |
| `cat large_file.yaml` | `rtk read large_file.yaml` |
| `ls -la /some/dir` | `rtk ls -la /some/dir` |
| `cd /foo && find . -name '*.cs'` | `cd /foo && rtk find . -name '*.cs'` |
| `python3 script.py` | `python3 script.py` *(unchanged)* |
| `rtk find ...` | `rtk find ...` *(no double-wrapping)* |

---

## Hook 2 — `transform_tool_result`: output filtering

Filters the output of high-token tools before it enters the conversation context. Each filter is independent and fails open — if parsing fails, the original output is returned unchanged.

### Filters

| Tool(s) | What is filtered | Limit |
|---|---|---|
| `terminal` | JSON-unwrap output field, deduplicate lines, strip blanks | 100 lines |
| `browser_navigate` | Strip `Layout*` accessibility-tree noise, deduplicate, truncate snapshot | 120 lines |
| `read_file`, `execute_code` | Deduplicate lines, strip blank lines | 100 lines |
| `write_file`, `patch` | Replace full unified diff with a 1-line summary | — |
| `search_files` | Truncate to first 50 results | 50 results |
| `kanban_show`, `kanban_list` | Keep only the last 10 events | 10 events |

### `write_file` / `patch` diff summary

A 50-line unified diff like:

The plugin does not block command execution. If anything goes wrong, Hermes runs the original command unchanged.
```diff
--- a/src/Battle/Pig3DAnimator.cs
+++ b/src/Battle/Pig3DAnimator.cs
@@ -42,7 +42,6 @@
...
```

Is replaced with:

```
[patch] src/Battle/Pig3DAnimator.cs (2 hunks, +5/-3 lines)
```

This is the main fix for context overflow on patch-heavy tasks where multiple diffs accumulate in the conversation.

If rtk is not available in PATH when Hermes loads the plugin, the plugin prints a warning and skips hook registration.
### `browser_navigate` noise stripping

- `rtk` is missing from `PATH`
- `rtk rewrite` exits with an error
- Hermes sends a non-terminal tool call
- The tool payload has no string `command`
Unity accessibility snapshots and browser pages often contain dozens of `LayoutBlock`, `LayoutInline`, `LayoutTableRow`, etc. lines that carry no semantic content. These are stripped before the snapshot enters the model context, typically reducing browser tool output by ~21%.

---

## Fail-open behavior

The plugin never blocks command execution or tool result delivery. In all of the following cases, Hermes proceeds with the original value unchanged:

- `rtk` is missing from `PATH` (hook not registered; one warning logged)
- `rtk rewrite` exits with an unexpected error
- A tool payload has no string `command`
- JSON parsing of a tool result fails
- The plugin raises an unexpected exception

---

## Limitations

- Only Hermes `terminal` tool calls are rewritten.
- Commands skipped by `rtk rewrite` stay unchanged, including commands already prefixed with `rtk`, compound shell commands, heredocs, and commands without an RTK filter.
- Shell hooks are not used for Hermes command rewriting. The integration depends on Hermes loading Python plugins and passing a mutable terminal tool payload.
- Only the `terminal` tool call is rewritten by `pre_tool_call`. Other tools (file reads, browser, etc.) are not affected.
- Shell hooks are not used for command rewriting. The integration depends on Hermes loading Python plugins and passing a mutable terminal tool payload.
- `transform_tool_result` filters apply to the tool output string only. Structured metadata fields (e.g. `exit_code`) are preserved.
239 changes: 236 additions & 3 deletions hooks/hermes/rtk-rewrite/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Hermes plugin adapter for RTK command rewriting.
"""Hermes plugin adapter for RTK command rewriting and output filtering.

All rewrite logic lives in RTK's Rust ``rtk rewrite`` command; this module
only bridges Hermes ``pre_tool_call`` payloads to that command and fails open.
also provides a pure-Python ``transform_tool_result`` filter for high-token
tools (browser_navigate, execute_code, read_file, terminal).
"""

import json
import re
import shutil
import subprocess
import sys
Expand All @@ -14,13 +17,26 @@
_rtk_available = None
_rtk_missing_warned = False

# Accessibility-tree noise patterns produced by browser_navigate snapshots.
_LAYOUT_NOISE = re.compile(
r"^\s*-?\s*Layout(?:Table(?:Row|Cell|Section)?|Inline[A-Za-z]*|Block[A-Za-z]*)"
r"(?:\s+\"[^\"]{0,60}\"\s*)?$"
)

_SNAPSHOT_MAX_LINES = 120
_TEXT_MAX_LINES = 100
_SEARCH_MAX_RESULTS = 50
_KANBAN_MAX_EVENTS = 10
_KANBAN_MAX_COMMENTS = 5


def register(ctx):
"""Register the Hermes pre-tool callback."""
"""Register the Hermes pre-tool and transform-result callbacks."""
if not _check_rtk():
return

ctx.register_hook("pre_tool_call", _pre_tool_call)
ctx.register_hook("transform_tool_result", _transform_tool_result)


def _check_rtk():
Expand Down Expand Up @@ -76,5 +92,222 @@ def _pre_tool_call(tool_name=None, args=None, **_kwargs):
return


def _transform_tool_result(tool_name=None, result=None, **_kwargs):
"""Filter high-token tool outputs before they enter the conversation context."""
if tool_name == "browser_navigate":
return _filter_browser(result)
if tool_name == "terminal":
return _filter_terminal(result)
if tool_name in ("execute_code", "read_file"):
return _filter_text(result)
if tool_name in ("write_file", "patch"):
return _filter_diff(result)
if tool_name == "search_files":
return _filter_search_files(result)
if tool_name in ("kanban_show", "kanban_list"):
return _filter_kanban(result)


def _filter_browser(result):
"""Strip Layout* accessibility noise from browser_navigate JSON and truncate snapshot."""
if not isinstance(result, str) or not result.strip():
return
before = len(result)
try:
data = json.loads(result)
except (json.JSONDecodeError, ValueError):
return _filter_text(result)

snapshot = data.get("snapshot", "")
if snapshot:
lines = snapshot.splitlines()
lines = [l for l in lines if l.strip() and not _LAYOUT_NOISE.match(l)]
seen, deduped = set(), []
for l in lines:
key = l.strip()
if key not in seen:
seen.add(key)
deduped.append(l)
if len(deduped) > _SNAPSHOT_MAX_LINES:
deduped = deduped[:_SNAPSHOT_MAX_LINES] + [f"... [{len(lines) - _SNAPSHOT_MAX_LINES} lines truncated]"]
data["snapshot"] = "\n".join(deduped)

# Drop noisy metadata fields
for key in ("stealth_warning", "stealth_features"):
data.pop(key, None)

filtered = json.dumps(data, ensure_ascii=False)
after = len(filtered)
if after < before:
print(
f"rtk: filtered browser_navigate: {before} -> {after} chars"
f" ({100*(before-after)//before}% reduction)",
file=sys.stderr,
)
return filtered


def _filter_terminal(result):
"""Filter terminal tool output: parse JSON wrapper, filter the output field, repack."""
if not isinstance(result, str) or not result.strip():
return
before = len(result)
try:
data = json.loads(result)
except (json.JSONDecodeError, ValueError):
return _filter_text(result)

output = data.get("output", "")
if not output:
return

filtered_output = _filter_text(output)
if filtered_output is None:
return

data["output"] = filtered_output
filtered = json.dumps(data, ensure_ascii=False)
after = len(filtered)
if after < before:
print(
f"rtk: filtered terminal: {before} -> {after} chars"
f" ({100*(before-after)//before}% reduction)",
file=sys.stderr,
)
return filtered


def _filter_text(result):
"""Strip blank lines, deduplicate, and truncate generic text outputs."""
if not isinstance(result, str) or not result.strip():
return
before = len(result)
lines = result.splitlines()
seen, deduped = set(), []
for l in lines:
key = l.strip()
if not key:
continue
if key not in seen:
seen.add(key)
deduped.append(l)
if len(deduped) > _TEXT_MAX_LINES:
deduped = deduped[:_TEXT_MAX_LINES] + [f"... [{len(lines) - _TEXT_MAX_LINES} lines truncated]"]
filtered = "\n".join(deduped)
after = len(filtered)
if after < before:
print(
f"rtk: filtered text ({after}/{before} chars, {100*(before-after)//before}% reduction)",
file=sys.stderr,
)
return filtered


def _filter_diff(result):
"""Replace unified diff output (write_file / patch) with a compact summary."""
if not isinstance(result, str) or not result.strip():
return
before = len(result)
lines = result.splitlines()
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
# Extract file path from diff header (b/path) or first @@ hunk
path = ""
for l in lines:
if l.startswith("+++ b/"):
path = l[6:].strip()
break
if l.startswith("+++ "):
path = l[4:].strip()
break
hunks = sum(1 for l in lines if l.startswith("@@"))
summary = f"[patch] {path} ({hunks} hunk{'s' if hunks != 1 else ''}, +{added}/-{removed} lines)"
after = len(summary)
if after < before:
print(
f"rtk: filtered diff ({path}): {before} -> {after} chars"
f" ({100*(before-after)//before}% reduction)",
file=sys.stderr,
)
return summary


def _filter_search_files(result):
"""Truncate search_files output to _SEARCH_MAX_RESULTS results."""
if not isinstance(result, str) or not result.strip():
return
before = len(result)
lines = [l for l in result.splitlines() if l.strip()]
if len(lines) <= _SEARCH_MAX_RESULTS:
return
truncated = lines[:_SEARCH_MAX_RESULTS] + [f"... [{len(lines) - _SEARCH_MAX_RESULTS} results truncated]"]
filtered = "\n".join(truncated)
after = len(filtered)
print(
f"rtk: filtered search_files: {before} -> {after} chars"
f" ({100*(before-after)//before}% reduction)",
file=sys.stderr,
)
return filtered


def _filter_kanban(result):
"""Truncate kanban_show events list and comments to reduce context bloat."""
if not isinstance(result, str) or not result.strip():
return
before = len(result)
lines = result.splitlines()
out = []
in_events = False
in_comments = False
event_count = 0
comment_count = 0
skipped_events = 0
skipped_comments = 0

for l in lines:
stripped = l.strip()
if stripped.startswith("Events ("):
in_events = True
in_comments = False
out.append(l)
continue
if stripped.startswith("Runs (") or stripped.startswith("children:") or stripped.startswith("Body:"):
in_events = False
if skipped_events:
out.append(f" ... [{skipped_events} older events truncated]")
skipped_events = 0
out.append(l)
continue
if stripped.startswith("Comments (") or (in_comments and stripped.startswith("[")):
in_comments = True
in_events = False
out.append(l)
continue

if in_events:
if event_count < _KANBAN_MAX_EVENTS:
out.append(l)
event_count += 1
else:
skipped_events += 1
continue

out.append(l)

if skipped_events:
out.append(f" ... [{skipped_events} older events truncated]")

filtered = "\n".join(out)
after = len(filtered)
if after < before:
print(
f"rtk: filtered kanban: {before} -> {after} chars"
f" ({100*(before-after)//before}% reduction)",
file=sys.stderr,
)
return filtered


def _warn(message):
print(f"rtk: hermes plugin warning: {message}", file=sys.stderr)
4 changes: 3 additions & 1 deletion hooks/hermes/rtk-rewrite/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: rtk-rewrite
version: "0.1.0"
description: Rewrite Hermes terminal commands through RTK before execution.
description: Rewrite terminal commands and filter tool outputs through RTK.
author: RTK Contributors
hooks:
- pre_tool_call
- transform_tool_result
provides_hooks:
- pre_tool_call
- transform_tool_result
Loading