perf(observability): gate tool-hook emit on has_hook; slim per-tool footprint

The salvaged observer contract gated the API-request hot path on has_hook()
but left the per-tool emit ungated: every tool call ran result-field
derivation + payload dict build + invoke_hook dispatch even with zero
plugins registered.

- _emit_post_tool_call_hook now short-circuits on has_hook("post_tool_call")
  and derives status/error fields lazily (after the gate, only when a
  listener will consume them). status defaults to None -> derived; explicit
  blocked/cancelled callers still pass status through.
- transform_tool_result emit (pre-existing hook) likewise gated on
  has_hook(); skips _tool_result_observer_fields when no listener.
- Removed the now-redundant _tool_result_observer_fields pre-computation at
  the three ok-path call sites (model_tools, agent_runtime_helpers,
  tool_executor) — the helper derives them, so the no-listener path costs
  one dict lookup and the call sites shrink.
- Tests: stub has_hook=True where payload correctness is asserted; add a
  no-listener regression proving post_tool_call/transform_tool_result emit
  is skipped when nothing is registered.
This commit is contained in:
teknium1
2026-06-03 06:36:46 -07:00
committed by Teknium
parent 432325933a
commit 827f251426
5 changed files with 79 additions and 44 deletions
+24
View File
@@ -42,6 +42,7 @@ class TestHandleFunctionCall:
def test_tool_hooks_receive_session_and_tool_call_ids(self):
with (
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
patch("hermes_cli.plugins.has_hook", return_value=True),
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
):
result = handle_function_call(
@@ -104,6 +105,7 @@ class TestHandleFunctionCall:
"""
with (
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
patch("hermes_cli.plugins.has_hook", return_value=True),
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
):
handle_function_call("web_search", {"q": "test"}, task_id="t1")
@@ -123,6 +125,26 @@ class TestHandleFunctionCall:
# pre_tool_call does NOT get duration_ms (nothing has run yet).
assert "duration_ms" not in kwargs_by_hook["pre_tool_call"]
def test_no_listener_skips_post_and_transform_emit(self):
"""When no plugin is registered for post_tool_call /
transform_tool_result, the emit path must short-circuit on
``has_hook`` and never build/dispatch a payload — so the
no-listener hot path stays cheap. ``pre_tool_call`` is always
polled (block-check), so it may still fire; the observer/transform
emits must not.
"""
with (
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
patch("hermes_cli.plugins.has_hook", return_value=False),
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
):
result = handle_function_call("web_search", {"q": "test"}, task_id="t1")
assert result == '{"ok":true}'
fired = {c.args[0] for c in mock_invoke_hook.call_args_list}
assert "post_tool_call" not in fired
assert "transform_tool_result" not in fired
# =========================================================================
# Agent loop tools
@@ -165,6 +187,7 @@ class TestPreToolCallBlocking:
raise AssertionError("dispatch should not run when blocked")
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch)
result = json.loads(handle_function_call("read_file", {"path": "test.txt"}, task_id="t1"))
@@ -228,6 +251,7 @@ class TestPreToolCallBlocking:
return []
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("model_tools.registry.dispatch",
lambda *a, **kw: json.dumps({"ok": True}))