d105376b21
* refactor(tools): discovery also scans tools/<pkg>/tool.py
A tool family that is a whole package had no way to register: discovery
globbed tools/*.py only and derived the module name from the filename.
Now the candidate list is tools/*.py plus tools/*/tool.py, merged and
sorted once so import order does not depend on depth (register() lets a
same-name duplicate overwrite silently), and the module name comes from
the path relative to tools/. Only tool.py is scanned inside a package,
so its siblings are libraries by construction. A package without an
__init__.py is skipped with a warning rather than registering from a
checkout and vanishing from the installed wheel.
The AST prefilter and the (mtime, size) disk cache are per absolute path
and work unchanged. The two hand-rolled tools/*.py enumerators in tests
now use the same candidate helper.
* refactor(connectors): one package for the connector domain, tools/connectors/
The connector code was spread across six flat files and a root-level
module that was a sibling of model_tools.py only by address:
tools/connections_tool.py -> tools/connectors/tool.py (schema, register, dispatcher)
tools/connectors/managed.py (the managed leg, split out)
tools/connections_tool_mcp.py -> tools/connectors/mcp.py (validation split out ->)
tools/connectors/targets.py (normalize_targets, validate_action)
tools/connections_tool_operation.py -> tools/connectors/operation.py
tools/connector_search.py -> tools/connectors/search.py
model_tools_connectors.py -> tools/connectors/dispatch.py
tools/tool_gateway/ -> tools/connectors/gateway/
Move only; every function body is unchanged. tools/connectors/__init__.py
is the door: nine names, the whole cross-package surface. model_tools and
tool_search deep-import a few helpers past it on purpose and the docstring
says so. The two split files make the import graph one-directional
(tool -> mcp -> targets, tool -> managed) where the old layout had
connections_tool importing validation out of the MCP file.
One behaviour-neutral seam change: the _connectors_available try/except
wrapper is gone. connectors_available() already fails closed, and both
the registry handler and the inline executor now read it as a module
attribute (gateway.config.connectors_available), so tests patch it in
one place instead of two. _default_client lives in managed.py, the only
module that calls it.
tools/managed_tool_gateway.py and tools/managed_gateway_auth.py stay:
they are gateway identity shared by tts, transcription, image and modal.
Test files follow their modules. No docs referenced the old paths; no
compat pointer is added (in-tree moves get none).
* ci: retrigger (zero-job dispatch on 142466de6b)
124 lines
5.8 KiB
Python
124 lines
5.8 KiB
Python
"""Real connector dispatch must run policies against each composed name."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.parametrize("blocked_by", ["hook", "execution"])
|
|
def test_remote_entries_run_request_hook_and_execution_policies(monkeypatch, blocked_by):
|
|
import model_tools
|
|
import hermes_cli.plugins as plugins
|
|
from tools.registry import invalidate_check_fn_cache
|
|
from tools.connectors.gateway import bridge, config
|
|
|
|
monkeypatch.setattr(config, "connectors_available", lambda: True)
|
|
monkeypatch.setattr(bridge, "connectors_available", lambda: True)
|
|
invalidate_check_fn_cache()
|
|
denied = "connectors__gmail__SEND_EMAIL"
|
|
rewritten = "connectors__slack__POST_MESSAGE"
|
|
calls = [{"name": name, "arguments": {"body": "original"}} for name in (denied, rewritten)]
|
|
events = []
|
|
wire = []
|
|
|
|
def request(**kw):
|
|
events.append(("request", kw["tool_name"]))
|
|
assert kw["args"] == {"body": "original"}
|
|
assert kw["session_id"] == "policy-session"
|
|
return {"args": {"body": "request-rewrite"}, "source": "test-policy"}
|
|
|
|
def hook(name, args, **kw):
|
|
events.append(("hook", name))
|
|
assert args == {"body": "request-rewrite"}
|
|
assert kw["middleware_trace"] == [{"source": "test-policy"}]
|
|
assert kw["tool_call_id"] == "policy-call"
|
|
if name == denied and blocked_by == "hook":
|
|
return "hook denied", None
|
|
return None, {"body": "hook-rewrite"}
|
|
|
|
def execution(**kw):
|
|
events.append(("execution", kw["tool_name"]))
|
|
assert kw["args"] == {"body": "hook-rewrite"}
|
|
assert kw["original_args"] == {"body": "original"}
|
|
assert kw["session_id"] == "policy-session"
|
|
if kw["tool_name"] == denied:
|
|
return json.dumps({"error": {"code": "POLICY_DENIED", "message": "execution denied", "policy": "no-mail"}})
|
|
return kw["next_call"]({}) # Empty dict must reach the wire, not original arguments.
|
|
|
|
monkeypatch.setattr(plugins.get_plugin_manager(), "_middleware", {
|
|
"tool_request": [request], "tool_execution": [execution]})
|
|
monkeypatch.setattr(plugins, "_dispatch_pre_tool_call_hooks", hook)
|
|
|
|
class Client:
|
|
def execute(self, planned):
|
|
wire.extend(planned)
|
|
return [{"data": "remote-ok", "error": None} for _ in planned]
|
|
|
|
monkeypatch.setattr(bridge, "_default_client_factory", Client)
|
|
kwargs = dict(enabled_toolsets=["connections"], session_id="policy-session", tool_call_id="policy-call",
|
|
skip_pre_tool_call_hook=True, skip_tool_request_middleware=True,
|
|
skip_tool_execution_middleware=True)
|
|
result = json.loads(model_tools.handle_function_call("tool_call", {"calls": calls}, **kwargs))
|
|
assert "denied" in json.dumps(result["results"][0]["error"])
|
|
if blocked_by == "execution":
|
|
assert result["results"][0]["error"] == {
|
|
"code": "POLICY_DENIED", "message": "execution denied", "policy": "no-mail"}
|
|
assert result["results"][1]["response"] == "remote-ok"
|
|
assert [(p.name, p.arguments) for p in wire] == [(rewritten, {})]
|
|
expected_denied = [("request", denied), ("hook", denied)]
|
|
if blocked_by == "execution":
|
|
expected_denied.append(("execution", denied))
|
|
assert events == expected_denied + [(phase, rewritten) for phase in ("request", "hook", "execution")]
|
|
assert result["total_count"] == 2 and result["success_count"] == result["error_count"] == 1
|
|
|
|
wire.clear()
|
|
result = json.loads(model_tools.handle_function_call("tool_call", {"calls": calls[:1]}, **kwargs))
|
|
assert result["error_count"] == 1
|
|
assert not wire # An entirely blocked batch never constructs/sends an execute request.
|
|
|
|
|
|
def test_stop_during_a_connector_batch_leaves_unstarted_entries_unsent(monkeypatch):
|
|
import model_tools
|
|
from tools.interrupt import set_interrupt
|
|
from tools.registry import invalidate_check_fn_cache
|
|
from tools.connectors.gateway import bridge, config
|
|
|
|
monkeypatch.setattr(config, "connectors_available", lambda: True)
|
|
monkeypatch.setattr(bridge, "connectors_available", lambda: True)
|
|
invalidate_check_fn_cache()
|
|
wire = []
|
|
|
|
class Client:
|
|
def execute(self, planned):
|
|
wire.extend(planned)
|
|
set_interrupt(True) # /stop lands while the first entry is on the wire.
|
|
return [{"data": "remote-ok", "error": None} for _ in planned]
|
|
|
|
monkeypatch.setattr(bridge, "_default_client_factory", Client)
|
|
calls = [{"name": f"connectors__gmail__{tool}", "arguments": {}}
|
|
for tool in ("FETCH_EMAILS", "SEND_EMAIL", "CREATE_DRAFT")]
|
|
try:
|
|
result = json.loads(model_tools.handle_function_call(
|
|
"tool_call", {"calls": calls}, enabled_toolsets=["connections"], session_id="stop-session",
|
|
skip_pre_tool_call_hook=True, skip_tool_request_middleware=True,
|
|
skip_tool_execution_middleware=True))
|
|
finally:
|
|
set_interrupt(False)
|
|
assert [p.name for p in wire] == [calls[0]["name"]]
|
|
assert result["results"][0]["response"] == "remote-ok"
|
|
assert [(e["index"], e["name"], e["error"]["code"]) for e in result["results"][1:]] == [
|
|
(1, calls[1]["name"], "INTERRUPTED"), (2, calls[2]["name"], "INTERRUPTED")]
|
|
assert result["total_count"] == 3 and result["success_count"] == 1 and result["error_count"] == 2
|
|
|
|
|
|
def test_disabled_connections_cannot_be_called_through_a_stale_schema(monkeypatch):
|
|
from tools.connectors import managed
|
|
from tools.connectors.gateway import config
|
|
from tools.registry import registry
|
|
|
|
monkeypatch.setattr(config, "connectors_available", lambda: False)
|
|
monkeypatch.setattr(managed, "_default_client",
|
|
lambda: (_ for _ in ()).throw(AssertionError("disabled connector attempted I/O")))
|
|
result = json.loads(registry.dispatch("manage_connections", {"action": "connect", "connectors": ["gmail"]}))
|
|
assert "not available" in result["error"]
|