{"repo":"NousResearch/hermes-agent","public_id":"8933","display_label":"Plugin hook to transform tool results pre-transcript","summary":"These members all concern adding a dedicated plugin hook that intercepts and transforms tool output before it is appended to the conversation transcript. Proposed use cases include compression, redaction, and generic rewriting of tool results.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12972},"resolution":{"state":"partially_resolved","by":"pr-12972"},"freshness":{"state":"stale","last_verified_at":"2026-04-20T10:48:08+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":8933,"pr_number":12972,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":8933,"title":"[Feature]: Dedicated plugin hook to transform tool results before they are added to the conversation transcript","url":null,"state":"closed"},{"kind":"issue","number":16063,"title":"Feature request: pre_tool_result hook for tool output interception/compression","url":null,"state":"closed"},{"kind":"pr","number":10155,"title":"feat(tools): add terminal output transform hook","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":12972,"title":"feat(plugins): add transform_tool_result hook for generic tool-result rewriting","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/plugins.py","model_tools.py","tests/hermes_cli/test_plugins.py","tests/test_model_tools.py","tests/test_transform_tool_result_hook.py","tests/tools/test_terminal_output_transform_hook.py"],"fix_hunks":[{"file":"hermes_cli/plugins.py","hunks":["@@ -55,6 +55,7 @@\n     \"pre_tool_call\",\n     \"post_tool_call\",\n     \"transform_terminal_output\",\n+    \"transform_tool_result\",\n     \"pre_llm_call\",\n     \"post_llm_call\",\n     \"pre_api_request\","]},{"file":"model_tools.py","hunks":["@@ -550,6 +550,30 @@ def handle_function_call(\n         except Exception:\n             pass\n \n+        # Generic tool-result canonicalization seam: plugins receive the\n+        # final result string (JSON, usually) and may replace it by\n+        # returning a string from transform_tool_result. Runs after\n+        # post_tool_call (which stays observational) and before the result\n+        # is appended back into conversation context. Fail-open; the first\n+        # valid string return wins; non-string returns are ignored.\n+        try:\n+            from hermes_cli.plugins import invoke_hook\n+            hook_results = invoke_hook(\n+                \"transform_tool_result\",\n+                tool_name=function_name,\n+                args=function_args,\n+                result=result,\n+                task_id=task_id or \"\",\n+                session_id=session_id or \"\",\n+                tool_call_id=tool_call_id or \"\",\n+            )\n+            for hook_result in hook_results:\n+                if isinstance(hook_result, str):\n+                    result = hook_result\n+                    break\n+        except Exception:\n+            pass\n+\n         return result\n \n     except Exception as e:"]},{"file":"tests/hermes_cli/test_plugins.py","hunks":["@@ -202,6 +202,7 @@ def test_valid_hooks_include_request_scoped_api_hooks(self):\n         assert \"pre_api_request\" in VALID_HOOKS\n         assert \"post_api_request\" in VALID_HOOKS\n         assert \"transform_terminal_output\" in VALID_HOOKS\n+        assert \"transform_tool_result\" in VALID_HOOKS\n \n     def test_register_and_invoke_hook(self, tmp_path, monkeypatch):\n         \"\"\"Registered hooks are called on invoke_hook().\"\"\""]},{"file":"tests/test_model_tools.py","hunks":["@@ -72,6 +72,15 @@ def test_tool_hooks_receive_session_and_tool_call_ids(self):\n                 session_id=\"session-1\",\n                 tool_call_id=\"call-1\",\n             ),\n+            call(\n+                \"transform_tool_result\",\n+                tool_name=\"web_search\",\n+                args={\"q\": \"test\"},\n+                result='{\"ok\":true}',\n+                task_id=\"task-1\",\n+                session_id=\"session-1\",\n+                tool_call_id=\"call-1\",\n+            ),\n         ]\n \n "]},{"file":"tests/test_transform_tool_result_hook.py","hunks":["@@ -0,0 +1,183 @@\n+\"\"\"Tests for the ``transform_tool_result`` plugin hook wired into\n+``model_tools.handle_function_call``.\n+\n+Mirrors the ``transform_terminal_output`` hook tests from Phase 1 but\n+targets the generic tool-result seam that runs for every tool dispatch.\n+\"\"\"\n+\n+import json\n+import os\n+from pathlib import Path\n+from unittest.mock import MagicMock\n+\n+import hermes_cli.plugins as plugins_mod\n+import model_tools\n+\n+\n+_UNSET = object()\n+\n+\n+def _run_handle_function_call(\n+    monkeypatch,\n+    *,\n+    tool_name=\"dummy_tool\",\n+    tool_args=None,\n+    dispatch_result='{\"output\": \"original\"}',\n+    invoke_hook=_UNSET,\n+):\n+    \"\"\"Drive ``handle_function_call`` with a mocked registry dispatch.\"\"\"\n+    from tools.registry import registry\n+\n+    monkeypatch.setattr(\n+        registry, \"dispatch\",\n+        lambda name, args, **kw: dispatch_result,\n+    )\n+    # Skip unrelated side effects (read-loop tracker).\n+    monkeypatch.setattr(model_tools, \"_READ_SEARCH_TOOLS\", frozenset())\n+\n+    if invoke_hook is not _UNSET:\n+        # Patch the symbol actually imported inside handle_function_call.\n+        monkeypatch.setattr(\"hermes_cli.plugins.invoke_hook\", invoke_hook)\n+\n+    return model_tools.handle_function_call(\n+        tool_name,\n+        tool_args or {},\n+        task_id=\"t1\",\n+        session_id=\"s1\",\n+        tool_call_id=\"tc1\",\n+        skip_pre_tool_call_hook=True,\n+    )\n+\n+\n+def test_result_unchanged_when_no_hook_registered(monkeypatch):\n+    # Real invoke_hook with no plugins loaded returns [].\n+    monkeypatch.setenv(\"HERMES_HOME\", \"/tmp/hermes_no_plugins\")\n+    # Force a fresh plugin manager so no stale plugins pollute state.\n+    plugins_mod._plugin_manager = plugins_mod.PluginManager()\n+\n+    out = _run_handle_function_call(monkeypatch)\n+    assert out == '{\"output\": \"original\"}'\n+\n+\n+def test_result_unchanged_for_none_hook_return(monkeypatch):\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        invoke_hook=lambda hook_name, **kw: [None],\n+    )\n+    assert out == '{\"output\": \"original\"}'\n+\n+\n+def test_result_ignores_non_string_hook_returns(monkeypatch):\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        invoke_hook=lambda hook_name, **kw: [{\"bad\": True}, 123, [\"nope\"]],\n+    )\n+    assert out == '{\"output\": \"original\"}'\n+\n+\n+def test_first_valid_string_return_replaces_result(monkeypatch):\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        invoke_hook=lambda hook_name, **kw: [None, {\"x\": 1}, \"first\", \"second\"],\n+    )\n+    assert out == \"first\"\n+\n+\n+def test_hook_receives_expected_kwargs(monkeypatch):\n+    captured = {}\n+\n+    def _hook(hook_name, **kwargs):\n+        if hook_name == \"transform_tool_result\":\n+            captured.update(kwargs)\n+        return []\n+\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        tool_name=\"my_tool\",\n+        tool_args={\"a\": 1, \"b\": \"x\"},\n+        dispatch_result='{\"ok\": true}',\n+        invoke_hook=_hook,\n+    )\n+    assert out == '{\"ok\": true}'\n+    assert captured[\"tool_name\"] == \"my_tool\"\n+    assert captured[\"args\"] == {\"a\": 1, \"b\": \"x\"}\n+    assert captured[\"result\"] == '{\"ok\": true}'\n+    assert captured[\"task_id\"] == \"t1\"\n+    assert captured[\"session_id\"] == \"s1\"\n+    assert captured[\"tool_call_id\"] == \"tc1\"\n+\n+\n+def test_hook_exception_falls_back_to_original(monkeypatch):\n+    def _raise(*_a, **_kw):\n+        raise RuntimeError(\"boom\")\n+\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        invoke_hook=_raise,\n+    )\n+    assert out == '{\"output\": \"original\"}'\n+\n+\n+def test_post_tool_call_remains_observational(monkeypatch):\n+    \"\"\"post_tool_call return values must NOT replace the result.\"\"\"\n+    def _hook(hook_name, **kw):\n+        if hook_name == \"post_tool_call\":\n+            # Observers returning a string must be ignored.\n+            return [\"observer return should be ignored\"]\n+        return []\n+\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        invoke_hook=_hook,\n+    )\n+    assert out == '{\"output\": \"original\"}'\n+\n+\n+def test_transform_tool_result_runs_after_post_tool_call(monkeypatch):\n+    \"\"\"post_tool_call sees ORIGINAL result; transform_tool_result sees same and may replace.\"\"\"\n+    observed = []\n+\n+    def _hook(hook_name, **kw):\n+        if hook_name == \"post_tool_call\":\n+            observed.append((\"post_tool_call\", kw[\"result\"]))\n+            return []\n+        if hook_name == \"transform_tool_result\":\n+            observed.append((\"transform_tool_result\", kw[\"result\"]))\n+            return [\"rewritten\"]\n+        return []\n+\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        dispatch_result='{\"raw\": \"value\"}',\n+        invoke_hook=_hook,\n+    )\n+    assert out == \"rewritten\"\n+    # Both hooks saw the ORIGINAL (untransformed) result.\n+    assert observed == [\n+        (\"post_tool_call\", '{\"raw\": \"value\"}'),\n+        (\"transform_tool_result\", '{\"raw\": \"value\"}'),\n+    ]\n+\n+\n+def test_transform_tool_result_integration_with_real_plugin(monkeypatch, tmp_path):\n+    \"\"\"End-to-end: load a real plugin from HERMES_HOME and verify it rewrites results.\"\"\"\n+    hermes_home = Path(os.environ[\"HERMES_HOME\"])\n+    plugins_dir = hermes_home / \"plugins\"\n+    plugin_dir = plugins_dir / \"transform_result_canon\"\n+    plugin_dir.mkdir(parents=True)\n+    (plugin_dir / \"plugin.yaml\").write_text(\"name: transform_result_canon\\n\", encoding=\"utf-8\")\n+    (plugin_dir / \"__init__.py\").write_text(\n+        \"def register(ctx):\\n\"\n+        '    ctx.register_hook(\"transform_tool_result\", '\n+        'lambda **kw: f\\'CANON[{kw[\"tool_name\"]}]\\' + kw[\"result\"])\\n',\n+        encoding=\"utf-8\",\n+    )\n+\n+    plugins_mod.discover_plugins()\n+\n+    out = _run_handle_function_call(\n+        monkeypatch,\n+        tool_name=\"some_tool\",\n+        dispatch_result='{\"payload\": 42}',\n+    )\n+    assert out == 'CANON[some_tool]{\"payload\": 42}'"]},{"file":"tests/tools/test_terminal_output_transform_hook.py","hunks":["@@ -115,6 +115,10 @@ def test_terminal_output_transform_still_truncates_long_replacement(monkeypatch,\n \n \n def test_terminal_output_transform_still_runs_strip_and_redact(monkeypatch, tmp_path):\n+    # Ensure redaction is active regardless of host HERMES_REDACT_SECRETS state\n+    # or collection-time import order (the module snapshots env at import).\n+    monkeypatch.setattr(\"agent.redact._REDACT_ENABLED\", True)\n+\n     secret = \"[redacted:secret-assignment]\"\n     result, _mock_env = _run_terminal(\n         monkeypatch,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}