Skip to content
Merged
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
70 changes: 2 additions & 68 deletions src/integrations/hermes-agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,11 +573,6 @@ def _tune():

# ── Registration ──────────────────────────────────────────────

# ── Session tracking for new lifecycle hooks
_modified_files: list = []
_validation_results: list = []
_errors: list = []

# ── Subagent (delegate_task) enforcement ────────────────────
# Subagents bypass all StringRay hooks because they run in isolated
# contexts. We enforce by snapshotting the working tree before dispatch
Expand Down Expand Up @@ -674,56 +669,6 @@ def _validate_subagent_changes(task_id: str, **kwargs):
f"[subagent-validate] PASSED: {rel_path}")


def _on_file_write(file_path: str, content: str, tool_name: str, **kwargs):
"""Fires when a code-producing tool writes a file.

Validates the file was written correctly and logs the event.
"""
_log_to_file("activity.log",
f"[file-write] path={file_path} tool={tool_name} size={len(content) if content else 0}")

_modified_files.append({
"path": file_path,
"tool": tool_name,
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
})


def _on_validation_result(tool_name: str, passed: bool, violations: list, **kwargs):
"""Fires when a validation/check completes.

Tracks validation outcomes for session context.
"""
_log_to_file("activity.log",
f"[validation] tool={tool_name} passed={passed} violations={len(violations)}")

_validation_results.append({
"tool": tool_name,
"passed": passed,
"violation_count": len(violations),
"violations": violations[:5],
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
})


def _on_error(tool_name: str, error: str, args: dict, **kwargs):
"""Fires when a tool call fails.

Logs the error and tracks it for session context.
"""
_log_to_file("activity.log",
f"[error] tool={tool_name} error={str(error)[:200]}")

_session_stats["bridge_errors"] += 1

_errors.append({
"tool": tool_name,
"error": str(error)[:200],
"args_keys": list((args or {}).keys()),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
})


def register(ctx):
"""Wire schemas to handlers and register lifecycle hooks."""
# ── Register tools ────────────────────────────────────────
Expand Down Expand Up @@ -762,17 +707,6 @@ def register(ctx):
except (AttributeError, TypeError):
logger.debug("[strray] on_session_start hook not yet available")

# Try to register new lifecycle hooks
for hook_name, hook_fn in [
("on_file_write", _on_file_write),
("on_validation_result", _on_validation_result),
("on_error", _on_error),
]:
try:
ctx.register_hook(hook_name, hook_fn)
except (AttributeError, TypeError):
logger.debug("[strray] %s hook not yet available", hook_name)

# ── Register slash command ────────────────────────────────
try:
ctx.register_command(
Expand All @@ -789,10 +723,10 @@ def register(ctx):
_ensure_log_dir()
_log_to_file("activity.log",
f"[plugin-loaded] StringRay Hermes Plugin v2.2 — "
f"4 tools, 5 hooks, subagent enforcement, bridge={BRIDGE_PATH.exists()}")
f"4 tools, 2 hooks, subagent enforcement, bridge={BRIDGE_PATH.exists()}")

logger.info(
"[strray] Plugin v2.2 loaded: 4 tools, 5 hooks, "
"[strray] Plugin v2.2 loaded: 4 tools, 2 hooks, "
"subagent enforcement active, bridge=%s",
BRIDGE_PATH.exists(),
)
5 changes: 1 addition & 4 deletions src/integrations/hermes-agent/after-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@
| `strray_health` | Framework health check |
| `strray_hooks` | Git hooks management (install, uninstall, list, status) |
| `pre_tool_call` hook | Quality gates + nudges before every tool call |
| `post_tool_call` hook | Post-processors + file tracking after every tool call |
| `on_file_write` hook | File write tracking and logging |
| `on_validation_result` hook | Validation outcome tracking |
| `on_error` hook | Error logging and session tracking |
| `post_tool_call` hook | Post-processors after every tool call |

## Git Hooks

Expand Down
3 changes: 0 additions & 3 deletions src/integrations/hermes-agent/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,3 @@ provides_tools:
provides_hooks:
- pre_tool_call
- post_tool_call
- on_file_write
- on_validation_result
- on_error
107 changes: 6 additions & 101 deletions src/integrations/hermes-agent/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1048,97 +1048,8 @@ def test_description_mentions_hooks(self):
self.assertIn("git hooks", s["description"])


class TestLifecycleHooks(unittest.TestCase):
"""Tests for the new lifecycle hooks: on_file_write, on_validation_result, on_error."""

def setUp(self):
# Reset tracking lists
pi._modified_files = []
pi._validation_results = []
pi._errors = []

def test_on_file_write_logs(self):
with tempfile.TemporaryDirectory() as td:
log_dir = Path(td)
original = pi.LOG_DIR
pi.LOG_DIR = log_dir

pi._on_file_write("src/index.ts", "hello world", "write_file")

self.assertEqual(len(pi._modified_files), 1)
self.assertEqual(pi._modified_files[0]["path"], "src/index.ts")
self.assertEqual(pi._modified_files[0]["tool"], "write_file")

content = (log_dir / "activity.log").read_text()
self.assertIn("[file-write]", content)

pi.LOG_DIR = original

def test_on_file_write_empty_content(self):
pi._on_file_write("a.ts", "", "write_file")
self.assertEqual(len(pi._modified_files), 1)

def test_on_validation_result_passed(self):
with tempfile.TemporaryDirectory() as td:
log_dir = Path(td)
original = pi.LOG_DIR
pi.LOG_DIR = log_dir

pi._on_validation_result("strray_validate", True, [])

self.assertEqual(len(pi._validation_results), 1)
self.assertTrue(pi._validation_results[0]["passed"])

content = (log_dir / "activity.log").read_text()
self.assertIn("[validation]", content)

pi.LOG_DIR = original

def test_on_validation_result_failed(self):
pi._on_validation_result("strray_codex_check", False, ["console.log found"])
self.assertFalse(pi._validation_results[0]["passed"])
self.assertEqual(pi._validation_results[0]["violation_count"], 1)
self.assertEqual(len(pi._validation_results[0]["violations"]), 1)

def test_on_validation_result_truncates_violations(self):
many = [f"violation-{i}" for i in range(20)]
pi._on_validation_result("test", False, many)
# Should keep only first 5
self.assertEqual(len(pi._validation_results[0]["violations"]), 5)

def test_on_error_logs(self):
with tempfile.TemporaryDirectory() as td:
log_dir = Path(td)
original = pi.LOG_DIR
pi.LOG_DIR = log_dir

pi._on_error("write_file", "disk full", {"path": "a.ts"})

self.assertEqual(len(pi._errors), 1)
self.assertEqual(pi._errors[0]["tool"], "write_file")

content = (log_dir / "activity.log").read_text()
self.assertIn("[error]", content)

pi.LOG_DIR = original

def test_on_error_increments_stats(self):
initial = pi._session_stats["bridge_errors"]
pi._on_error("terminal", "timeout", None)
self.assertEqual(pi._session_stats["bridge_errors"], initial + 1)

def test_on_error_truncates_long_error(self):
long_error = "x" * 500
pi._on_error("tool", long_error, {})
self.assertLessEqual(len(pi._errors[0]["error"]), 200)

def test_on_error_no_args(self):
pi._on_error("tool", "crash", None)
self.assertEqual(pi._errors[0]["args_keys"], [])


class TestRegisterIntegrationV2_1(unittest.TestCase):
"""Test that register() wires all 4 tools and 5 hooks in v2.1."""
"""Test that register() wires all 4 tools and 2 hooks in v2.2."""

def test_wires_four_tools(self):
ctx = MagicMock()
Expand All @@ -1161,33 +1072,27 @@ def test_strray_hooks_handler_wired(self):
hm = {c[1]["name"]: c[1]["handler"] for c in ctx.register_tool.call_args_list}
self.assertIs(hm["strray_hooks"], tools_mod.strray_hooks)

def test_registers_five_hooks(self):
def test_registers_two_hooks(self):
ctx = MagicMock()
pi.register(ctx)
hook_names = [c[0][0] for c in ctx.register_hook.call_args_list]
self.assertIn("pre_tool_call", hook_names)
self.assertIn("post_tool_call", hook_names)
self.assertIn("on_file_write", hook_names)
self.assertIn("on_validation_result", hook_names)
self.assertIn("on_error", hook_names)

def test_survives_missing_lifecycle_hooks(self):
"""New hooks should fail gracefully if not supported."""
def test_survives_missing_session_hook(self):
"""Session hook should fail gracefully if not supported."""
ctx = MagicMock()
# All 5 hook registrations: pre_tool_call, post_tool_call, on_session_start, on_file_write, on_validation_result, on_error
# Let the 3 new ones raise
def side_effect(*args):
raise AttributeError("not available")
ctx.register_hook.side_effect = [None, None, None, side_effect, side_effect, side_effect]
ctx.register_hook.side_effect = [None, None, side_effect]
pi.register(ctx) # should not raise

def test_v2_1_log_message(self):
def test_v2_2_log_message(self):
ctx = MagicMock()
with self.assertLogs("strray-hermes", level="INFO") as cm:
pi.register(ctx)
self.assertTrue(any("v2.2" in m for m in cm.output))
self.assertTrue(any("4 tools" in m for m in cm.output))
self.assertTrue(any("5 hooks" in m for m in cm.output))


if __name__ == "__main__":
Expand Down
15 changes: 3 additions & 12 deletions src/skills/hermes-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Hooks available: pre-commit, post-commit, pre-push, post-push.
| `pre-push` | Blocking | Full validation suite before push |
| `post-push` | Non-blocking | Comprehensive monitoring after push |

## 5 Lifecycle Hooks
## 2 Lifecycle Hooks

These fire automatically — no action needed from the user or agent.

Expand All @@ -129,19 +129,10 @@ Fires before ANY tool executes:
Fires after ANY tool returns:
1. Logs tool-complete event
2. For code-producing tools: runs post-processors via bridge
3. Tracks file modifications for session context

### on_file_write
### on_session_start (graceful fallback)

Fires when a code-producing tool writes a file. Validates and logs the event.

### on_validation_result

Fires when a validation/check completes. Tracks outcomes for session context.

### on_error

Fires when a tool call fails. Logs the error and tracks it.
Fires when a new session starts. Resets stats and logs to disk. Registration is wrapped in try/except for hosts that don't support it yet.

## Slash Command

Expand Down
Loading