2026-02-26 13:54:20 +03:00
|
|
|
"""Tests for model_tools.py — function call dispatch, agent-loop interception, legacy toolsets."""
|
|
|
|
|
|
|
|
|
|
import json
|
2026-04-25 22:13:12 -07:00
|
|
|
from unittest.mock import ANY, call, patch
|
2026-03-29 12:26:44 +05:30
|
|
|
|
2026-02-26 13:54:20 +03:00
|
|
|
|
|
|
|
|
from model_tools import (
|
|
|
|
|
handle_function_call,
|
|
|
|
|
get_all_tool_names,
|
|
|
|
|
get_toolset_for_tool,
|
|
|
|
|
_AGENT_LOOP_TOOLS,
|
|
|
|
|
_LEGACY_TOOLSET_MAP,
|
|
|
|
|
TOOL_TO_TOOLSET_MAP,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# handle_function_call
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestHandleFunctionCall:
|
|
|
|
|
def test_agent_loop_tool_returns_error(self):
|
|
|
|
|
for tool_name in _AGENT_LOOP_TOOLS:
|
|
|
|
|
result = json.loads(handle_function_call(tool_name, {}))
|
|
|
|
|
assert "error" in result
|
|
|
|
|
assert "agent loop" in result["error"].lower()
|
|
|
|
|
|
|
|
|
|
def test_unknown_tool_returns_error(self):
|
|
|
|
|
result = json.loads(handle_function_call("totally_fake_tool_xyz", {}))
|
|
|
|
|
assert "error" in result
|
2026-03-05 18:46:30 -08:00
|
|
|
assert "totally_fake_tool_xyz" in result["error"]
|
2026-02-26 13:54:20 +03:00
|
|
|
|
|
|
|
|
def test_exception_returns_json_error(self):
|
|
|
|
|
# Even if something goes wrong, should return valid JSON
|
|
|
|
|
result = handle_function_call("web_search", None) # None args may cause issues
|
|
|
|
|
parsed = json.loads(result)
|
|
|
|
|
assert isinstance(parsed, dict)
|
2026-03-05 18:46:30 -08:00
|
|
|
assert "error" in parsed
|
|
|
|
|
assert len(parsed["error"]) > 0
|
|
|
|
|
assert "error" in parsed["error"].lower() or "failed" in parsed["error"].lower()
|
2026-02-26 13:54:20 +03:00
|
|
|
|
2026-03-29 12:26:44 +05:30
|
|
|
def test_tool_hooks_receive_session_and_tool_call_ids(self):
|
|
|
|
|
with (
|
|
|
|
|
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
|
2026-06-03 06:05:35 -07:00
|
|
|
patch("hermes_cli.plugins.has_hook", return_value=True),
|
2026-03-29 12:26:44 +05:30
|
|
|
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
|
|
|
|
|
):
|
|
|
|
|
result = handle_function_call(
|
|
|
|
|
"web_search",
|
|
|
|
|
{"q": "test"},
|
|
|
|
|
task_id="task-1",
|
|
|
|
|
tool_call_id="call-1",
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result == '{"ok":true}'
|
|
|
|
|
assert mock_invoke_hook.call_args_list == [
|
|
|
|
|
call(
|
|
|
|
|
"pre_tool_call",
|
|
|
|
|
tool_name="web_search",
|
|
|
|
|
args={"q": "test"},
|
|
|
|
|
task_id="task-1",
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
tool_call_id="call-1",
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
turn_id="",
|
|
|
|
|
api_request_id="",
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace=[],
|
2026-03-29 12:26:44 +05:30
|
|
|
),
|
|
|
|
|
call(
|
|
|
|
|
"post_tool_call",
|
|
|
|
|
tool_name="web_search",
|
|
|
|
|
args={"q": "test"},
|
|
|
|
|
result='{"ok":true}',
|
|
|
|
|
task_id="task-1",
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
tool_call_id="call-1",
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
turn_id="",
|
|
|
|
|
api_request_id="",
|
2026-04-25 22:13:12 -07:00
|
|
|
duration_ms=ANY,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
status="ok",
|
|
|
|
|
error_type=None,
|
|
|
|
|
error_message=None,
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace=[],
|
2026-03-29 12:26:44 +05:30
|
|
|
),
|
feat(plugins): add transform_tool_result hook for generic tool-result rewriting (#12972)
Closes #8933 more fully, extending the per-tool transform_terminal_output
hook from #12929 to a generic seam that fires after every tool dispatch.
Plugins can rewrite any tool's result string (normalize formats, redact
fields, summarize verbose output) without wrapping individual tools.
Changes
- hermes_cli/plugins.py: add "transform_tool_result" to VALID_HOOKS
- model_tools.py: invoke the hook in handle_function_call after
post_tool_call (which remains observational); first valid str return
replaces the result; fail-open
- tests/test_transform_tool_result_hook.py: 9 new tests covering no-op,
None return, non-string return, first-match wins, kwargs, hook
exception fallback, post_tool_call observation invariant, ordering
vs post_tool_call, and an end-to-end real-plugin integration
- tests/hermes_cli/test_plugins.py: assert new hook in VALID_HOOKS
- tests/test_model_tools.py: extend the hook-call-sequence assertion
to include the new hook
Design
- transform_tool_result runs AFTER post_tool_call so observers always
see the original (untransformed) result. This keeps post_tool_call's
observational contract.
- transform_terminal_output (from #12929) still runs earlier, inside
terminal_tool, so plugins can canonicalize BEFORE the 50k truncation
drops middle content. Both hooks coexist; they target different layers.
2026-04-20 03:48:08 -07:00
|
|
|
call(
|
|
|
|
|
"transform_tool_result",
|
|
|
|
|
tool_name="web_search",
|
|
|
|
|
args={"q": "test"},
|
|
|
|
|
result='{"ok":true}',
|
|
|
|
|
task_id="task-1",
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
tool_call_id="call-1",
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
turn_id="",
|
|
|
|
|
api_request_id="",
|
2026-04-25 22:13:12 -07:00
|
|
|
duration_ms=ANY,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
status="ok",
|
|
|
|
|
error_type=None,
|
|
|
|
|
error_message=None,
|
feat(plugins): add transform_tool_result hook for generic tool-result rewriting (#12972)
Closes #8933 more fully, extending the per-tool transform_terminal_output
hook from #12929 to a generic seam that fires after every tool dispatch.
Plugins can rewrite any tool's result string (normalize formats, redact
fields, summarize verbose output) without wrapping individual tools.
Changes
- hermes_cli/plugins.py: add "transform_tool_result" to VALID_HOOKS
- model_tools.py: invoke the hook in handle_function_call after
post_tool_call (which remains observational); first valid str return
replaces the result; fail-open
- tests/test_transform_tool_result_hook.py: 9 new tests covering no-op,
None return, non-string return, first-match wins, kwargs, hook
exception fallback, post_tool_call observation invariant, ordering
vs post_tool_call, and an end-to-end real-plugin integration
- tests/hermes_cli/test_plugins.py: assert new hook in VALID_HOOKS
- tests/test_model_tools.py: extend the hook-call-sequence assertion
to include the new hook
Design
- transform_tool_result runs AFTER post_tool_call so observers always
see the original (untransformed) result. This keeps post_tool_call's
observational contract.
- transform_terminal_output (from #12929) still runs earlier, inside
terminal_tool, so plugins can canonicalize BEFORE the 50k truncation
drops middle content. Both hooks coexist; they target different layers.
2026-04-20 03:48:08 -07:00
|
|
|
),
|
2026-03-29 12:26:44 +05:30
|
|
|
]
|
|
|
|
|
|
2026-04-25 22:13:12 -07:00
|
|
|
def test_post_tool_call_receives_non_negative_integer_duration_ms(self):
|
|
|
|
|
"""Regression: post_tool_call and transform_tool_result hooks must
|
|
|
|
|
receive a non-negative integer ``duration_ms`` kwarg measuring
|
|
|
|
|
dispatch latency. Inspired by Claude Code 2.1.119, which added
|
|
|
|
|
``duration_ms`` to its PostToolUse hook inputs.
|
|
|
|
|
"""
|
|
|
|
|
with (
|
|
|
|
|
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
|
2026-06-03 06:05:35 -07:00
|
|
|
patch("hermes_cli.plugins.has_hook", return_value=True),
|
2026-04-25 22:13:12 -07:00
|
|
|
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
|
|
|
|
|
):
|
|
|
|
|
handle_function_call("web_search", {"q": "test"}, task_id="t1")
|
|
|
|
|
|
|
|
|
|
kwargs_by_hook = {
|
|
|
|
|
c.args[0]: c.kwargs for c in mock_invoke_hook.call_args_list
|
|
|
|
|
}
|
|
|
|
|
assert "duration_ms" in kwargs_by_hook["post_tool_call"]
|
|
|
|
|
assert "duration_ms" in kwargs_by_hook["transform_tool_result"]
|
|
|
|
|
|
|
|
|
|
post_duration = kwargs_by_hook["post_tool_call"]["duration_ms"]
|
|
|
|
|
transform_duration = kwargs_by_hook["transform_tool_result"]["duration_ms"]
|
|
|
|
|
assert isinstance(post_duration, int)
|
|
|
|
|
assert post_duration >= 0
|
|
|
|
|
# Both hooks should observe the same measured duration.
|
|
|
|
|
assert post_duration == transform_duration
|
|
|
|
|
# pre_tool_call does NOT get duration_ms (nothing has run yet).
|
|
|
|
|
assert "duration_ms" not in kwargs_by_hook["pre_tool_call"]
|
|
|
|
|
|
2026-06-03 06:05:35 -07:00
|
|
|
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
|
|
|
|
|
|
2026-06-03 11:22:06 -07:00
|
|
|
def test_tool_request_and_execution_middleware_wrap_registry_dispatch(self, monkeypatch):
|
|
|
|
|
seen = {}
|
|
|
|
|
|
|
|
|
|
def fake_invoke_middleware(kind, **kwargs):
|
|
|
|
|
if kind == "tool_request":
|
|
|
|
|
return [{
|
|
|
|
|
"args": {**kwargs["args"], "rewritten": True},
|
|
|
|
|
"source": "test-middleware",
|
|
|
|
|
"reason": "rewrite",
|
|
|
|
|
}]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def execution_middleware(**kwargs):
|
|
|
|
|
seen["execution_args"] = kwargs["args"]
|
|
|
|
|
return kwargs["next_call"]({**kwargs["args"], "wrapped": True})
|
|
|
|
|
|
|
|
|
|
def fake_dispatch(tool_name, args, **kwargs):
|
|
|
|
|
seen["dispatch"] = (tool_name, args, kwargs)
|
|
|
|
|
return json.dumps({"ok": True, "args": args})
|
|
|
|
|
|
|
|
|
|
manager = type(
|
|
|
|
|
"Manager",
|
|
|
|
|
(),
|
|
|
|
|
{"_middleware": {"tool_request": [fake_invoke_middleware], "tool_execution": [execution_middleware]}},
|
|
|
|
|
)()
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_middleware", fake_invoke_middleware)
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager)
|
|
|
|
|
hook_calls = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.plugins.invoke_hook",
|
|
|
|
|
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
|
|
|
|
|
monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch)
|
|
|
|
|
|
|
|
|
|
result = json.loads(
|
|
|
|
|
handle_function_call(
|
|
|
|
|
"web_search",
|
|
|
|
|
{"q": "test"},
|
|
|
|
|
task_id="task-1",
|
|
|
|
|
tool_call_id="tool-1",
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert seen["execution_args"] == {"q": "test", "rewritten": True}
|
|
|
|
|
assert seen["dispatch"][1] == {"q": "test", "rewritten": True, "wrapped": True}
|
|
|
|
|
assert result["args"] == {"q": "test", "rewritten": True, "wrapped": True}
|
|
|
|
|
expected_trace = [{"source": "test-middleware", "reason": "rewrite"}]
|
|
|
|
|
pre_call = next(call for call in hook_calls if call[0] == "pre_tool_call")
|
|
|
|
|
post_call = next(call for call in hook_calls if call[0] == "post_tool_call")
|
|
|
|
|
assert pre_call[1]["middleware_trace"] == expected_trace
|
|
|
|
|
assert post_call[1]["middleware_trace"] == expected_trace
|
|
|
|
|
|
2026-02-26 13:54:20 +03:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Agent loop tools
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestAgentLoopTools:
|
|
|
|
|
def test_expected_tools_in_set(self):
|
|
|
|
|
assert "todo" in _AGENT_LOOP_TOOLS
|
|
|
|
|
assert "memory" in _AGENT_LOOP_TOOLS
|
|
|
|
|
assert "session_search" in _AGENT_LOOP_TOOLS
|
|
|
|
|
assert "delegate_task" in _AGENT_LOOP_TOOLS
|
|
|
|
|
|
|
|
|
|
def test_no_regular_tools_in_set(self):
|
|
|
|
|
assert "web_search" not in _AGENT_LOOP_TOOLS
|
|
|
|
|
assert "terminal" not in _AGENT_LOOP_TOOLS
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 21:15:25 -07:00
|
|
|
# =========================================================================
|
|
|
|
|
# Pre-tool-call blocking via plugin hooks
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestPreToolCallBlocking:
|
|
|
|
|
"""Verify that pre_tool_call hooks can block tool execution."""
|
|
|
|
|
|
|
|
|
|
def test_blocked_tool_returns_error_and_skips_dispatch(self, monkeypatch):
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
hook_calls = []
|
|
|
|
|
|
2026-04-13 21:15:25 -07:00
|
|
|
def fake_invoke_hook(hook_name, **kwargs):
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
hook_calls.append((hook_name, kwargs))
|
2026-04-13 21:15:25 -07:00
|
|
|
if hook_name == "pre_tool_call":
|
|
|
|
|
return [{"action": "block", "message": "Blocked by policy"}]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
dispatch_called = False
|
|
|
|
|
_orig_dispatch = None
|
|
|
|
|
|
|
|
|
|
def fake_dispatch(*args, **kwargs):
|
|
|
|
|
nonlocal dispatch_called
|
|
|
|
|
dispatch_called = True
|
|
|
|
|
raise AssertionError("dispatch should not run when blocked")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
2026-06-03 06:05:35 -07:00
|
|
|
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
|
2026-04-13 21:15:25 -07:00
|
|
|
monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch)
|
|
|
|
|
|
|
|
|
|
result = json.loads(handle_function_call("read_file", {"path": "test.txt"}, task_id="t1"))
|
|
|
|
|
assert result == {"error": "Blocked by policy"}
|
|
|
|
|
assert not dispatch_called
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
post_call = next(call for call in hook_calls if call[0] == "post_tool_call")
|
|
|
|
|
assert post_call[1]["status"] == "blocked"
|
|
|
|
|
assert post_call[1]["error_type"] == "plugin_block"
|
|
|
|
|
assert post_call[1]["error_message"] == "Blocked by policy"
|
|
|
|
|
assert post_call[1]["duration_ms"] == 0
|
2026-04-13 21:15:25 -07:00
|
|
|
|
|
|
|
|
def test_blocked_tool_skips_read_loop_notification(self, monkeypatch):
|
|
|
|
|
notifications = []
|
|
|
|
|
|
|
|
|
|
def fake_invoke_hook(hook_name, **kwargs):
|
|
|
|
|
if hook_name == "pre_tool_call":
|
|
|
|
|
return [{"action": "block", "message": "Blocked"}]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
|
|
|
|
monkeypatch.setattr("model_tools.registry.dispatch",
|
|
|
|
|
lambda *a, **kw: (_ for _ in ()).throw(AssertionError("should not run")))
|
|
|
|
|
monkeypatch.setattr("tools.file_tools.notify_other_tool_call",
|
|
|
|
|
lambda task_id: notifications.append(task_id))
|
|
|
|
|
|
|
|
|
|
result = json.loads(handle_function_call("web_search", {"q": "test"}, task_id="t1"))
|
|
|
|
|
assert result == {"error": "Blocked"}
|
|
|
|
|
assert notifications == []
|
|
|
|
|
|
|
|
|
|
def test_invalid_hook_returns_do_not_block(self, monkeypatch):
|
|
|
|
|
"""Malformed hook returns should be ignored — tool executes normally."""
|
|
|
|
|
def fake_invoke_hook(hook_name, **kwargs):
|
|
|
|
|
if hook_name == "pre_tool_call":
|
|
|
|
|
return [
|
|
|
|
|
"block",
|
|
|
|
|
{"action": "block"}, # missing message
|
|
|
|
|
{"action": "deny", "message": "nope"},
|
|
|
|
|
]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
|
|
|
|
monkeypatch.setattr("model_tools.registry.dispatch",
|
|
|
|
|
lambda *a, **kw: json.dumps({"ok": True}))
|
|
|
|
|
|
|
|
|
|
result = json.loads(handle_function_call("read_file", {"path": "test.txt"}, task_id="t1"))
|
|
|
|
|
assert result == {"ok": True}
|
|
|
|
|
|
2026-04-29 12:43:39 -07:00
|
|
|
def test_skip_flag_prevents_double_fire(self, monkeypatch):
|
|
|
|
|
"""When skip_pre_tool_call_hook=True, the hook does not fire again.
|
|
|
|
|
|
|
|
|
|
The caller (e.g. run_agent._invoke_tool) has already called
|
|
|
|
|
get_pre_tool_call_block_message(), which fires the hook once.
|
|
|
|
|
handle_function_call must NOT fire it a second time — that was
|
|
|
|
|
the classic double-fire bug where observer hooks logged every
|
|
|
|
|
tool call twice.
|
|
|
|
|
"""
|
2026-04-13 21:15:25 -07:00
|
|
|
hook_calls = []
|
|
|
|
|
|
|
|
|
|
def fake_invoke_hook(hook_name, **kwargs):
|
|
|
|
|
hook_calls.append(hook_name)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
2026-06-03 06:05:35 -07:00
|
|
|
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
|
2026-04-13 21:15:25 -07:00
|
|
|
monkeypatch.setattr("model_tools.registry.dispatch",
|
|
|
|
|
lambda *a, **kw: json.dumps({"ok": True}))
|
|
|
|
|
|
|
|
|
|
handle_function_call("web_search", {"q": "test"}, task_id="t1",
|
|
|
|
|
skip_pre_tool_call_hook=True)
|
|
|
|
|
|
2026-04-29 12:43:39 -07:00
|
|
|
# Single-fire contract: when skip=True the caller already fired
|
|
|
|
|
# pre_tool_call, so handle_function_call must not fire it again.
|
|
|
|
|
assert hook_calls.count("pre_tool_call") == 0, (
|
|
|
|
|
f"pre_tool_call fired {hook_calls.count('pre_tool_call')} times "
|
|
|
|
|
f"with skip_pre_tool_call_hook=True; expected 0 "
|
|
|
|
|
f"(caller already fired it). hook_calls={hook_calls}"
|
|
|
|
|
)
|
|
|
|
|
# post_tool_call and transform_tool_result still fire — only the
|
|
|
|
|
# pre-call block-check path is suppressed by the skip flag.
|
2026-04-13 21:15:25 -07:00
|
|
|
assert "post_tool_call" in hook_calls
|
2026-04-29 12:43:39 -07:00
|
|
|
assert "transform_tool_result" in hook_calls
|
|
|
|
|
|
|
|
|
|
def test_run_agent_pattern_fires_pre_tool_call_exactly_once(self, monkeypatch):
|
|
|
|
|
"""End-to-end regression for the double-fire bug.
|
|
|
|
|
|
|
|
|
|
Mirrors run_agent._invoke_tool: first calls
|
|
|
|
|
get_pre_tool_call_block_message() (which fires the hook as part of
|
|
|
|
|
its block-directive poll), then calls
|
|
|
|
|
handle_function_call(skip_pre_tool_call_hook=True). The plugin
|
|
|
|
|
hook MUST fire exactly once across both calls — not twice as it
|
|
|
|
|
did before the fix (observer plugins were seeing every tool
|
|
|
|
|
execution logged twice).
|
|
|
|
|
"""
|
|
|
|
|
from hermes_cli.plugins import get_pre_tool_call_block_message
|
|
|
|
|
|
|
|
|
|
hook_calls = []
|
|
|
|
|
|
|
|
|
|
def fake_invoke_hook(hook_name, **kwargs):
|
|
|
|
|
hook_calls.append(hook_name)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
|
|
|
|
monkeypatch.setattr("model_tools.registry.dispatch",
|
|
|
|
|
lambda *a, **kw: json.dumps({"ok": True}))
|
|
|
|
|
|
|
|
|
|
# Step 1: caller checks for a block directive (this fires pre_tool_call once).
|
|
|
|
|
block = get_pre_tool_call_block_message(
|
|
|
|
|
"web_search", {"q": "test"}, task_id="t1",
|
|
|
|
|
)
|
|
|
|
|
assert block is None
|
|
|
|
|
|
|
|
|
|
# Step 2: caller dispatches with skip=True so the hook isn't re-fired.
|
|
|
|
|
handle_function_call(
|
|
|
|
|
"web_search", {"q": "test"}, task_id="t1",
|
|
|
|
|
skip_pre_tool_call_hook=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert hook_calls.count("pre_tool_call") == 1, (
|
|
|
|
|
f"pre_tool_call fired {hook_calls.count('pre_tool_call')} times "
|
|
|
|
|
f"across the run_agent (block-check + dispatch) path; "
|
|
|
|
|
f"expected exactly 1. hook_calls={hook_calls}"
|
|
|
|
|
)
|
2026-04-13 21:15:25 -07:00
|
|
|
|
|
|
|
|
|
2026-02-26 13:54:20 +03:00
|
|
|
# =========================================================================
|
|
|
|
|
# Legacy toolset map
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestLegacyToolsetMap:
|
|
|
|
|
def test_expected_legacy_names(self):
|
|
|
|
|
expected = [
|
feat(moa): expose MoA presets as selectable virtual models (#46081)
* feat(moa): expose MoA presets as selectable virtual models
Reconstructed onto current main (PR #46081's base had diverged with no common
ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual
provider: each named preset is a selectable model under provider 'moa', and the
preset's aggregator is the acting model that answers and calls tools.
Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same
batch pattern delegate_task uses) — all references dispatched at once, collected
when every one finishes, then handed to the aggregator. Output order is
preserved, failures and the MoA-recursion guard stay isolated per reference.
- Removed the old mixture_of_agents model tool and moa toolset.
- Added moa as a virtual provider in the provider/model inventory.
- /moa is shortcut behavior over model selection (default preset / named preset
/ one-shot prompt).
- Dashboard + Desktop manage named presets; presets appear in model pickers.
- Parallel reference fan-out in agent/moa_loop.py with regression test.
* fix(moa): thread moa_config through _run_agent to _run_agent_inner
The reconstructed gateway MoA wiring declared moa_config on _run_agent (the
profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper
never forwarded it — _run_agent_inner had no such parameter, so the runtime hit
NameError: name 'moa_config' is not defined on the compression-failure session
sync path. Add moa_config to _run_agent_inner's signature and forward it from
both wrapper call sites (multiplex and non-multiplex). Caught by
tests/gateway/test_compression_failure_session_sync.py on CI shard test(4).
* fix(moa): classify moa as a virtual provider in the catalog
The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so
provider_catalog() fell through to the default auth_type="api_key" with no
env vars — tripping two catalog invariants:
- test_provider_catalog: api_key providers must expose a credential env var
- test_provider_parity: every hermes-model provider must be desktop-configurable
moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that
overlay as an auth_type fallback so the catalog reports moa as virtual (no real
credential, no network endpoint). Exempt virtual providers from the desktop
parity union check the same way 'custom' is exempt — derived from the catalog,
not a hardcoded slug, so future virtual providers are covered too.
2026-06-25 13:52:06 -07:00
|
|
|
"web_tools", "terminal_tools", "vision_tools",
|
2026-02-26 13:54:20 +03:00
|
|
|
"image_tools", "skills_tools", "browser_tools", "cronjob_tools",
|
chore: remove Atropos RL environments and tinker-atropos integration (#26106)
* chore: remove Atropos RL environments, tools, tests, skill, and tinker-atropos submodule
Delete:
- environments/ (43 files — base env, agent loop, tool call parsers, benchmarks)
- rl_cli.py (standalone RL training CLI)
- tools/rl_training_tool.py (all 10 rl_* tools)
- tests: test_rl_training_tool, test_tool_call_parsers, test_managed_server_tool_support,
test_agent_loop, test_agent_loop_vllm, test_agent_loop_tool_calling,
test_terminalbench2_env_security
- optional-skills/mlops/hermes-atropos-environments/
- tinker-atropos git submodule + .gitmodules
* chore: remove RL/Atropos references from Python source
- toolsets.py: remove rl toolset block + update comment
- model_tools.py: remove rl_tools group + update async bridging comment
- hermes_cli/tools_config.py: remove RL display entry, _DEFAULT_OFF_TOOLSETS,
setup block, and rl_training post-setup handler
- tools/budget_config.py: remove RL environment reference in docstring
- tests/test_model_tools.py: remove rl_tools from expected groups
- tests/run_agent/test_streaming_tool_call_repair.py: fix stale cross-reference
* chore: remove rl/yc-bench extras and tinker-atropos refs from pyproject.toml
- Remove rl extra (atroposlib, tinker, fastapi, uvicorn, wandb)
- Remove yc-bench extra
- Remove rl_cli from py-modules
- Remove [tool.ty.src] exclude for tinker-atropos
- Remove [tool.ruff] exclude for tinker-atropos
- Regenerate uv.lock
* chore: remove tinker-atropos from install/setup scripts
- setup-hermes.sh: remove entire tinker-atropos submodule install block
- scripts/install.sh: remove both tinker-atropos blocks (Termux + standard)
- scripts/install.ps1: remove tinker-atropos block
- nix/hermes-agent.nix: remove tinker-atropos pip install line
* chore: remove RL references from cli-config.yaml.example
* docs: remove Atropos/RL references from README, CONTRIBUTING, AGENTS.md
* docs: remove RL/Atropos references from website
- Delete: environments.md, rl-training.md, mlops-hermes-atropos-environments.md
- sidebars.ts: remove rl-training and environments sidebar entries
- optional-skills-catalog.md: remove hermes-atropos-environments row
- tools-reference.md: remove entire rl toolset section
- toolsets-reference.md: remove rl row + update example
- integrations/index.md: remove RL Training bullet
- architecture.md: remove environments/ from tree + RL section
- contributing.md: remove tinker-atropos setup
- updating.md: remove tinker-atropos install + stale submodule update
* chore: remove remaining RL/Atropos stragglers
- hermes_cli/config.py: remove TINKER_API_KEY + WANDB_API_KEY env var defs
- hermes_cli/doctor.py: remove Submodules check section (tinker-atropos)
- hermes_cli/setup.py: remove RL Training status check
- hermes_cli/status.py: remove Tinker + WandB from API key status display
- agent/display.py: remove both rl_* tool preview/activity blocks
- website/docs: remove RL references from providers.md + env-variables.md
- tests: remove TINKER_API_KEY from conftest, set_config_value, setup_script
* chore: remove RL training section from .env.example
2026-05-15 10:36:38 +05:30
|
|
|
"file_tools", "tts_tools",
|
2026-02-26 13:54:20 +03:00
|
|
|
]
|
|
|
|
|
for name in expected:
|
|
|
|
|
assert name in _LEGACY_TOOLSET_MAP, f"Missing legacy toolset: {name}"
|
|
|
|
|
|
|
|
|
|
def test_values_are_lists_of_strings(self):
|
|
|
|
|
for name, tools in _LEGACY_TOOLSET_MAP.items():
|
|
|
|
|
assert isinstance(tools, list), f"{name} is not a list"
|
|
|
|
|
for tool in tools:
|
|
|
|
|
assert isinstance(tool, str), f"{name} contains non-string: {tool}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Backward-compat wrappers
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestBackwardCompat:
|
|
|
|
|
def test_get_all_tool_names_returns_list(self):
|
|
|
|
|
names = get_all_tool_names()
|
|
|
|
|
assert isinstance(names, list)
|
|
|
|
|
assert len(names) > 0
|
|
|
|
|
# Should contain well-known tools
|
2026-03-05 18:46:30 -08:00
|
|
|
assert "web_search" in names
|
|
|
|
|
assert "terminal" in names
|
2026-02-26 13:54:20 +03:00
|
|
|
|
|
|
|
|
def test_get_toolset_for_tool(self):
|
|
|
|
|
result = get_toolset_for_tool("web_search")
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
|
|
|
|
|
def test_get_toolset_for_unknown_tool(self):
|
|
|
|
|
result = get_toolset_for_tool("totally_nonexistent_tool")
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
def test_tool_to_toolset_map(self):
|
|
|
|
|
assert isinstance(TOOL_TO_TOOLSET_MAP, dict)
|
|
|
|
|
assert len(TOOL_TO_TOOLSET_MAP) > 0
|
2026-04-24 14:26:30 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# _coerce_number — inf / nan must fall through to the original string
|
|
|
|
|
# (regression: fix: eliminate duplicate checkpoint entries and JSON-unsafe coercion)
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestCoerceNumberInfNan:
|
|
|
|
|
"""_coerce_number must honor its documented contract ("Returns original
|
|
|
|
|
string on failure") for inf/nan inputs, because float('inf') and
|
|
|
|
|
float('nan') are not JSON-compliant under strict serialization."""
|
|
|
|
|
|
|
|
|
|
def test_inf_returns_original_string(self):
|
|
|
|
|
from model_tools import _coerce_number
|
|
|
|
|
assert _coerce_number("inf") == "inf"
|
|
|
|
|
|
|
|
|
|
def test_negative_inf_returns_original_string(self):
|
|
|
|
|
from model_tools import _coerce_number
|
|
|
|
|
assert _coerce_number("-inf") == "-inf"
|
|
|
|
|
|
|
|
|
|
def test_nan_returns_original_string(self):
|
|
|
|
|
from model_tools import _coerce_number
|
|
|
|
|
assert _coerce_number("nan") == "nan"
|
|
|
|
|
|
|
|
|
|
def test_infinity_spelling_returns_original_string(self):
|
|
|
|
|
from model_tools import _coerce_number
|
|
|
|
|
# Python's float() parses "Infinity" too — still not JSON-safe.
|
|
|
|
|
assert _coerce_number("Infinity") == "Infinity"
|
|
|
|
|
|
|
|
|
|
def test_coerced_result_is_strict_json_safe(self):
|
|
|
|
|
"""Whatever _coerce_number returns for inf/nan must round-trip
|
|
|
|
|
through strict (allow_nan=False) json.dumps without raising."""
|
|
|
|
|
from model_tools import _coerce_number
|
|
|
|
|
for s in ("inf", "-inf", "nan", "Infinity"):
|
|
|
|
|
result = _coerce_number(s)
|
|
|
|
|
json.dumps({"x": result}, allow_nan=False) # must not raise
|
|
|
|
|
|
|
|
|
|
def test_normal_numbers_still_coerce(self):
|
|
|
|
|
"""Guard against over-correction — real numbers still coerce."""
|
|
|
|
|
from model_tools import _coerce_number
|
|
|
|
|
assert _coerce_number("42") == 42
|
|
|
|
|
assert _coerce_number("3.14") == 3.14
|
|
|
|
|
assert _coerce_number("1e3") == 1000
|
fix(tools): preserve core tools when a platform bundle is disabled
When a platform-bundle name (e.g. `hermes-yuanbao`, or any `hermes-*`) lands
in `agent.disabled_toolsets`, the shared tool-assembly path
(`model_tools._compute_tool_definitions`, used by the gateway, cron, AND the
CLI) subtracted the WHOLE bundle from the enabled set. Because every platform
bundle is defined as `_HERMES_CORE_TOOLS + [platform extras]`, and core tools
are shared by every other enabled toolset, the subtraction emptied the tool
list entirely — the model received `tools: []` / `tool_choice: null` and
started replying "I cannot execute shell commands" with no error, no warning,
and `hermes tools list` / `hermes doctor` still green. For unattended cron
jobs this fails silently for days. (#33924)
(The original report framed this as gateway-only; it actually affects every
caller of `_compute_tool_definitions`, including the CLI — the reporter's
follow-up confirms this. Fixing the shared chokepoint covers all paths.)
Fix: for a `hermes-*` bundle in `disabled_toolsets`, subtract only its
*non-core delta* (its platform-specific tools plus those of any `includes`),
leaving `_HERMES_CORE_TOOLS` intact. Disabling a bundle now removes its
platform tools (e.g. the `yb_*` tools for `hermes-yuanbao`) while terminal,
read_file, web, etc. survive. A `logger.warning` notes that core tools are
preserved and that bundle names usually belong in `toolsets:`, not
`disabled_toolsets` — informative, not destructive (the subtraction still
behaves sensibly).
Salvaged from #33941 by @liuhao1024 (authorship preserved). Extracted the
inline bundle-resolution into a module-level `_bundle_non_core_tools` helper
(was re-importing `toolsets` inside the disable loop), and added the
informative warning folding in the UX intent of #34073 (@ousiaresearch)
without its hard "ignore the bundle name" behavior — which would have undone
this fix's sensible-subtraction.
Verified empirically: disabling `hermes-yuanbao` from a gateway-style enabled
set keeps all core tools (18→18) and would remove only the 5 `yb_*` tools;
disabling `hermes-discord` removes only `discord`/`discord_admin`.
Fixes #33924
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-21 16:22:55 +05:30
|
|
|
|
|
|
|
|
class TestDisabledToolsetsPlatformBundle:
|
|
|
|
|
"""Regression test for #33924: disabling a platform bundle (hermes-*)
|
|
|
|
|
must not remove core tools from other enabled toolsets."""
|
|
|
|
|
|
|
|
|
|
def test_disabling_platform_bundle_preserves_core_tools(self):
|
|
|
|
|
"""Disabling hermes-yuanbao should not strip core tools from hermes-telegram."""
|
|
|
|
|
from model_tools import get_tool_definitions
|
|
|
|
|
|
|
|
|
|
tools_telegram = get_tool_definitions(
|
|
|
|
|
enabled_toolsets=["hermes-telegram"],
|
|
|
|
|
quiet_mode=True,
|
|
|
|
|
)
|
|
|
|
|
tools_telegram_no_yuanbao = get_tool_definitions(
|
|
|
|
|
enabled_toolsets=["hermes-telegram"],
|
|
|
|
|
disabled_toolsets=["hermes-yuanbao"],
|
|
|
|
|
quiet_mode=True,
|
|
|
|
|
)
|
|
|
|
|
names_telegram = {t["function"]["name"] for t in tools_telegram}
|
|
|
|
|
names_no_yuanbao = {t["function"]["name"] for t in tools_telegram_no_yuanbao}
|
|
|
|
|
|
|
|
|
|
# Disabling a *different* platform bundle must not remove any tools
|
|
|
|
|
assert names_telegram == names_no_yuanbao, (
|
|
|
|
|
f"Tools lost after disabling hermes-yuanbao: "
|
|
|
|
|
f"{names_telegram - names_no_yuanbao}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_disabling_platform_bundle_removes_own_tools(self):
|
|
|
|
|
"""Disabling hermes-discord should remove discord-specific tools."""
|
|
|
|
|
from model_tools import get_tool_definitions
|
|
|
|
|
|
|
|
|
|
tools = get_tool_definitions(
|
|
|
|
|
enabled_toolsets=["hermes-discord"],
|
|
|
|
|
disabled_toolsets=["hermes-discord"],
|
|
|
|
|
quiet_mode=True,
|
|
|
|
|
)
|
|
|
|
|
names = {t["function"]["name"] for t in tools}
|
|
|
|
|
assert "discord" not in names
|
|
|
|
|
|
|
|
|
|
def test_disabling_non_platform_toolset_still_works(self):
|
|
|
|
|
"""Disabling a regular (non-hermes-) toolset still subtracts all tools."""
|
|
|
|
|
from model_tools import get_tool_definitions
|
|
|
|
|
|
|
|
|
|
tools_normal = get_tool_definitions(
|
|
|
|
|
enabled_toolsets=["hermes-telegram"],
|
|
|
|
|
quiet_mode=True,
|
|
|
|
|
)
|
|
|
|
|
tools_no_web = get_tool_definitions(
|
|
|
|
|
enabled_toolsets=["hermes-telegram"],
|
|
|
|
|
disabled_toolsets=["web"],
|
|
|
|
|
quiet_mode=True,
|
|
|
|
|
)
|
|
|
|
|
names_normal = {t["function"]["name"] for t in tools_normal}
|
|
|
|
|
names_no_web = {t["function"]["name"] for t in tools_no_web}
|
|
|
|
|
|
|
|
|
|
web_tools = {"web_search", "web_extract"}
|
|
|
|
|
removed = names_normal - names_no_web
|
|
|
|
|
# web tools should be removed (if they were present)
|
|
|
|
|
present_web = web_tools & names_normal
|
|
|
|
|
assert present_web <= removed, (
|
|
|
|
|
f"Web tools not removed: {present_web - removed}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disabling_bundle_removes_platform_tools_but_keeps_core(self):
|
|
|
|
|
"""Disabling hermes-discord (when enabled) removes discord/discord_admin
|
|
|
|
|
from the resolved delta but keeps core tools — via bundle_non_core_tools."""
|
|
|
|
|
from toolsets import bundle_non_core_tools, _HERMES_CORE_TOOLS
|
|
|
|
|
|
|
|
|
|
delta = bundle_non_core_tools("hermes-yuanbao")
|
|
|
|
|
# The delta is the bundle's platform-specific tools, NOT core.
|
|
|
|
|
assert "yb_send_dm" in delta
|
|
|
|
|
assert not (delta & set(_HERMES_CORE_TOOLS)), "core tools must not be in the removal delta"
|
|
|
|
|
|
|
|
|
|
def test_bundle_non_core_tools_unknown_falls_back(self):
|
|
|
|
|
"""An unknown/garbage bundle name falls back to full resolution (best effort)."""
|
|
|
|
|
from toolsets import bundle_non_core_tools
|
|
|
|
|
# A non-existent bundle resolves to an empty set (no tools), not a crash.
|
|
|
|
|
assert bundle_non_core_tools("hermes-does-not-exist") == set()
|