From 2081b71c427ee6481cd15fcab0962dc0cbd9bfc1 Mon Sep 17 00:00:00 2001 From: sjz-ks <166376523+sjz-ks@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:31:23 +0800 Subject: [PATCH] feat(tools): add terminal output transform hook --- hermes_cli/plugins.py | 1 + hermes_cli/tips.py | 3 +- tests/hermes_cli/test_plugins.py | 25 +++ .../test_terminal_output_transform_hook.py | 195 ++++++++++++++++++ tools/terminal_tool.py | 21 ++ 5 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_terminal_output_transform_hook.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 2385a5c942..06002efe18 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -54,6 +54,7 @@ logger = logging.getLogger(__name__) VALID_HOOKS: Set[str] = { "pre_tool_call", "post_tool_call", + "transform_terminal_output", "pre_llm_call", "post_llm_call", "pre_api_request", diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 77c2b24058..cf68d5eecd 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -245,7 +245,7 @@ TIPS = [ "Three plugin types: general (tools/hooks), memory providers, and context engines.", "hermes plugins install owner/repo installs plugins directly from GitHub.", "8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.", - "Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.", + "Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, and transform_terminal_output for foreground terminal output canonicalization.", # --- Miscellaneous --- "Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.", @@ -345,4 +345,3 @@ def get_random_tip(exclude_recent: int = 0) -> str: return random.choice(TIPS) - diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index a97340df58..a8f8c1b262 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -201,6 +201,7 @@ class TestPluginHooks: def test_valid_hooks_include_request_scoped_api_hooks(self): assert "pre_api_request" in VALID_HOOKS assert "post_api_request" in VALID_HOOKS + assert "transform_terminal_output" in VALID_HOOKS def test_register_and_invoke_hook(self, tmp_path, monkeypatch): """Registered hooks are called on invoke_hook().""" @@ -297,6 +298,30 @@ class TestPluginHooks: ) assert results == [{"seen": 2, "mc": 5, "tc": 3}] + def test_transform_terminal_output_hook_can_be_registered_and_invoked(self, tmp_path, monkeypatch): + plugins_dir = tmp_path / "hermes_test" / "plugins" + _make_plugin_dir( + plugins_dir, "transform_hook", + register_body=( + 'ctx.register_hook("transform_terminal_output", ' + 'lambda **kw: f"{kw[\'command\']}|{kw[\'returncode\']}|{kw[\'env_type\']}|{kw[\'task_id\']}|{len(kw[\'output\'])}")' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "transform_terminal_output", + command="echo hello", + output="abcdef", + returncode=7, + task_id="task-1", + env_type="local", + ) + assert results == ["echo hello|7|local|task-1|6"] + def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog): """Registering an unknown hook name logs a warning.""" plugins_dir = tmp_path / "hermes_test" / "plugins" diff --git a/tests/tools/test_terminal_output_transform_hook.py b/tests/tools/test_terminal_output_transform_hook.py new file mode 100644 index 0000000000..6eca4135ee --- /dev/null +++ b/tests/tools/test_terminal_output_transform_hook.py @@ -0,0 +1,195 @@ +import json +import os +from pathlib import Path +from unittest.mock import MagicMock + +import hermes_cli.plugins as plugins_mod +import tools.terminal_tool as terminal_tool_module + + +_UNSET = object() + + +def _make_env_config(tmp_path, **overrides): + config = { + "env_type": "local", + "timeout": 30, + "cwd": str(tmp_path), + "host_cwd": None, + "modal_mode": "auto", + "docker_image": "", + "singularity_image": "", + "modal_image": "", + "daytona_image": "", + } + config.update(overrides) + return config + + +def _run_terminal( + monkeypatch, + tmp_path, + *, + output, + returncode=0, + invoke_hook=_UNSET, + approval=None, + command="echo hello", +): + mock_env = MagicMock() + mock_env.execute.return_value = {"output": output, "returncode": returncode} + + monkeypatch.setattr( + terminal_tool_module, "_get_env_config", lambda: _make_env_config(tmp_path) + ) + monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr( + terminal_tool_module, + "_check_all_guards", + lambda *_args, **_kwargs: approval or {"approved": True}, + ) + monkeypatch.setitem(terminal_tool_module._active_environments, "default", mock_env) + monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0) + + if invoke_hook is not _UNSET: + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook) + + result = json.loads(terminal_tool_module.terminal_tool(command=command)) + return result, mock_env + + +def test_terminal_output_unchanged_when_transform_hook_not_registered(monkeypatch, tmp_path): + result, _mock_env = _run_terminal(monkeypatch, tmp_path, output="plain output") + + assert result["output"] == "plain output" + assert result["exit_code"] == 0 + assert result["error"] is None + + +def test_terminal_output_unchanged_for_none_hook_result(monkeypatch, tmp_path): + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="plain output", + invoke_hook=lambda hook_name, **kwargs: [None], + ) + + assert result["output"] == "plain output" + + +def test_terminal_output_ignores_invalid_hook_results(monkeypatch, tmp_path): + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="plain output", + invoke_hook=lambda hook_name, **kwargs: [{"bad": True}, 123, ["nope"]], + ) + + assert result["output"] == "plain output" + + +def test_terminal_output_uses_first_valid_string_from_hooks(monkeypatch, tmp_path): + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="plain output", + invoke_hook=lambda hook_name, **kwargs: [None, {"bad": True}, "first", "second"], + ) + + assert result["output"] == "first" + + +def test_terminal_output_transform_still_truncates_long_replacement(monkeypatch, tmp_path): + transformed_output = "PLUGIN-HEAD\n" + ("A" * 60000) + "\nPLUGIN-TAIL" + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="short output", + invoke_hook=lambda hook_name, **kwargs: [transformed_output], + ) + + assert "PLUGIN-HEAD" in result["output"] + assert "PLUGIN-TAIL" in result["output"] + assert "[OUTPUT TRUNCATED" in result["output"] + assert transformed_output != result["output"] + + +def test_terminal_output_transform_still_runs_strip_and_redact(monkeypatch, tmp_path): + secret = "sk-proj-abc123def456ghi789jkl012mno345" + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="plain output", + invoke_hook=lambda hook_name, **kwargs: [f" \x1b[31mOPENAI_API_KEY={secret}\x1b[0m "], + ) + + assert "\x1b" not in result["output"] + assert secret not in result["output"] + assert "OPENAI_API_KEY=" in result["output"] + assert "***" in result["output"] + + +def test_terminal_output_transform_hook_exception_falls_back(monkeypatch, tmp_path): + def _raise(*_args, **_kwargs): + raise RuntimeError("boom") + + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="plain output", + invoke_hook=_raise, + ) + + assert result["output"] == "plain output" + assert result["exit_code"] == 0 + assert result["error"] is None + + +def test_terminal_output_transform_does_not_change_approval_or_exit_code_meaning(monkeypatch, tmp_path): + approval = { + "approved": True, + "user_approved": True, + "description": "dangerous command", + } + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output="original output", + returncode=1, + approval=approval, + command="grep foo bar", + invoke_hook=lambda hook_name, **kwargs: ["replaced output"], + ) + + assert result["output"] == "replaced output" + assert result["approval"] == ( + "Command required approval (dangerous command) and was approved by the user." + ) + assert result["exit_code_meaning"] == "No matches found (not an error)" + + +def test_terminal_output_transform_integration_with_real_plugin(monkeypatch, tmp_path): + hermes_home = Path(os.environ["HERMES_HOME"]) + plugins_dir = hermes_home / "plugins" + plugin_dir = plugins_dir / "terminal_transform" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text("name: terminal_transform\n", encoding="utf-8") + (plugin_dir / "__init__.py").write_text( + "def register(ctx):\n" + ' ctx.register_hook("transform_terminal_output", ' + 'lambda **kw: "PLUGIN-HEAD\\n" + kw["output"] + "\\nPLUGIN-TAIL")\n', + encoding="utf-8", + ) + + plugins_mod.discover_plugins() + + long_output = "X" * 60000 + result, _mock_env = _run_terminal( + monkeypatch, + tmp_path, + output=long_output, + ) + + assert "PLUGIN-HEAD" in result["output"] + assert "PLUGIN-TAIL" in result["output"] + assert "[OUTPUT TRUNCATED" in result["output"] diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 6a69a3b839..732b50b14e 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1721,6 +1721,27 @@ def terminal_tool( # Add helpful message for sudo failures in messaging context output = _handle_sudo_failure(output, env_type) + + # Foreground terminal output canonicalization seam: plugins receive + # the full output string before default truncation and may only + # replace it by returning a string from transform_terminal_output. + # The hook is fail-open, and the first valid string return wins. + try: + from hermes_cli.plugins import invoke_hook + hook_results = invoke_hook( + "transform_terminal_output", + command=command, + output=output, + returncode=returncode, + task_id=effective_task_id or "", + env_type=env_type, + ) + for hook_result in hook_results: + if isinstance(hook_result, str): + output = hook_result + break + except Exception: + pass # Truncate output if too long, keeping both head and tail MAX_OUTPUT_CHARS = 50000