diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index f994ecfab2..2ec46728d2 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -25,10 +25,11 @@ from tools.threat_patterns import scan_for_threats logger = logging.getLogger(__name__) # Interactive / user-facing tools never run concurrently: any of these in a batch is a barrier. -_NEVER_PARALLEL_TOOLS = frozenset({"clarify"}) +_NEVER_PARALLEL_TOOLS = frozenset({"clarify", "manage_connections"}) # Read-only tools with no shared mutable session state. _PARALLEL_SAFE_TOOLS = frozenset({ + "connectors__execute", # pure remote batches have per-dispatch idempotency keys "ha_get_state", "ha_list_entities", "ha_list_services", @@ -86,18 +87,48 @@ _PARALLEL_SAFE_BRIDGE_LOOKUPS = frozenset({"tool_search", "tool_describe"}) def _peel_bridge_call(tool_name: str, function_args: dict) -> tuple[str, dict]: - """Resolve a ``tool_call`` bridge invocation to ``(underlying_name, underlying_args)`` so - admission is decided on the real tool (as the executors' unwrap does). An unparseable - bridge call is returned unchanged: it stays a sequential barrier and fails at dispatch.""" + """Resolve a ``tool_call`` bridge invocation to its underlying tool. + + The batch planner admits calls to a parallel run by tool NAME, but when + tool search is active the model emits the literal name ``tool_call`` for + every deferred tool β€” so a server opted in via + ``supports_parallel_tool_calls: true`` silently lost concurrency the + moment the bridge activated. Peel the wrapper here so admission is + decided on the underlying tool, exactly like the executors' unwrap. + + Returns ``(underlying_name, underlying_args)`` when the wrapper parses + cleanly, else ``(tool_name, function_args)`` unchanged β€” an unparseable + bridge call stays a sequential barrier and fails at dispatch as before. + """ try: - from tools.tool_search import TOOL_CALL_NAME, resolve_underlying_call - if tool_name == TOOL_CALL_NAME: - underlying, underlying_args, err = resolve_underlying_call(function_args) - if err is None and underlying: + from tools.tool_search import ( + CONNECTOR_BATCH_SENTINEL, + TOOL_CALL_NAME, + is_connector_name, + resolve_underlying_call, + ) + if tool_name != TOOL_CALL_NAME: + return tool_name, function_args + underlying, underlying_args, err = resolve_underlying_call(function_args) + if err is not None or not underlying: + return tool_name, function_args + if underlying == CONNECTOR_BATCH_SENTINEL: + # Only a PURE connector batch is parallel-safe (network-bound, + # no local state, own idempotency key). A batch containing any + # local entry keeps the sequential barrier: its entries never + # went through per-tool admission here, so treating the batch + # as parallel-safe would bypass path-overlap serialization for + # local writers and the per-server MCP parallel opt-in. + entries = underlying_args.get("calls") or [] + if entries and all( + isinstance(e, dict) and is_connector_name(e.get("name")) + for e in entries + ): return underlying, underlying_args + return tool_name, function_args + return underlying, underlying_args except Exception: - pass - return tool_name, function_args + return tool_name, function_args def _batch_admission(tool_call, execution_cwd: Optional[Path]) -> tuple[str, List[Path], bool] | None: diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 6b15d2f637..518b714f1b 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -389,6 +389,10 @@ def _unwrap_tool_search_call( underlying, underlying_args, err = _ts.resolve_underlying_call(function_args) if err or not underlying: return function_name, function_args, None + if underlying == _ts.CONNECTOR_BATCH_SENTINEL: + # Both executors retain the wrapper: scope/probe/hooks run per entry + # in the batch dispatcher, not against a synthetic registry name. + return function_name, function_args, None if underlying not in _tool_search_scoped_names(agent): return function_name, function_args, ( f"'{underlying}' is not available in this session. Use tool_search to find tools you can call." diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f5266fb9a1..6f9a67a62a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -895,7 +895,8 @@ def _unset_nested(config, dotted_key: str) -> bool: _ENV_CONFIG_KEYS = frozenset({ 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', - 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', + 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_URL', 'CONNECTOR_GATEWAY_URL', + 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'PERPLEXITY_API_KEY', 'API_SERVER_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index df64c94219..c66290478a 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1812,6 +1812,10 @@ DEFAULT_CONFIG = { # Range 200..60000. "listing_max_tokens": 4000, }, + # Remote connector discovery/lifecycle through the Nous tool gateway. + # The flag is the user's off switch; availability additionally requires + # the portal sign-in every managed tool gates on. + "connectors": {"enabled": True}, }, "logging": { # File logging to ~/.hermes/logs/: agent.log captures INFO+, errors.log WARNING+. "level": "INFO", # minimum level for agent.log: DEBUG, INFO, WARNING @@ -2506,6 +2510,14 @@ OPTIONAL_ENV_VARS = { "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)", "Firecrawl gateway URL (leave empty to derive from domain)", None, password=False, advanced=True), + "TOOL_GATEWAY_URL": _tool( + "Exact shared tool-gateway origin for on-origin vendors and media uploads (optional)", + "Shared tool-gateway URL (leave empty to derive from domain)", None, + password=False, advanced=True), + "CONNECTOR_GATEWAY_URL": _tool( + "Exact connector-gateway origin for the connectors API (optional)", + "Connector-gateway URL (leave empty to derive from domain)", None, + password=False, advanced=True), "TOOL_GATEWAY_DOMAIN": _tool( "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor " "hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com", diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 63c751b592..1bb04268a8 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -67,6 +67,7 @@ CONFIGURABLE_TOOLSETS = [ ("memory", "πŸ’Ύ Memory", "persistent memory across sessions"), ("context_engine", "🧩 Context Engine", "runtime tools from the active context engine"), ("session_search", "πŸ”Ž Session Search", "search past conversations"), + ("connections", "πŸ”Œ Connections", "remote connector tools and account authorization"), ("clarify", "❓ Clarifying Questions", "clarify"), ("delegation", "πŸ‘₯ Task Delegation", "delegate_task"), ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"), diff --git a/model_tools.py b/model_tools.py index 50944003ed..c317fcd95c 100644 --- a/model_tools.py +++ b/model_tools.py @@ -668,6 +668,10 @@ def _dispatch_bridge_tool(function_name: str, function_args: Dict[str, Any], underlying_name, underlying_args, err = ts.resolve_underlying_call(args) if err or not underlying_name: return tool_error(err or "tool_call could not be resolved"), None + if underlying_name == ts.CONNECTOR_BATCH_SENTINEL: + if not ts.connections_in_scope(current_defs): + return tool_error("Connectors are not available in this session."), None + return None, (underlying_name, underlying_args) # Defense in depth: resolve_underlying_call only checks the global # registry; also require membership in the session-scoped catalog. if underlying_name not in ts.scoped_deferrable_names(current_defs): @@ -764,6 +768,10 @@ def _execute_tool(function_name: str, function_args: Dict[str, Any], original_ar dispatch_kwargs["user_task"] = user_task def _dispatch(next_args: Dict[str, Any]) -> Any: + from tools.tool_gateway.names import is_connector_name + if is_connector_name(function_name): + from model_tools_connectors import dispatch_connector_call + return dispatch_connector_call(function_name, next_args, ids.tool_call_id) return registry.dispatch(function_name, next_args, **dispatch_kwargs) with _approval_observability(ids): @@ -836,6 +844,14 @@ def handle_function_call( result, underlying = bridged if underlying is None: return _emit(result, duration_ms=_elapsed_ms(start)) + from tools.tool_gateway.names import CONNECTOR_BATCH_SENTINEL + if underlying[0] == CONNECTOR_BATCH_SENTINEL: + from model_tools_connectors import dispatch_connector_batch + return _emit(dispatch_connector_batch( + underlying[1]["calls"], ids, user_task=user_task, + enabled_tools=enabled_tools, middleware_trace=trace, + enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, + ), duration_ms=_elapsed_ms(start)) return handle_function_call( *underlying, **asdict(ids), user_task=user_task, enabled_tools=enabled_tools, skip_pre_tool_call_hook=skip_pre_tool_call_hook, skip_tool_request_middleware=skip_tool_request_middleware, @@ -843,6 +859,13 @@ def handle_function_call( enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, ) + from tools.tool_gateway.names import is_connector_name, parse_connector_name + if function_name == "manage_connections" or is_connector_name(function_name): + if "manage_connections" not in _select_tool_names(enabled_toolsets, disabled_toolsets, quiet_mode=True): + return _emit(tool_error("Connectors are not available in this session.")) + if is_connector_name(function_name) and parse_connector_name(function_name) is None: + return _emit(tool_error("Malformed connector tool name; expected connectors____.")) + original_args = dict(function_args) if not skip_tool_request_middleware: function_args, original_args, trace = _apply_request_middleware(function_name, function_args, ids, trace) diff --git a/model_tools_connectors.py b/model_tools_connectors.py new file mode 100644 index 0000000000..915a264fb8 --- /dev/null +++ b/model_tools_connectors.py @@ -0,0 +1,66 @@ +"""Connector calls re-enter the normal dispatcher under their composed names.""" + +import json +from dataclasses import asdict + +from tools.registry import tool_error +from tools.tool_gateway.config import MAX_CALLS_PER_DISPATCH +from tools.tool_gateway.merge import assemble_results, fill_remote_failure, partition_calls + + +def dispatch_connector_call(name, arguments, tool_call_id): + """Transport leg only; the caller owns the normal tool policy pipeline. + + Execution middleware wraps the actual I/O, so connector entries execute + individually rather than queuing side effects after a policy callback returns. + """ + from tools.tool_gateway.bridge import run_remote + + partition = partition_calls([{"name": name, "arguments": arguments}]) + entries = run_remote(partition.remote, tool_call_id, availability=None, client_factory=None) + entry = entries[0] + return json.dumps({key: value for key, value in entry.items() if key in {"response", "error"}}, + ensure_ascii=False) + + +def dispatch_connector_batch(calls, ids, *, user_task, enabled_tools, + middleware_trace, enabled_toolsets, disabled_toolsets): + from model_tools import handle_function_call + from tools.interrupt import is_interrupted + + if len(calls) > MAX_CALLS_PER_DISPATCH: + return tool_error(f"too many calls: {len(calls)} > max {MAX_CALLS_PER_DISPATCH}. " + "Retry with fewer calls per batch.") + partition = partition_calls(calls) + if partition.local: + return tool_error("Local tools require one entry per tool_call; mixed and multi-local batches are not supported.") + entries = list(partition.errors) + for offset, plan in enumerate(partition.remote): + if is_interrupted(): + # The executor only checks for /stop between tools, and this whole batch + # is one tool to it: unstarted entries stay unsent, or a stop landing on + # entry 1 of 20 would still fire 19 remote side effects. + entries.extend(fill_remote_failure( + partition.remote[offset:], "Stopped by the user before this call was made.", + code="INTERRUPTED")) + break + # Wrapper-level skip flags describe only the wrapper, never its entries. + payload = handle_function_call( + plan.name, plan.arguments, **asdict(ids), user_task=user_task, + enabled_tools=enabled_tools, tool_request_middleware_trace=list(middleware_trace), + skip_pre_tool_call_hook=False, skip_tool_request_middleware=False, + skip_tool_execution_middleware=False, + enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, + ) + try: + value = json.loads(payload) if isinstance(payload, str) else payload + except ValueError: + value = payload + entry = {"index": plan.position, "name": plan.name} + if isinstance(value, dict) and "error" in value: + error = value["error"] + entry["error"] = error if isinstance(error, dict) else {"code": "TOOL_ERROR", "message": str(error)} + else: + entry["response"] = value.get("response", value) if isinstance(value, dict) else value + entries.append(entry) + return json.dumps(assemble_results(len(calls), entries), ensure_ascii=False) diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index f5d84e56ff..34b637b92a 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -550,7 +550,7 @@ class TestBridgeDispatch: def test_tool_call_bad_args_error(self): with patch("model_tools.get_tool_definitions", return_value=[]): result = json.loads(handle_function_call("tool_call", {})) - assert "requires a 'name'" in result["error"] + assert "requires 'calls'" in result["error"] def test_tool_call_rejects_out_of_scope_and_unwraps_in_scope(self): import tools.tool_search as ts diff --git a/tests/tools/test_connections_tool.py b/tests/tools/test_connections_tool.py new file mode 100644 index 0000000000..f7da7cc8a4 --- /dev/null +++ b/tests/tools/test_connections_tool.py @@ -0,0 +1,537 @@ +"""Behavior tests for manage_connections. + +DI-callable idiom: a fake client injected through manage_connections' +seams; no module mocks, no network. +""" + +import json +import time +from unittest.mock import patch + +import pytest + +import tools.connections_tool # registers the tool +from tools.connections_tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections + + +class FakeClient: + def __init__(self): + self.calls = [] + + def list_connectors(self): + self.calls.append(("list",)) + return [ + {"connector": "gmail", "enabled": True, "connected": False}, + {"connector": "linear", "enabled": True, "connected": True}, + ] + + def connections(self, connectors, *, reinitiate=False): + self.calls.append(("connections", tuple(connectors), reinitiate)) + return { + "results": [ + { + "connector": c, + "status": "initiated", + "connect_url": f"https://connect.example/{c}", + "instruction": f"finish authorizing {c} in the browser", + "reinitiated": reinitiate, + } + for c in connectors + ], + "summary": {"total": len(connectors), "initiated": len(connectors)}, + } + + +def test_status_lists_and_filters_connectors(): + client = FakeClient() + out = json.loads( + manage_connections( + {"action": "status", "connectors": ["GMAIL"]}, + client_factory=lambda: client, + ) + ) + assert out["connectors"] == [ + {"connector": "gmail", "enabled": True, "connected": False} + ] + + +def test_connect_returns_link_and_instruction_once_per_session(): + client = FakeClient() + seen = set() + first = json.loads( + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=seen, + ) + ) + entry = first["results"][0] + assert entry["connect_url"] == "https://connect.example/gmail" + assert "instruction" in entry + + second = json.loads( + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=seen, + ) + ) + assert "instruction" not in second["results"][0] # shown once per session + assert ("connections", ("gmail",), False) in client.calls + + # A DIFFERENT session sharing the process still gets the guidance. + other_session = json.loads( + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=seen, + session_id="other-session", + ) + ) + assert "instruction" in other_session["results"][0] + + +def test_reconnect_sets_reinitiate(): + client = FakeClient() + manage_connections( + {"action": "reconnect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=set(), + ) + assert ("connections", ("gmail",), True) in client.calls + + +def test_connect_without_connectors_is_a_usage_error(): + out = json.loads( + manage_connections({"action": "connect"}, client_factory=FakeClient) + ) + assert "requires 'connectors'" in out["error"] + + +def test_disconnect_is_refused_before_any_gateway_call(): + # De-authentication is user-only: the tool rejects it up front and the + # gateway never hears about it. + client = FakeClient() + out = json.loads( + manage_connections( + {"action": "disconnect", "connectors": ["gmail"]}, + client_factory=lambda: client, + ) + ) + assert "error" in out + assert client.calls == [] + + +def test_gateway_failure_is_a_model_actionable_error(): + def exploding(): + raise RuntimeError("gateway on fire") + + out = json.loads( + manage_connections({"action": "status"}, client_factory=exploding) + ) + assert "connector gateway request failed" in out["error"] + + +def test_mcp_actions_are_not_this_tools_business(): + # Local MCP setup belongs to setup_mcp, which owns the desktop consent + # callback. Folding those actions in here promised a flow this tool has no + # way to reach, so they are rejected as unknown actions. + out = json.loads( + manage_connections({"action": "install", "server": "linear"}) + ) + assert "action must be one of" in out["error"] + assert "install" not in MANAGE_CONNECTIONS_SCHEMA["parameters"]["properties"]["action"]["enum"] + + +# --------------------------------------------------------------------------- +# action "wait": the waiting happens inside the call, not in the model's head +# --------------------------------------------------------------------------- + + +class WaitClient(FakeClient): + """Reports `connector` connected from the `flips_on`-th list call onward. + + `flips_on=None` never connects, which is the ordinary shape of a user who + wandered off mid-authorization. + """ + + def __init__(self, connector="gmail", flips_on=None): + super().__init__() + self.connector = connector + self.flips_on = flips_on + self.polls = 0 + self.on_poll = None + + def list_connectors(self): + self.polls += 1 + if self.on_poll is not None: + self.on_poll(self.polls) + connected = self.flips_on is not None and self.polls >= self.flips_on + return [{"connector": self.connector, "enabled": True, "connected": connected}] + + +@pytest.fixture +def no_sleep(monkeypatch): + """Collect the wait slices instead of spending them, so tests run in ms.""" + slices = [] + monkeypatch.setattr(time, "sleep", lambda seconds: slices.append(seconds)) + return slices + + +def _aged(rendered): + """Rewind every recorded stamp past the just-minted window. + + A real wait follows the connect across a turn boundary (a model round + trip); these tests call the two back to back, so without the rewind every + wait would hit the same-batch bounce instead of the path under test. + """ + for slugs in rendered.values(): + for slug, stamp in list(slugs.items()): + slugs[slug] = stamp - 60.0 + return rendered + + +def _wait(client, connectors=("gmail",), *, rendered=None, session_id=None, rewind=True, **extra): + args = {"action": "wait", "connectors": list(connectors)} + args.update(extra) + if rendered is None: + rendered = {str(session_id or ""): {c: time.monotonic() for c in connectors}} + if rewind: + _aged(rendered) + return json.loads( + manage_connections( + args, + client_factory=lambda: client, + rendered_links=rendered, + session_id=session_id, + ) + ) + + +def test_wait_returns_connected_when_the_gateway_flips_live(no_sleep): + """The whole point: the link is shown, then the call absorbs the waiting. + + Goes through 'connect' first so the link-rendering bookkeeping wait relies + on is exercised, not simulated. + """ + client = WaitClient(flips_on=3) + rendered = {} + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=set(), + rendered_links=rendered, + ) + + out = _wait(client, rendered=rendered) + assert out["status"] == "connected" + assert out["pending"] == [] + assert out["connectors"] == [ + {"connector": "gmail", "enabled": True, "connected": True} + ] + assert client.polls == 3 # each poll is a live gateway read, none cached + # Waits are taken in one-second slices so the interrupt flag stays answered. + assert set(no_sleep) == {1.0} + + +def test_wait_timeout_lists_what_is_pending_and_denies_being_an_error(no_sleep): + client = WaitClient(flips_on=None) + out = _wait(client, timeout_seconds=20) + + assert out["status"] == "timeout" + assert out["pending"] == ["gmail"] + assert out["connectors"] == [] + assert "NOT an error" in out["note"] + assert "ASK THE USER" in out["note"] + # The three offers the model must put to the user. + assert "keep waiting" in out["note"] + assert "continue without" in out["note"] + assert "fresh connect links" in out["note"] + assert "timeout_note" not in out # nothing was clamped + assert client.polls == 5 # 20s of budget at a 5s cadence, the last gap partial + + +def test_wait_clamps_an_over_long_timeout_and_says_the_cap_was_applied(no_sleep): + client = WaitClient(flips_on=None) + out = _wait(client, timeout_seconds=600) + + assert out["status"] == "timeout" + assert "180" in out["timeout_note"] + assert "capped" in out["timeout_note"] + assert client.polls == 37 # the cap, not the ask, bounded the loop + + +def test_wait_tolerates_transient_gateway_blips_but_not_a_dead_gateway(no_sleep): + # One blip costs a poll, never the whole wait: the connection still + # resolves when the gateway comes back. Three consecutive failures mean + # the gateway is genuinely down β€” the wait ends as a NEVER-error timeout + # that reports what the last good poll saw. + flaky = WaitClient(flips_on=4) + + def blip_twice(n): + if n in (2, 3): + raise RuntimeError("gateway hiccup") + + flaky.on_poll = blip_twice + out = _wait(flaky, timeout_seconds=180) + assert out["status"] == "connected" + assert flaky.polls == 4 + + dead = WaitClient(flips_on=None) + dead.on_poll = lambda n: (_ for _ in ()).throw(RuntimeError("gateway down")) + out = _wait(dead, timeout_seconds=180) + assert out["status"] == "timeout" + assert "stopped answering" in out["note"] + assert out["pending"] == ["gmail"] + assert "NOT an error" in out["note"] + assert dead.polls == 3 # gave up on the third consecutive failure + + +def test_wait_interrupted_mid_wait_reports_interrupted_not_an_error(no_sleep): + from tools.interrupt import set_interrupt + + client = WaitClient(flips_on=None) + client.on_poll = lambda n: set_interrupt(True) + try: + out = _wait(client, timeout_seconds=180) + finally: + set_interrupt(False) + + assert out["status"] == "interrupted" + assert out["pending"] == ["gmail"] + assert "NOT an error" in out["note"] + # Stopped in the first slice of the first wait rather than polling on. + assert client.polls == 1 + assert no_sleep == [] + + +def test_wait_refuses_a_connector_whose_link_this_session_never_showed(no_sleep): + """Structural anti-footgun: waiting for a link nobody rendered is a stall. + + Nothing is going to change, so the loop would burn its whole budget and + then report a pending connector the user was never asked to authorize. + """ + client = WaitClient(flips_on=1) + out = _wait(client, ("gmail", "linear"), rendered={"": {"gmail": 1.0}}) + + assert "wait refused" in out["error"] + assert "linear" in out["error"] + assert "connect" in out["error"] + assert client.polls == 0 # refused before any gateway read + + +def test_wait_in_the_same_batch_as_connect_bounces_instead_of_blocking(no_sleep): + """connectβ†’wait in one assistant turn: the user has not seen the links. + + The bounce is a normal result, not an error β€” the model is told to send + its message first and wait next turn. Zero polls, zero sleep. + """ + client = WaitClient(flips_on=1) + rendered = {} + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=set(), + rendered_links=rendered, + ) + + out = _wait(client, rendered=rendered, rewind=False) + assert out["status"] == "pending" + assert out["pending"] == ["gmail"] + assert "has not seen them" in out["note"] + assert "next turn" in out["note"] + assert client.polls == 0 + assert no_sleep == [] + + +def test_wait_accepts_a_connector_that_was_already_connected(no_sleep): + """connect on an already-live app mints no link; wait must still run. + + The refusal guard exists for connectors this session never addressed β€” + an active one WAS addressed, and there is no link the user must see, so + an immediate wait legitimately returns connected on the first poll. + """ + + class ActiveClient(WaitClient): + def connections(self, connectors, *, reinitiate=False): + self.calls.append(("connections", tuple(connectors), reinitiate)) + return { + "results": [{"connector": c, "status": "active"} for c in connectors], + "summary": {"total": len(connectors), "active": len(connectors)}, + } + + client = ActiveClient(flips_on=1) + rendered = {} + out = json.loads( + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=set(), + rendered_links=rendered, + ) + ) + assert out["results"][0]["status"] == "active" + assert "connect_url" not in out["results"][0] + assert "Already connected" in out["results"][0]["note"] + + # No rewind: even seconds after the connect, the wait runs (never_fresh). + waited = _wait(client, rendered=rendered, rewind=False) + assert waited["status"] == "connected" + assert client.polls == 1 + + +def test_wait_link_bookkeeping_is_per_session(no_sleep): + """A link shown in session A does not license a wait in session B.""" + client = WaitClient(flips_on=1) + rendered = {} + manage_connections( + {"action": "connect", "connectors": ["gmail"]}, + client_factory=lambda: client, + seen_instructions=set(), + rendered_links=rendered, + session_id="session-a", + ) + assert _wait(client, rendered=rendered, session_id="session-a")["status"] == ( + "connected" + ) + other = _wait(client, rendered=rendered, session_id="session-b") + assert "wait refused" in other["error"] + + +def test_wait_requires_connectors(): + out = json.loads( + manage_connections({"action": "wait"}, client_factory=FakeClient) + ) + assert "requires 'connectors'" in out["error"] + + +def test_wait_never_rides_a_parallel_batch(): + """A three-minute block must not hold a gathered batch's siblings hostage.""" + from agent.tool_dispatch_helpers import _NEVER_PARALLEL_TOOLS + + assert "manage_connections" in _NEVER_PARALLEL_TOOLS + + +# --------------------------------------------------------------------------- +# reachability: a registered tool nobody enables is a tool nobody can call +# --------------------------------------------------------------------------- + + +def _session_tool_names(enabled_toolsets, *, connectors, disabled_toolsets=None): + """Tool names a session would actually receive, through the real assembly. + + Skips the tool_search step so the assertion is about NAME resolution and + check_fn, not about how many MCP servers the developer running the suite + happens to have configured. + """ + from model_tools import _compute_tool_definitions + from tools.registry import invalidate_check_fn_cache + + with patch("tools.tool_gateway.config.connectors_available", + return_value=connectors): + invalidate_check_fn_cache() + try: + defs = _compute_tool_definitions( + enabled_toolsets=enabled_toolsets, + disabled_toolsets=disabled_toolsets, + quiet_mode=True, + skip_tool_search_assembly=True, + ) + finally: + invalidate_check_fn_cache() + return {d["function"]["name"] for d in defs} + + + +def test_cli_session_gets_the_tool_outside_a_code_workspace(tmp_path, monkeypatch): + """The path a plain `hermes` run takes: _get_platform_tools, no git cwd.""" + from hermes_cli.tools_config import _get_platform_tools + + monkeypatch.chdir(tmp_path) + enabled = sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True)) + + assert "connections" in enabled + assert "manage_connections" in _session_tool_names(enabled, connectors=True) + + +def test_cli_session_gets_the_tool_inside_a_code_workspace(monkeypatch): + """Same resolver, run from this repo β€” the surface the live miss was on.""" + from pathlib import Path + + from hermes_cli.tools_config import _get_platform_tools + + monkeypatch.chdir(Path(__file__).resolve().parents[2]) + enabled = sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True)) + assert "manage_connections" in _session_tool_names(enabled, connectors=True) + + +def test_tui_and_desktop_sessions_get_the_tool(monkeypatch): + """The path the TUI/desktop gateway takes to build its selection.""" + from tui_gateway.server import _load_enabled_toolsets + + monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False) + for platform in ("tui", "desktop"): + selection = _load_enabled_toolsets(platform) + names = _session_tool_names(selection, connectors=True) + assert "manage_connections" in names, platform + + +def test_focus_mode_coding_posture_gets_the_tool(monkeypatch): + """An engineer pinned to the coding posture still sees their accounts.""" + from pathlib import Path + + from agent.coding_context import coding_selection + + repo = Path(__file__).resolve().parents[2] + monkeypatch.chdir(repo) + selection = coding_selection( + platform="cli", cwd=str(repo), config={"agent": {"coding_context": "focus"}} + ) + assert selection == ["coding"] # posture collapse still collapses + assert "manage_connections" in _session_tool_names(selection, connectors=True) + + +def test_signed_out_session_sees_nothing(tmp_path, monkeypatch): + """check_fn is the only entitlement gate, on every surface.""" + from hermes_cli.tools_config import _get_platform_tools + from tui_gateway.server import _load_enabled_toolsets + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False) + selections = [ + sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True)), + _load_enabled_toolsets("tui"), + ["coding"], + ] + for selection in selections: + assert "manage_connections" not in _session_tool_names( + selection, connectors=False + ), selection + + +def test_operator_can_still_turn_it_off(tmp_path, monkeypatch): + """`agent.disabled_toolsets: [connections]` wins; a bundle name does not. + + The name is added before the disabled subtraction, so the toolset behaves + like any other. Naming a platform composite instead must NOT strip it β€” + that branch preserves core tools on purpose (#33924). + """ + from hermes_cli.tools_config import _get_platform_tools + + monkeypatch.chdir(tmp_path) + enabled = sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True)) + + assert "manage_connections" not in _session_tool_names( + enabled, connectors=True, disabled_toolsets=["connections"] + ) + assert "manage_connections" in _session_tool_names( + enabled, connectors=True, disabled_toolsets=["hermes-cli"] + ) + + +def test_tool_is_never_deferrable(): + from tools.tool_search import is_deferrable_tool_name + + # Core names short-circuit before the toolset check, so listing + # "connections" in _DIRECT_SURFACE_TOOLSETS would be redundant. + assert is_deferrable_tool_name("manage_connections") is False diff --git a/tests/tools/test_connector_bridge_wiring.py b/tests/tools/test_connector_bridge_wiring.py new file mode 100644 index 0000000000..214019d374 --- /dev/null +++ b/tests/tools/test_connector_bridge_wiring.py @@ -0,0 +1,707 @@ +"""Behavior tests for the connector leg of the tool_search bridge. + +DI-callable idiom (test_managed_tool_gateway.py precedent): remote legs are +injected as plain callables; no module mocks, no network. Local-only behavior +is pinned byte-identical when the remote leg fails (D32). +""" + +import json +import logging + +import pytest + +from agent.tool_dispatch_helpers import _peel_bridge_call +from tools.tool_gateway.bridge import connector_describe +from tools.tool_search import ( + CONNECTOR_BATCH_SENTINEL, + ToolSearchConfig, + assemble_tool_defs, + dispatch_tool_describe, + dispatch_tool_search, + resolve_underlying_call, +) +from tools.tool_search_validation import normalize_tool_call_entries + + +def _tool_search_description(tool_defs): + # session_search is in the default defer set, so the bridge activates in + # both arms for the same reason: a deferrable local tool exists. + defs = tool_defs + [{"type": "function", "function": { + "name": "session_search", "description": "Search past sessions", "parameters": {}}}] + assembled = assemble_tool_defs( + defs, context_length=200_000, config=ToolSearchConfig.from_raw({"enabled": "on"})) + assert assembled.activated + return next(td["function"]["description"] for td in assembled.tool_defs + if td["function"]["name"] == "tool_search") + + +def test_tool_search_names_manage_connections_only_when_the_session_has_it(): + """The model learns that connectors__ names belong to accounts managed by + manage_connections from the tool_search description, but only when that tool is in the + session. Signed out (or connectors off) the tool is absent and the description must not + name a tool the model cannot call.""" + with_connections = _tool_search_description(_local_defs()) + assert "manage_connections" in with_connections + assert "connectors__" in with_connections + + without = _tool_search_description( + [td for td in _local_defs() if td["function"]["name"] != "manage_connections"]) + assert "manage_connections" not in without + + +def _local_defs(): + """One deferrable (mcp-toolset) tool, one core-shaped tool.""" + return [ + {"type": "function", "function": {"name": "manage_connections", "parameters": {}}}, + { + "type": "function", + "function": { + "name": "mcp__github__create_issue", + "description": "Create a GitHub issue", + "parameters": {"type": "object", "properties": {}, "required": ["title"]}, + }, + }, + ] + + +# --------------------------------------------------------------------------- +# resolve_underlying_call: batch shapes +# --------------------------------------------------------------------------- + + +def test_resolve_single_connector_entry_returns_sentinel(): + name, args, err = resolve_underlying_call( + {"calls": [{"name": "connectors__gmail__SEND_EMAIL", "arguments": {"to": "x"}}]} + ) + assert err is None + assert name == CONNECTOR_BATCH_SENTINEL + assert args["calls"][0]["name"] == "connectors__gmail__SEND_EMAIL" + assert args["calls"][0]["arguments"] == {"to": "x"} + + +def test_resolve_multi_local_batch_requires_separate_calls(): + name, args, err = resolve_underlying_call( + {"calls": [ + {"name": "some_local_tool", "arguments": {}}, + {"name": "another_local", "arguments": {}}, + ]} + ) + assert name is None + assert "one entry per tool_call" in err + + +def test_resolve_legacy_single_shape_unchanged_for_local_names(): + # Non-deferrable local name keeps the historical rejection message. + name, args, err = resolve_underlying_call({"name": "not_a_real_tool", "arguments": {}}) + assert name is None + assert "not a deferrable tool" in (err or "") + + +def test_resolve_legacy_connector_single_shape_routes_to_sentinel(): + name, args, err = resolve_underlying_call( + {"name": "connectors__gmail__CREATE_EMAIL_DRAFT", "arguments": {}} + ) + assert err is None + assert name == CONNECTOR_BATCH_SENTINEL + assert len(args["calls"]) == 1 + + +@pytest.mark.parametrize( + "bad,expected_fragment", + [ + ({}, "requires 'calls'"), + ({"calls": []}, "non-empty array"), + ({"calls": [{"arguments": {}}]}, "requires a 'name'"), + ({"calls": [{"name": "tool_search"}]}, "itself a bridge tool"), + ({"calls": [{"name": "x", "arguments": "not json {"}]}, "not valid JSON"), + ({"calls": [{"name": "x", "arguments": 42}]}, "must be an object"), + ({"calls": "nope"}, "non-empty array"), + ], +) +def test_normalize_rejects_malformed_batches(bad, expected_fragment): + entries, err = normalize_tool_call_entries(bad) + assert entries == [] + assert expected_fragment in (err or "") + + +# --------------------------------------------------------------------------- +# dispatch_tool_search: remote merge +# --------------------------------------------------------------------------- + + +def _fake_connector_search(queries): + assert queries == [{"use_case": "send an email"}] + return { + "results": [ + {"index": 1, "use_case": "send an email", "tools": ["GMAIL_SEND_EMAIL", "ORPHAN_TOOL"]}, + ], + "schemas": { + "GMAIL_SEND_EMAIL": { + "connector": "gmail", + "tool": "GMAIL_SEND_EMAIL", + "description": "Send an email via gmail", + "input_schema": {"type": "object", "required": ["to", "subject"]}, + }, + # ORPHAN_TOOL deliberately has no schema entry: without a + # connector it cannot compose a callable name and must be dropped. + }, + "connections": [{"connector": "gmail", "connected": False, "description": ""}], + } + + +def _registered_local_defs(): + """Deferrable local tools the registry knows, so they enter the BM25 catalog: an issue + tracker whose descriptions mention email notifications, and an unrelated tool.""" + from tools.registry import registry + + specs = [ + ("mcp__tracker__create_issue", "Create an issue. Sends an email notification to the team."), + ("mcp__tracker__list_issues", "List issues in a project. Email digests are optional."), + ("mcp__tracker__archive_project", "Archive a project and its issues."), + ] + defs = [] + for name, desc in specs: + schema = {"name": name, "description": desc, + "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}} + registry.register(name=name, handler=lambda a, **k: "{}", schema=schema, toolset="mcp-tracker") + defs.append({"type": "function", "function": schema}) + return [{"type": "function", "function": {"name": "manage_connections", "parameters": {}}}] + defs, [n for n, _ in specs] + + +def test_connector_intent_is_not_starved_by_local_tools_sharing_one_word(): + """The reported bug: with a large local catalog, tools that merely shared 'email' filled + every slot and the gmail connector tool never appeared. Ranked as one corpus with the + rarest-token gate ('gmail' is in one document), the connector tool is the only result.""" + from tools.registry import registry + + defs, names = _registered_local_defs() + try: + out = json.loads(dispatch_tool_search( + {"queries": ["send gmail email"], "limit": 5}, + current_tool_defs=defs, + connector_search=lambda q: { + "results": [{"use_case": "send gmail email", "tools": ["GMAIL_SEND_EMAIL"]}], + "schemas": {"GMAIL_SEND_EMAIL": { + "connector": "gmail", "tool": "GMAIL_SEND_EMAIL", + "description": "Send an email via gmail", "input_schema": {}}}, + })) + assert out["results"][0]["matches"] == ["connectors__gmail__SEND_EMAIL"] + finally: + for n in names: + registry.deregister(n) + + +def test_both_sources_answer_within_one_limit(): + """When a local MCP server and a connector both serve the same service, both surface, + ranked by the same BM25 pass, and `limit` caps the group as a whole.""" + from tools.registry import registry + + defs, names = _registered_local_defs() + try: + out = json.loads(dispatch_tool_search( + {"queries": ["tracker create issue"], "limit": 2}, + current_tool_defs=defs, + connector_search=lambda q: { + "results": [{"use_case": "tracker create issue", "tools": ["TRACKER_CREATE_ISSUE"]}], + "schemas": {"TRACKER_CREATE_ISSUE": { + "connector": "tracker", "tool": "TRACKER_CREATE_ISSUE", + "description": "Create a tracker issue", "input_schema": {}}}, + })) + matches = out["results"][0]["matches"] + assert len(matches) == 2 + assert set(matches) == {"mcp__tracker__create_issue", "connectors__tracker__CREATE_ISSUE"} + finally: + for n in names: + registry.deregister(n) + + +def test_search_composes_lowercase_connector_from_vendor_cased_schema(): + # The gateway search surface leaks vendor-cased connector slugs for + # custom toolkits; the composed name must carry the lowercase catalog + # form or the gateway's own policy gates refuse the call. + def cased_search(queries): + return { + "results": [{"index": 1, "tools": ["CUSTOM_X_READ"]}], + "schemas": { + "CUSTOM_X_READ": { + "connector": "CUSTOM_X", + "tool": "CUSTOM_X_READ", + "description": "d", + "input_schema": {}, + } + }, + } + + out = json.loads( + dispatch_tool_search( + {"queries": ["custom_x read"]}, + current_tool_defs=_local_defs(), + connector_search=cased_search, + ) + ) + assert "connectors__custom_x__READ" in out["results"][0]["matches"] + + +def test_search_merges_remote_hits_tagged_as_connectors(): + out = json.loads( + dispatch_tool_search( + {"queries": ["send an email"]}, + current_tool_defs=_local_defs(), + connector_search=_fake_connector_search, + ) + ) + matches = out["results"][0]["matches"] + composed = "connectors__gmail__SEND_EMAIL" + assert composed in matches + assert all("ORPHAN_TOOL" not in m for m in matches) + record = out["tools"][composed] + assert record["source"] == "connectors" + assert record["source_name"] == "gmail" + assert record["required"] == ["to", "subject"] + + +@pytest.mark.parametrize("order", [("GMAIL_FETCH_PROFILE", "FETCH_PROFILE"), ("FETCH_PROFILE", "GMAIL_FETCH_PROFILE")]) +def test_search_keeps_only_the_twin_a_colliding_name_reaches(order, caplog): + """Composition is not injective: GMAIL_FETCH_PROFILE and a literal FETCH_PROFILE on + gmail both compose to connectors__gmail__FETCH_PROFILE, and describe/execute decode + that name to GMAIL_FETCH_PROFILE. If a vendor ever ships both, search must not + describe the literal under a name that runs the prefixed tool, whichever the + gateway listed first, and must say so in the log rather than alias silently.""" + def twins(queries): + return { + "results": [{"index": 1, "tools": list(order)}], + "schemas": { + "GMAIL_FETCH_PROFILE": {"connector": "gmail", "tool": "GMAIL_FETCH_PROFILE", + "description": "prefixed twin", "input_schema": {}}, + "FETCH_PROFILE": {"connector": "gmail", "tool": "FETCH_PROFILE", + "description": "literal twin", "input_schema": {}}, + }, + } + + with caplog.at_level(logging.WARNING, logger="tools.connector_search"): + out = json.loads(dispatch_tool_search( + {"queries": ["gmail fetch profile"]}, + current_tool_defs=_local_defs(), + connector_search=twins, + )) + assert out["results"][0]["matches"] == ["connectors__gmail__FETCH_PROFILE"] + assert out["tools"]["connectors__gmail__FETCH_PROFILE"]["description"] == "prefixed twin" + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "GMAIL_FETCH_PROFILE" in warnings[0] and "FETCH_PROFILE" in warnings[0] + + +def test_search_limit_caps_the_group_across_both_legs_and_counts_total(): + def many_hits(queries): + slugs = [f"CUSTOM_X_TOOL_{i}" for i in range(9)] + return { + "results": [{"index": 1, "tools": slugs}], + "schemas": { + s: {"connector": "custom_x", "tool": s, "description": "widget", "input_schema": {}} + for s in slugs + }, + } + + out = json.loads( + dispatch_tool_search( + {"queries": ["custom_x widget"], "limit": 3}, + current_tool_defs=_local_defs(), + connector_search=many_hits, + ) + ) + matches = out["results"][0]["matches"] + assert len(matches) == 3 # limit is the per-query cap across BOTH legs + assert all(m.startswith("connectors__") for m in matches) + # total_available counts returned remote tools on top of the local catalog + # (empty here: the fake def is not registry-backed in this test env). + assert out["total_available"] == 3 + + +def test_search_drops_remote_group_with_mismatched_use_case_echo(): + def misaligned(queries): + return { + "results": [{"index": 1, "use_case": "SOMETHING ELSE", "tools": ["CUSTOM_X_READ"]}], + "schemas": { + "CUSTOM_X_READ": {"connector": "custom_x", "tool": "CUSTOM_X_READ", "description": "d", "input_schema": {}} + }, + } + + out = json.loads( + dispatch_tool_search( + {"queries": ["send an email"]}, + current_tool_defs=_local_defs(), + connector_search=misaligned, + ) + ) + assert not any(m.startswith("connectors__") for m in out["results"][0]["matches"]) + + +def test_search_identical_to_local_only_when_remote_leg_fails(): + def exploding_search(queries): + raise RuntimeError("gateway exploded") + + local_only = dispatch_tool_search( + {"queries": ["send an email"]}, + current_tool_defs=_local_defs(), + connector_search=lambda queries: {}, + ) + with_failure = dispatch_tool_search( + {"queries": ["send an email"]}, + current_tool_defs=_local_defs(), + connector_search=exploding_search, + ) + assert local_only == with_failure # byte-identical: D32 + + +def test_search_never_sends_the_gateway_more_use_cases_than_it_accepts(): + """The gateway's search route returns HTTP 502 above 7 use_cases per request, and one + tool_search call maps to one gateway request. Seven queries reach it in one request; + eight are refused before any request is made, so the model gets a retry hint and the + gateway never sees a request it cannot answer.""" + sent = [] + + def recording_search(use_cases): + sent.append(use_cases) + return {} + + seven = [f"query {i}" for i in range(7)] + parsed = json.loads(dispatch_tool_search( + {"queries": seven}, current_tool_defs=_local_defs(), connector_search=recording_search)) + assert "error" not in parsed + assert sent == [[{"use_case": q} for q in seven]] + + sent.clear() + parsed = json.loads(dispatch_tool_search( + {"queries": seven + ["query 7"]}, current_tool_defs=_local_defs(), + connector_search=recording_search)) + assert "too many queries" in parsed["error"] + assert sent == [] + + +# --------------------------------------------------------------------------- +# dispatch_tool_describe: remote merge +# --------------------------------------------------------------------------- + + +def test_describe_merges_remote_schema_and_leaves_misses_in_not_found(): + composed = "connectors__gmail__SEND_EMAIL" + stale = "connectors__gmail__GONE_TOOL" + + def fake_describe(names): + assert set(names) == {composed, stale} + return {"tools": {composed: {"description": "Send an email", "parameters": {"type": "object"}}}} + + out = json.loads( + dispatch_tool_describe( + {"names": [composed, stale]}, + current_tool_defs=_local_defs(), + connector_describe=fake_describe, + ) + ) + assert out["tools"][composed]["parameters"] == {"type": "object"} + assert stale in out["not_found"] + assert "errors" not in out # a connector miss is stale/unknown, not an error + + +def test_describe_connector_names_fall_to_not_found_when_dark(): + composed = "connectors__gmail__SEND_EMAIL" + out = json.loads( + dispatch_tool_describe( + {"names": [composed]}, + current_tool_defs=_local_defs(), + connector_describe=lambda names: {}, + ) + ) + assert out["not_found"] == [composed] + + +# --------------------------------------------------------------------------- +# planner admission: only PURE connector batches are parallel-safe +# --------------------------------------------------------------------------- + + +def test_peel_admits_pure_connector_batch_as_sentinel(): + name, args = _peel_bridge_call( + "tool_call", + {"calls": [ + {"name": "connectors__gmail__SEND_EMAIL", "arguments": {}}, + {"name": "connectors__slack__POST_MESSAGE", "arguments": {}}, + ]}, + ) + assert name == CONNECTOR_BATCH_SENTINEL + + +def test_peel_keeps_mixed_and_local_batches_as_sequential_barrier(): + mixed = {"calls": [ + {"name": "connectors__gmail__SEND_EMAIL", "arguments": {}}, + {"name": "write_file", "arguments": {"path": "x"}}, + ]} + name, args = _peel_bridge_call("tool_call", mixed) + assert name == "tool_call" # barrier: local entries never got admission + + all_local = {"calls": [ + {"name": "write_file", "arguments": {"path": "x"}}, + {"name": "read_file", "arguments": {"path": "x"}}, + ]} + name, _ = _peel_bridge_call("tool_call", all_local) + assert name == "tool_call" + + +# --------------------------------------------------------------------------- +# run_remote through production dispatch: vendor-slug restoration, the +# one-pass literal fallback, and the gateway request body +# +# tool_call batches re-enter core dispatch once per connector entry, so each +# entry reaches run_remote alone. The gateway is swapped at the bridge's +# client factory, the same seam the real client is created through. +# --------------------------------------------------------------------------- + + +def _connectors_on(monkeypatch, client_factory): + from tools.registry import invalidate_check_fn_cache + from tools.tool_gateway import bridge, config + + monkeypatch.setattr(config, "connectors_available", lambda: True) + monkeypatch.setattr(bridge, "connectors_available", lambda: True) + monkeypatch.setattr(bridge, "_default_client_factory", client_factory) + invalidate_check_fn_cache() + + +def _tool_call(calls): + import model_tools + + return json.loads(model_tools.handle_function_call( + "tool_call", {"calls": calls}, enabled_toolsets=["connections"], session_id="bridge-session", + skip_pre_tool_call_hook=True, skip_tool_request_middleware=True, + skip_tool_execution_middleware=True)) + + +def test_execute_falls_back_once_for_literal_slug_without_touching_siblings(monkeypatch): + class FakeClient: + def __init__(self): + self.calls = [] + + def execute(self, planned): + self.calls.append([plan.tool for plan in planned]) + (plan,) = planned + if plan.tool == "GRANOLA_FETCH_NOTES": + return [{"data": None, "error": {"code": "TOOL_NOT_FOUND", "message": "missing"}}] + if plan.tool == "SLACK_POST_MESSAGE": + return [{"data": None, "error": {"code": "TOOL_NOT_ALLOWED", "message": "blocked"}}] + return [{"data": f"ran {plan.tool}", "error": None}] + + client = FakeClient() + _connectors_on(monkeypatch, lambda: client) + out = _tool_call([ + {"name": "connectors__gmail__SEND_EMAIL", "arguments": {}}, + {"name": "connectors__granola__FETCH_NOTES", "arguments": {}}, + {"name": "connectors__slack__POST_MESSAGE", "arguments": {}}, + ]) + + # Every entry crosses the wire under its restored vendor slug. Only the + # confirmed TOOL_NOT_FOUND miss is retried, once, under the literal slug; + # the success and the TOOL_NOT_ALLOWED sibling are never re-sent. + assert client.calls == [ + ["GMAIL_SEND_EMAIL"], ["GRANOLA_FETCH_NOTES"], ["FETCH_NOTES"], ["SLACK_POST_MESSAGE"]] + assert out["results"][0]["response"] == "ran GMAIL_SEND_EMAIL" + assert out["results"][1] == { + "index": 1, "name": "connectors__granola__FETCH_NOTES", "response": "ran FETCH_NOTES"} + assert out["results"][2]["error"]["code"] == "TOOL_NOT_ALLOWED" + assert out["success_count"] == 2 and out["error_count"] == 1 + + +@pytest.mark.parametrize("fail_at", ["GRANOLA_FETCH_NOTES", "FETCH_NOTES"]) +def test_execute_transport_failure_degrades_only_the_failing_entry(monkeypatch, fail_at): + class FakeClient: + def execute(self, planned): + (plan,) = planned + if plan.tool == fail_at: + raise RuntimeError("transport failed") + if plan.tool == "GRANOLA_FETCH_NOTES": + return [{"data": None, "error": {"code": "TOOL_NOT_FOUND", "message": "missing"}}] + return [{"data": "sibling", "error": None}] + + _connectors_on(monkeypatch, FakeClient) + out = _tool_call([ + {"name": "connectors__gmail__SEND_EMAIL", "arguments": {}}, + {"name": "connectors__granola__FETCH_NOTES", "arguments": {}}, + ]) + + # Whether the primary send or the literal retry blows up, only that entry + # degrades to PROVIDER_ERROR; the sibling keeps its result. + assert out["results"][0]["response"] == "sibling" + assert out["results"][1]["error"]["code"] == "PROVIDER_ERROR" + assert out["success_count"] == 1 and out["error_count"] == 1 + + +class _FakeResponse: + def __init__(self, body): + self.status_code = 200 + self._body = body + self.text = json.dumps(body) + + def json(self): + return self._body + + +class _RecordingTransport: + """Records every outgoing request; answers each with `data` echoes.""" + + def __init__(self): + self.requests = [] + + def request(self, method, url, *, headers=None, json=None, timeout=None): + self.requests.append({"method": method, "url": url, "json": json}) + tools = (json or {}).get("tools") or [] + results = [ + {"index": i, "connector": t.get("connector"), "tool": t.get("tool"), "data": "ok"} + for i, t in enumerate(tools) + ] + return _FakeResponse( + { + "results": results, + "successCount": len(results), + "errorCount": 0, + "totalCount": len(results), + } + ) + + +def _recording_client_factory(transport): + from tools.tool_gateway.client import ConnectorClient + + return lambda: ConnectorClient( + transport=transport, + endpoint_resolver=lambda: "https://tool-gateway.test", + header_provider=lambda url: {"Authorization": "Bearer nous-token"}, + ) + + +def _sent_tools(transport): + assert len(transport.requests) == 1 + return transport.requests[0]["json"]["tools"] + + +def test_hook_rewrite_and_restored_vendor_slug_reach_the_gateway_request_body(monkeypatch): + import hermes_cli.plugins as plugins + + transport = _RecordingTransport() + _connectors_on(monkeypatch, _recording_client_factory(transport)) + # A pre_tool_call redaction pass: the secret must never leave the process. + monkeypatch.setattr(plugins, "_dispatch_pre_tool_call_hooks", + lambda name, args, **kw: (None, {**args, "body": "[REDACTED]"})) + + out = _tool_call([{"name": "connectors__gmail__SEND_EMAIL", + "arguments": {"to": "x@example.com", "body": "sk-secret"}}]) + + assert _sent_tools(transport) == [ + {"connector": "gmail", "tool": "GMAIL_SEND_EMAIL", + "arguments": {"to": "x@example.com", "body": "[REDACTED]"}}, + ] + assert "sk-secret" not in json.dumps(transport.requests[0]["json"]) + assert out["results"][0]["response"] == "ok" # correlation survives the rewrite + + +# --------------------------------------------------------------------------- +# bridge.connector_describe: composed-name round trip +# --------------------------------------------------------------------------- + + +def test_connector_describe_maps_slugs_back_to_composed_names(): + class FakeClient: + def schemas(self, slugs): + assert slugs == ["GMAIL_SEND_EMAIL", "SEND_EMAIL"] + return { + "schemas": { + "GMAIL_SEND_EMAIL": { + "connector": "gmail", + "tool": "GMAIL_SEND_EMAIL", + "description": "Send an email", + "input_schema": {"type": "object"}, + } + }, + "not_found": [], + } + + out = connector_describe( + ["connectors__gmail__SEND_EMAIL", "connectors__broken"], + availability=lambda: True, + client_factory=lambda: FakeClient(), + ) + assert out["tools"]["connectors__gmail__SEND_EMAIL"]["parameters"] == {"type": "object"} + + +def test_connector_describe_colliding_candidate_slugs_resolve_per_name(): + # connectors__first__SECOND_X nominates (FIRST_SECOND_X, SECOND_X) and + # connectors__second__X nominates (SECOND_X, X): the shared SECOND_X must + # not be claimed globally by whichever name came first. Each name takes + # its own best-ranked resolved candidate β€” here the first name's prefixed + # primary is unknown to the gateway, so BOTH names land on SECOND_X. + class FakeClient: + def schemas(self, slugs): + assert slugs == ["FIRST_SECOND_X", "SECOND_X", "X"] + return { + "schemas": { + "SECOND_X": { + "connector": "second", + "tool": "SECOND_X", + "description": "Shared slug", + "input_schema": {"type": "object"}, + } + }, + "not_found": ["FIRST_SECOND_X", "X"], + } + + out = connector_describe( + ["connectors__first__SECOND_X", "connectors__second__X"], + availability=lambda: True, + client_factory=lambda: FakeClient(), + ) + assert set(out["tools"]) == { + "connectors__first__SECOND_X", + "connectors__second__X", + } + + +def test_connector_describe_prefers_each_names_prefixed_candidate(): + # When BOTH of a name's candidates resolve, the prefixed primary wins β€” + # the literal is only the recovery lane for slugs the encoder never + # stripped. + class FakeClient: + def schemas(self, slugs): + assert slugs == ["GMAIL_X", "X"] + return { + "schemas": { + "GMAIL_X": { + "connector": "gmail", + "tool": "GMAIL_X", + "description": "prefixed", + "input_schema": {"type": "object", "title": "prefixed"}, + }, + "X": { + "connector": "gmail", + "tool": "X", + "description": "literal", + "input_schema": {"type": "object", "title": "literal"}, + }, + }, + "not_found": [], + } + + out = connector_describe( + ["connectors__gmail__X"], + availability=lambda: True, + client_factory=lambda: FakeClient(), + ) + assert out["tools"]["connectors__gmail__X"]["description"] == "prefixed" + + +def test_connector_describe_is_empty_on_unavailable_and_exploding_client(): + assert connector_describe(["connectors__g__T"], availability=lambda: False) == {} + + def boom(): + raise RuntimeError("boom") + + assert connector_describe( + ["connectors__g__T"], availability=lambda: True, client_factory=boom + ) == {} diff --git a/tests/tools/test_connector_dispatch_policy.py b/tests/tools/test_connector_dispatch_policy.py new file mode 100644 index 0000000000..523dfcab00 --- /dev/null +++ b/tests/tools/test_connector_dispatch_policy.py @@ -0,0 +1,122 @@ +"""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.tool_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.tool_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 import connections_tool + from tools.registry import registry + + monkeypatch.setattr(connections_tool, "_connectors_available", lambda: False) + monkeypatch.setattr(connections_tool, "_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"] diff --git a/tests/tools/test_connector_local_batches.py b/tests/tools/test_connector_local_batches.py new file mode 100644 index 0000000000..75d4668b48 --- /dev/null +++ b/tests/tools/test_connector_local_batches.py @@ -0,0 +1,72 @@ +"""Local deferred tools retain the live agent path; batches must not bypass it.""" + +import json +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize("mixed", [False, True]) +def test_local_batches_rejected_before_any_entry_executes(monkeypatch, mixed): + import model_tools + from tools.tool_search import resolve_underlying_call + from tools.tool_gateway import bridge, config + from tools.registry import invalidate_check_fn_cache + + monkeypatch.setattr(config, "connectors_available", lambda: True) + monkeypatch.setattr(bridge, "connectors_available", lambda: True) + invalidate_check_fn_cache() + calls = [ + {"name": "session_search", "arguments": {}}, + {"name": "connectors__gmail__SEND_EMAIL" if mixed else "todo_list", "arguments": {}}, + ] + name, args, error = resolve_underlying_call({"calls": calls}) + assert name is None and "one entry per tool_call" in error + invoked = [] + monkeypatch.setattr(model_tools.registry, "dispatch", lambda *a, **kw: invoked.append(a)) + monkeypatch.setattr(bridge, "_default_client_factory", lambda: invoked.append("gateway")) + result = json.loads(model_tools.handle_function_call( + "tool_call", {"calls": calls}, enabled_toolsets=["connections", "session_search", "todo"])) + assert "one entry per tool_call" in result["error"] + assert invoked == [] + + +@pytest.mark.parametrize("flatten_probe", [False, True]) +def test_single_local_unwrap_keeps_session_db_todo_store_and_setup_callback(tmp_path, flatten_probe): + from agent.tool_executor import _unwrap_tool_search_call + from agent.agent_runtime_helpers import invoke_tool + from hermes_state import SessionDB + from tools.todo_tool import TodoStore + + db = SessionDB(tmp_path / "recall.db") + db.create_session("past-session", source="cli") + db.append_message("past-session", role="user", content="live-db-proof") + callbacks = [] + def setup(server, action, reason): + callbacks.append((server, action, reason)) + return json.dumps({"status": "declined", "server": server}) + + agent = SimpleNamespace( + enabled_toolsets=["todo", "session_search", "desktop_ui"], disabled_toolsets=[], + session_id="current-session", _todo_store=TodoStore(), _memory_manager=None, + _get_session_db_for_recall=lambda: db, setup_mcp_callback=setup, + ) + calls = [ + {"name": "session_search", "arguments": {"session_id": "past-session"}}, + {"name": "todo_list", "arguments": {"todos": [{"id": "a", "content": "live-store-proof", "status": "pending"}]}}, + {"name": "setup_mcp", "arguments": {"server": "example", "action": "install", "reason": "live-callback-proof"}}, + ] + results = [] + try: + for entry in calls: + name, args, error = _unwrap_tool_search_call( + agent, "tool_call", {"calls": [entry]}, flatten_probe=flatten_probe) + assert name == entry["name"] and error is None + results.append(json.loads(invoke_tool( + agent, name, args, "task", tool_call_id="call", pre_tool_block_checked=True))) + assert "live-db-proof" in json.dumps(results[0]) + assert agent._todo_store.read()[0]["content"] == "live-store-proof" + assert results[2] == {"status": "declined", "server": "example"} + assert callbacks == [("example", "install", "live-callback-proof")] + finally: + db.close() diff --git a/tests/tools/test_connector_names.py b/tests/tools/test_connector_names.py new file mode 100644 index 0000000000..07e4c0ab03 --- /dev/null +++ b/tests/tools/test_connector_names.py @@ -0,0 +1,70 @@ +"""Invariant tests for model-facing connector names and wire-slug recovery.""" + +import pytest + +from tools.tool_gateway.names import ( + format_connector_name, + parse_connector_name, + vendor_slug_candidates, +) + + +ENCODE_CASES = [ + ( + "gmail", + "GMAIL_GET_PROFILE", + "connectors__gmail__GET_PROFILE", + True, + ), + ( + "better_stack_mcp", + "BETTER_STACK_MCP_ACKNOWLEDGE_INCIDENT", + "connectors__better_stack_mcp__ACKNOWLEDGE_INCIDENT", + True, + ), + ( + "gmail", + "FETCH_PROFILE", + "connectors__gmail__FETCH_PROFILE", + False, + ), + ( + "gmail", + "GMAIL", + "connectors__gmail__GMAIL", + False, + ), + ( + "granola", + "GRANOLA_MCP_GET_MEETINGS", + "connectors__granola__MCP_GET_MEETINGS", + True, + ), +] + + +@pytest.mark.parametrize("connector,vendor_slug,composed,_stripped", ENCODE_CASES) +def test_format_connector_name_strips_only_the_exact_toolkit_prefix( + connector, vendor_slug, composed, _stripped +): + assert format_connector_name(connector, vendor_slug) == composed + + +def test_vendor_slug_candidates_are_prefixed_then_literal(): + assert vendor_slug_candidates("gmail", "GET_PROFILE") == ( + "GMAIL_GET_PROFILE", + "GET_PROFILE", + ) + + +@pytest.mark.parametrize("connector,vendor_slug,composed,stripped", ENCODE_CASES) +def test_every_encoded_vendor_slug_has_a_wire_recovery_candidate( + connector, vendor_slug, composed, stripped +): + parsed = parse_connector_name(composed) + assert parsed is not None + + candidates = vendor_slug_candidates(parsed.connector, parsed.tool) + assert vendor_slug in candidates + if stripped: + assert candidates[0] == vendor_slug diff --git a/tests/tools/test_connector_session_scope.py b/tests/tools/test_connector_session_scope.py new file mode 100644 index 0000000000..40284e29c0 --- /dev/null +++ b/tests/tools/test_connector_session_scope.py @@ -0,0 +1,86 @@ +"""Connector capabilities follow session grants, not process-wide credentials.""" + +import json + +import pytest + + +@pytest.mark.parametrize("enabled,disabled,allowed", [ + ([], [], False), + (["safe"], [], False), + (["hermes-webhook"], [], False), + (["connections"], [], True), + (["hermes-cli"], ["connections"], False), + (["safe"], ["connections"], False), + (None, ["connections"], False), + (None, [], True), +]) +def test_connector_scope_controls_schema_discovery_and_execution(monkeypatch, enabled, disabled, allowed): + import model_tools + from tools.tool_gateway import bridge, config + from tools import connections_tool + + monkeypatch.setattr(config, "connectors_available", lambda: True) + monkeypatch.setattr(bridge, "connectors_available", lambda: True) + from tools.registry import invalidate_check_fn_cache + invalidate_check_fn_cache() + remote = [] + name = "connectors__gmail__SEND_EMAIL" + + class Client: + def search(self, queries): + remote.append("search") + return {"results": [{"tools": ["GMAIL_SEND_EMAIL"]}], "schemas": { + "GMAIL_SEND_EMAIL": {"connector": "gmail", "description": "Send mail", "input_schema": {}}}} + + def schemas(self, names): + remote.append("describe") + return {"schemas": {"GMAIL_SEND_EMAIL": {"description": "Send mail", "input_schema": {}}}} + + def execute(self, planned): + remote.append("execute") + return [{"data": "sent", "error": None} for _ in planned] + + def list_connectors(self): + remote.append("status") + return [] + + monkeypatch.setattr(bridge, "_default_client_factory", Client) + monkeypatch.setattr(connections_tool, "_default_client", Client) + scope = {"enabled_toolsets": enabled, "disabled_toolsets": disabled} + defs = model_tools.get_tool_definitions(**scope, quiet_mode=True, skip_tool_search_assembly=True) + assert ("manage_connections" in {td["function"]["name"] for td in defs}) is allowed + if enabled == []: + assert model_tools.get_tool_definitions(**scope, quiet_mode=True) == [] + + def call(tool, args): + return json.loads(model_tools.handle_function_call(tool, args, **scope)) + + assert (name in call("tool_search", {"queries": ["send mail"]})["tools"]) is allowed + assert (name in call("tool_describe", {"names": [name]})["tools"]) is allowed + result = call("tool_call", {"calls": [{"name": name, "arguments": {}}]}) + direct = call(name, {}) + status = call("manage_connections", {"action": "status"}) + if allowed: + assert result["results"][0]["response"] == "sent" + assert "error" not in status + assert direct["response"] == "sent" + assert remote == ["search", "describe", "execute", "execute", "status"] + else: + assert "not available in this session" in json.dumps(result) + assert "not available in this session" in json.dumps(status) + assert "not available in this session" in json.dumps(direct) + assert remote == [] + + +def test_ordinary_platform_defaults_grant_connections_without_widening_webhook(): + from hermes_cli.tools_config import _get_platform_tools + from toolsets import resolve_toolset + + for platform in ("cli", "telegram"): + enabled = _get_platform_tools({}, platform) + assert "connections" in enabled + assert "manage_connections" in {name for ts in enabled for name in resolve_toolset(ts)} + assert "connections" not in _get_platform_tools({}, "webhook") + for selection in ([], ["safe"], ["file"]): + assert "connections" not in _get_platform_tools({"platform_toolsets": {"cli": selection}}, "cli") diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index 7f5d788c14..71ac04b6bc 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -6,6 +6,8 @@ from pathlib import Path import sys from unittest.mock import patch +from tools import managed_gateway_auth + MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py" MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH) @@ -133,3 +135,76 @@ def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tm assert is_managed_tool_gateway_ready("modal") is True assert refresh_calls == [] + + +def test_connector_gateway_origin_pins_the_deployed_connectors_host(): + # The connectors API is its own deployment on its own canonical host, so + # the default resolution must not land on the media/vendor origin. + with patch.dict( + os.environ, + {"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"}, + clear=False, + ): + os.environ.pop("CONNECTOR_GATEWAY_URL", None) + assert managed_gateway_auth.connector_gateway_origin() == ( + "https://connector-gateway.nousresearch.com" + ) + +def test_managed_gateway_origin_honors_the_harness_override(): + # TOOL_GATEWAY_URL pins the full media origin (the e2e harness sets it to a + # loopback gateway), and the bearer gate must accept exactly that origin. + with patch.dict(os.environ, {"TOOL_GATEWAY_URL": "http://127.0.0.1:3009/"}, clear=False): + os.environ.pop("CONNECTOR_GATEWAY_URL", None) + assert managed_gateway_auth.managed_gateway_origin() == "http://127.0.0.1:3009" + assert managed_gateway_auth.is_managed_nous_gateway_url( + "http://127.0.0.1:3009/api/vendorx/generations" + ) + assert not managed_gateway_auth.is_managed_nous_gateway_url( + "https://tools.nousresearch.com/api/vendorx/generations" + ) + +def test_connector_gateway_origin_honors_its_own_override(): + # CONNECTOR_GATEWAY_URL is the connectors host's own key: it moves the + # connectors origin without touching the media origin, and the bearer gate + # accepts the overridden origin. + with patch.dict( + os.environ, + { + "CONNECTOR_GATEWAY_URL": "http://127.0.0.1:3009/", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }, + clear=False, + ): + os.environ.pop("TOOL_GATEWAY_URL", None) + assert managed_gateway_auth.connector_gateway_origin() == "http://127.0.0.1:3009" + assert managed_gateway_auth.managed_gateway_origin() == ( + "https://tool-gateway.nousresearch.com" + ) + assert managed_gateway_auth.is_managed_nous_gateway_url( + "http://127.0.0.1:3009/v1/connectors/search" + ) + +def test_default_bearer_gate_accepts_both_deployed_hosts_only(): + # Exact (scheme, netloc) equality against each deployed origin. Both + # first-party hosts are in; the retired `tools.` host, subdomain cousins, + # and scheme downgrades are all out. + with patch.dict( + os.environ, + {"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"}, + clear=False, + ): + os.environ.pop("TOOL_GATEWAY_URL", None) + os.environ.pop("CONNECTOR_GATEWAY_URL", None) + for trusted in ( + "https://connector-gateway.nousresearch.com/v1/connectors/execute", + "https://tool-gateway.nousresearch.com/api/vendorx/generations", + ): + assert managed_gateway_auth.is_managed_nous_gateway_url(trusted) + for untrusted in ( + "https://tools.nousresearch.com/v1/connectors/execute", + "https://evil-connector-gateway.nousresearch.com.attacker.dev/v1/connectors", + "https://connector-gateway.nousresearch.com.attacker.dev/v1/connectors", + "http://connector-gateway.nousresearch.com/v1/connectors", + "http://tool-gateway.nousresearch.com/api/vendorx/generations", + ): + assert not managed_gateway_auth.is_managed_nous_gateway_url(untrusted) diff --git a/tests/tools/test_refresh_agent_mcp_tools.py b/tests/tools/test_refresh_agent_mcp_tools.py index fa61f69ea1..1643c8571d 100644 --- a/tests/tools/test_refresh_agent_mcp_tools.py +++ b/tests/tools/test_refresh_agent_mcp_tools.py @@ -8,6 +8,7 @@ rely on (name-based diff, in-place mutation, agent-scoped filtering) rather than freezing any particular tool list. """ +import json import threading import types @@ -291,6 +292,35 @@ def test_preserve_prefix_appends_late_arrivals_at_the_tail(monkeypatch): ] +def test_preserve_prefix_keeps_the_bridge_tools_byte_identical(monkeypatch): + """``tool_search``'s description is derived from the session at build time: the + deferred-tool count, the embedded listing, and whether ``manage_connections`` was + present. Every one of those inputs can move between turns (a late MCP server, a + ``check_fn`` flap on a portal blip), and a moved byte in the tool array re-prefills + the whole cached history. The refresh must leave the bridge entries exactly as + built; a search still reads the live catalog at dispatch.""" + from tools.tool_search_catalog import BRIDGE_TOOL_NAMES + + built = _tool("tool_search") + built["function"]["description"] = "Search 21 additional tools. connectors__ hint present." + agent = _agent(["read_file", "manage_connections"]) + agent.tools.append(built) + agent.valid_tool_names.add("tool_search") + before = json.dumps(agent.tools, sort_keys=True) + + fresh_bridge = _tool("tool_search") + fresh_bridge["function"]["description"] = "Search 33 additional tools." + # manage_connections flapped out (portal blip); a late server grew the count. + _serve(monkeypatch, [_tool("read_file"), fresh_bridge, _tool("mcp_late_tool")]) + _registered(monkeypatch, ["read_file", "manage_connections", "mcp_late_tool", *BRIDGE_TOOL_NAMES]) + + added = _mcp_agent.refresh_agent_mcp_tools(agent, preserve_prefix=True) + + assert added == {"mcp_late_tool"} + assert json.dumps(agent.tools[:3], sort_keys=True) == before + assert [t["function"]["name"] for t in agent.tools][-1] == "mcp_late_tool" + + # --------------------------------------------------------------------------- # tools[] freeze: eviction rebuild + the /reload-mcp re-probe hatch # --------------------------------------------------------------------------- diff --git a/tests/tools/test_tool_gateway_client.py b/tests/tools/test_tool_gateway_client.py new file mode 100644 index 0000000000..5c84f8810e --- /dev/null +++ b/tests/tools/test_tool_gateway_client.py @@ -0,0 +1,331 @@ +"""Behavior tests for ConnectorClient and the bridge entry points. + +DI-callable idiom (test_managed_tool_gateway.py precedent): fakes are +injected through the constructor seams β€” no module mocks, no patching of +transports. FakeTransport records requests and replays queued responses. +""" + +import json +from dataclasses import replace as dataclass_replace + +import pytest + +from tools.tool_gateway.bridge import connector_search_hits +from tools.tool_gateway.client import ConnectorClient +from tools.tool_gateway.errors import ( + GatewayAuthError, + GatewayUnavailable, + IdempotencyConflict, + ToolGatewayError, +) +from tools.tool_gateway.names import vendor_slug_candidates + + +class FakeResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + self.text = json.dumps(body) + + def json(self): + return self._body + + +class FakeTransport: + """Records requests; replays queued responses (exceptions raise).""" + + def __init__(self, *responses): + self.responses = list(responses) + self.requests = [] + + def request(self, method, url, *, headers=None, json=None, timeout=None): + self.requests.append( + {"method": method, "url": url, "headers": dict(headers or {}), "json": json} + ) + outcome = self.responses.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +def make_client(transport): + return ConnectorClient( + transport=transport, + endpoint_resolver=lambda: "https://tool-gateway.test", + header_provider=lambda url: {"Authorization": "Bearer nous-token"}, + ) + + +def execute_envelope(results): + errors = sum(1 for r in results if r.get("error")) + return { + "results": results, + "successCount": len(results) - errors, + "errorCount": errors, + "totalCount": len(results), + } + + +PLAN_CALLS = [ + {"name": "connectors__gmail__SEND_EMAIL", "arguments": {"to": "x"}}, + {"name": "connectors__slack__POST_MESSAGE", "arguments": {}}, +] + + +def planned(calls=PLAN_CALLS): + from tools.tool_gateway.merge import partition_calls + + return tuple( + dataclass_replace( + plan, + tool=vendor_slug_candidates(plan.connector, plan.tool)[0], + ) + for plan in partition_calls(calls).remote + ) + + +# --------------------------------------------------------------------------- +# execute: request shape + idempotency +# --------------------------------------------------------------------------- + + +def test_execute_sends_one_request_with_camelcase_body_and_key(): + transport = FakeTransport( + FakeResponse( + 200, + execute_envelope( + [ + {"index": 0, "connector": "gmail", "tool": "GMAIL_SEND_EMAIL", "data": {"id": "m1"}}, + {"index": 1, "connector": "slack", "tool": "SLACK_POST_MESSAGE", "data": "ok"}, + ] + ), + ) + ) + results = make_client(transport).execute(planned()) + + assert len(transport.requests) == 1 + request = transport.requests[0] + assert request["url"].endswith("/v1/connectors/execute") + assert request["json"] == { + "tools": [ + {"connector": "gmail", "tool": "GMAIL_SEND_EMAIL", "arguments": {"to": "x"}}, + {"connector": "slack", "tool": "SLACK_POST_MESSAGE", "arguments": {}}, + ] + } + assert request["headers"]["x-idempotency-key"] # present, non-empty + assert request["headers"]["Authorization"] == "Bearer nous-token" + assert results == [ + {"data": {"id": "m1"}, "error": None}, + {"data": "ok", "error": None}, + ] + + +def test_retry_on_5xx_reuses_the_same_idempotency_key(): + transport = FakeTransport( + FakeResponse(502, {"error": {"code": "BAD_GATEWAY", "message": "upstream"}}), + FakeResponse( + 200, + execute_envelope( + [{"index": 0, "connector": "gmail", "tool": "GMAIL_SEND_EMAIL", "data": "sent"}] + ), + ), + ) + results = make_client(transport).execute(planned(PLAN_CALLS[:1])) + + assert len(transport.requests) == 2 + first_key = transport.requests[0]["headers"]["x-idempotency-key"] + second_key = transport.requests[1]["headers"]["x-idempotency-key"] + assert first_key == second_key + assert results[0]["data"] == "sent" + + +def test_retry_on_transport_failure_reuses_key_then_gives_up(): + transport = FakeTransport( + ConnectionError("reset"), ConnectionError("reset again") + ) + with pytest.raises(ToolGatewayError) as exc_info: + make_client(transport).execute(planned(PLAN_CALLS[:1])) + assert exc_info.value.code == "TRANSPORT_ERROR" + assert len(transport.requests) == 2 + assert ( + transport.requests[0]["headers"]["x-idempotency-key"] + == transport.requests[1]["headers"]["x-idempotency-key"] + ) + + +def test_4xx_never_retries(): + transport = FakeTransport( + FakeResponse(400, {"error": {"code": "BAD_REQUEST", "message": "nope"}}) + ) + with pytest.raises(ToolGatewayError): + make_client(transport).execute(planned(PLAN_CALLS[:1])) + assert len(transport.requests) == 1 + + +def test_409_raises_idempotency_conflict_and_never_retries(): + transport = FakeTransport( + FakeResponse( + 409, + {"error": {"code": "IDEMPOTENCY_CONFLICT", "message": "key reused"}}, + ) + ) + with pytest.raises(IdempotencyConflict): + make_client(transport).execute(planned(PLAN_CALLS[:1])) + assert len(transport.requests) == 1 + + +# --------------------------------------------------------------------------- +# status mapping + auth +# --------------------------------------------------------------------------- + + +def test_404_raises_gateway_unavailable_the_dark_signal(): + transport = FakeTransport(FakeResponse(404, {"error": {"code": "NOT_FOUND", "message": "no route"}})) + with pytest.raises(GatewayUnavailable): + make_client(transport).execute(planned(PLAN_CALLS[:1])) + + +def test_401_raises_auth_error_and_missing_token_fails_fast(): + transport = FakeTransport( + FakeResponse(401, {"error": {"code": "UNAUTHORIZED", "message": "expired"}}) + ) + with pytest.raises(GatewayAuthError): + make_client(transport).execute(planned(PLAN_CALLS[:1])) + + # No token -> no request at all. + no_token = FakeTransport() + client = ConnectorClient( + transport=no_token, + endpoint_resolver=lambda: "https://tool-gateway.test", + header_provider=lambda url: {}, + ) + with pytest.raises(GatewayAuthError): + client.execute(planned(PLAN_CALLS[:1])) + assert no_token.requests == [] + + +def test_connection_required_stays_inside_the_200_envelope(): + transport = FakeTransport( + FakeResponse( + 200, + execute_envelope( + [ + { + "index": 0, + "connector": "gmail", + "tool": "GMAIL_SEND_EMAIL", + "error": { + "code": "CONNECTION_REQUIRED", + "message": "connect gmail", + "connector": "gmail", + "connectUrl": "https://example.test/connect/1", + }, + } + ] + ), + ) + ) + (result,) = make_client(transport).execute(planned(PLAN_CALLS[:1])) + assert result["error"]["code"] == "CONNECTION_REQUIRED" + assert result["error"]["connect_url"] == "https://example.test/connect/1" + + +# --------------------------------------------------------------------------- +# bridge: connector_search_hits silent degradation (D32) +# --------------------------------------------------------------------------- + + +def test_search_hits_empty_on_unavailable_dark_gateway_and_exploding_client(): + assert connector_search_hits( + [{"use_case": "send mail"}], availability=lambda: False + ) == {} + + def dark_factory(): + raise GatewayUnavailable("dark", code="NOT_FOUND", status=404) + + assert ( + connector_search_hits( + [{"use_case": "send mail"}], + availability=lambda: True, + client_factory=dark_factory, + ) + == {} + ) + + def boom_factory(): + raise RuntimeError("boom") + + assert ( + connector_search_hits( + [{"use_case": "send mail"}], + availability=lambda: True, + client_factory=boom_factory, + ) + == {} + ) + + +def test_search_hits_pass_through_on_success(): + class FakeClient: + def search(self, queries): + assert queries == [{"use_case": "send mail"}] + return {"results": [{"index": 1, "use_case": "send mail"}]} + + hits = connector_search_hits( + [{"use_case": "send mail"}], + availability=lambda: True, + client_factory=lambda: FakeClient(), + ) + assert hits["results"][0]["use_case"] == "send mail" + + +# --------------------------------------------------------------------------- +# default endpoint resolver: the SHARED origin, not a fabricated vendor +# --------------------------------------------------------------------------- + + +_GATEWAY_ENV_KEYS = ( + "TOOL_GATEWAY_URL", + "CONNECTOR_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", +) + + +def _resolve_with_env(**overrides): + """Run the default resolver with ONLY the given gateway env keys set.""" + import os + from unittest.mock import patch + + from tools.tool_gateway.client import _default_endpoint_resolver + + env = {k: v for k, v in os.environ.items() if k not in _GATEWAY_ENV_KEYS} + env.update(overrides) + with patch.dict("os.environ", env, clear=True): + return _default_endpoint_resolver() + + +def test_default_resolver_uses_the_connector_gateway_origin(): + # Connector routes live on the connectors deployment's own host, so the + # resolver wants that origin β€” never a fabricated "connectors" vendor + # passthrough host, and never the media/on-origin-vendor host. + assert _resolve_with_env(CONNECTOR_GATEWAY_URL="http://127.0.0.1:3009") == ( + "http://127.0.0.1:3009" + ) + assert _resolve_with_env(TOOL_GATEWAY_DOMAIN="gw.example.com") == ( + "https://connector-gateway.gw.example.com" + ) + + +def test_default_resolver_ignores_the_media_host_override(): + # TOOL_GATEWAY_URL moves the media/on-origin-vendor host only. Letting it + # drag the connectors client along would silently point connector calls at + # a host that does not serve them. + assert _resolve_with_env( + TOOL_GATEWAY_URL="http://127.0.0.1:3009", + TOOL_GATEWAY_DOMAIN="gw.example.com", + ) == "https://connector-gateway.gw.example.com" + + +def test_default_resolver_is_none_on_a_misconfigured_scheme(): + assert _resolve_with_env(TOOL_GATEWAY_SCHEME="ftp") is None diff --git a/tests/tools/test_tool_gateway_merge.py b/tests/tools/test_tool_gateway_merge.py new file mode 100644 index 0000000000..a2fe19c2ea --- /dev/null +++ b/tests/tools/test_tool_gateway_merge.py @@ -0,0 +1,306 @@ +"""Behavior tests for the pure tool_gateway merge/partition/name logic. + +Pure functions, zero fakes, no I/O β€” matching the DI-callable test idiom +(``test_managed_tool_gateway.py``). Wire/client behavior is covered in the +client PR; this file owns partition β†’ splice β†’ assemble and the name codec. +""" + +import pytest + +from tools.tool_gateway.config import ConnectorConfig, connectors_available +from tools.tool_gateway.errors import ( + GatewayAuthError, + GatewayUnavailable, + IdempotencyConflict, + ToolGatewayError, + parse_gateway_error, +) +from tools.tool_gateway.merge import ( + assemble_results, + fill_remote_failure, + partition_calls, + splice_remote_results, +) +from tools.tool_gateway.names import ( + CONNECTOR_BATCH_SENTINEL, + format_connector_name, + parse_connector_name, +) + + +# --------------------------------------------------------------------------- +# names +# --------------------------------------------------------------------------- + + +def test_parse_round_trips_and_keeps_tool_slug_underscores(): + name = format_connector_name("gmail", "GMAIL_SEND_EMAIL") + parsed = parse_connector_name(name) + assert parsed is not None + assert (parsed.connector, parsed.tool) == ("gmail", "SEND_EMAIL") + assert parsed.raw == name + + +@pytest.mark.parametrize( + "bad", + [ + None, + 42, + "", + "tool_search", + "connectors__", + "connectors____", + "connectors__gmail", + "connectors__gmail__", + "connectors____GMAIL_SEND_EMAIL", + CONNECTOR_BATCH_SENTINEL, # planner sentinel is not a callable name + ], +) +def test_parse_rejects_malformed_names_without_raising(bad): + assert parse_connector_name(bad) is None + + +def test_parse_preserves_case_both_directions(): + parsed = parse_connector_name("connectors__GitHub__Create_Issue") + assert parsed is not None + assert (parsed.connector, parsed.tool) == ("GitHub", "Create_Issue") + + +# --------------------------------------------------------------------------- +# partition +# --------------------------------------------------------------------------- + + +def test_partition_splits_mixed_batch_preserving_positions(): + calls = [ + {"name": "local_tool", "arguments": {"a": 1}}, + {"name": "connectors__gmail__SEND_EMAIL", "arguments": {"to": "x"}}, + {"name": "another_local", "arguments": {}}, + {"name": "connectors__slack__POST_MESSAGE"}, + ] + part = partition_calls(calls) + assert [pos for pos, _ in part.local] == [0, 2] + assert [p.position for p in part.remote] == [1, 3] + assert part.remote[0].connector == "gmail" + assert part.remote[0].arguments == {"to": "x"} + assert part.remote[1].arguments == {} # missing arguments -> {} + assert part.errors == () + + +def test_partition_malformed_connector_name_is_per_entry_error_siblings_run(): + calls = [ + {"name": "connectors__broken"}, # claims prefix, doesn't parse + {"name": "connectors__gmail__SEND_EMAIL"}, + ] + part = partition_calls(calls) + assert len(part.errors) == 1 + assert part.errors[0]["index"] == 0 + assert part.errors[0]["error"]["code"] == "TOOL_NOT_FOUND" + assert [p.position for p in part.remote] == [1] + + +def test_partition_is_total_on_garbage_entries(): + part = partition_calls([None, "just-a-string", {"no_name": True}]) + assert len(part.local) == 3 + assert part.remote == () + assert part.errors == () + + +# --------------------------------------------------------------------------- +# splice +# --------------------------------------------------------------------------- + + +def _plan(calls): + return partition_calls(calls).remote + + +def test_splice_maps_by_slot_and_renders_success_and_error(): + planned = _plan( + [ + {"name": "connectors__gmail__SEND_EMAIL"}, + {"name": "connectors__slack__POST_MESSAGE"}, + ] + ) + remote = [ + {"data": {"id": "msg_1"}, "error": None}, + { + "data": None, + "error": {"code": "TOOL_NOT_ALLOWED", "message": "policy refused"}, + }, + ] + entries = splice_remote_results(planned, remote) + assert entries[0] == { + "index": 0, + "name": "connectors__gmail__SEND_EMAIL", + "response": {"id": "msg_1"}, + } + assert entries[1]["index"] == 1 + assert entries[1]["error"]["code"] == "TOOL_NOT_ALLOWED" + + +def test_splice_short_remote_response_fills_provider_error(): + planned = _plan( + [ + {"name": "connectors__gmail__SEND_EMAIL"}, + {"name": "connectors__slack__POST_MESSAGE"}, + ] + ) + entries = splice_remote_results(planned, [{"data": "ok", "error": None}]) + assert entries[0]["response"] == "ok" + assert entries[1]["error"]["code"] == "PROVIDER_ERROR" + assert entries[1]["name"] == "connectors__slack__POST_MESSAGE" + + +def test_splice_over_long_remote_response_drops_surplus(): + planned = _plan([{"name": "connectors__gmail__SEND_EMAIL"}]) + entries = splice_remote_results( + planned, [{"data": "ok", "error": None}, {"data": "surplus", "error": None}] + ) + assert len(entries) == 1 + assert entries[0]["response"] == "ok" + + +def test_splice_none_response_fills_every_slot(): + planned = _plan([{"name": "connectors__gmail__SEND_EMAIL"}]) + entries = splice_remote_results(planned, None) + assert entries[0]["error"]["code"] == "PROVIDER_ERROR" + + +def test_connection_required_renders_shared_shape_with_connect_url(): + planned = _plan([{"name": "connectors__gmail__SEND_EMAIL"}]) + remote = [ + { + "data": None, + "error": { + "code": "CONNECTION_REQUIRED", + "message": "connect gmail first", + "connect_url": "https://example.test/connect/abc", + "hint": "then retry the call", + }, + } + ] + (entry,) = splice_remote_results(planned, remote) + error = entry["error"] + assert error["code"] == "CONNECTION_REQUIRED" + assert error["connector"] == "gmail" + assert error["connect_url"] == "https://example.test/connect/abc" # not redacted + assert error["hint"] == "then retry the call" + + +def test_fill_remote_failure_marks_all_planned_slots(): + planned = _plan( + [ + {"name": "connectors__gmail__SEND_EMAIL"}, + {"name": "connectors__slack__POST_MESSAGE"}, + ] + ) + entries = fill_remote_failure(planned, "gateway unreachable") + assert [e["index"] for e in entries] == [0, 1] + assert all(e["error"]["code"] == "PROVIDER_ERROR" for e in entries) + + +# --------------------------------------------------------------------------- +# assemble +# --------------------------------------------------------------------------- + + +def test_assemble_recomputes_counts_over_merged_array(): + local = [{"index": 0, "name": "local_tool", "response": "local ok"}] + remote = [ + {"index": 1, "name": "connectors__gmail__G", "response": "sent"}, + {"index": 2, "name": "connectors__x__Y", "error": {"code": "PROVIDER_ERROR", "message": "boom"}}, + ] + out = assemble_results(3, local, remote) + assert [e["index"] for e in out["results"]] == [0, 1, 2] + assert out["success_count"] == 2 + assert out["error_count"] == 1 + assert out["total_count"] == 3 + + +def test_assemble_interleaves_back_into_original_order(): + # original: [remote, local, remote] β€” splice order must not matter. + remote = [ + {"index": 0, "name": "connectors__a__T", "response": "r0"}, + {"index": 2, "name": "connectors__b__U", "response": "r2"}, + ] + local = [{"index": 1, "name": "local_tool", "response": "l1"}] + out = assemble_results(3, local, remote) + assert [e.get("response") for e in out["results"]] == ["r0", "l1", "r2"] + + +def test_assemble_is_total_on_unclaimed_and_duplicate_slots(): + out = assemble_results( + 2, + [{"index": 0, "name": "a", "response": "first"}], + [{"index": 0, "name": "a", "response": "dupe"}], # dropped + ) + assert out["results"][0]["response"] == "first" + assert out["results"][1]["error"]["code"] == "PROVIDER_ERROR" # unclaimed + assert out["total_count"] == 2 + + +# --------------------------------------------------------------------------- +# errors: the one envelope parser +# --------------------------------------------------------------------------- + + +def test_envelope_parser_maps_statuses_to_exception_family(): + assert isinstance(parse_gateway_error(401, {}), GatewayAuthError) + assert isinstance(parse_gateway_error(403, {}), GatewayAuthError) + assert isinstance(parse_gateway_error(404, {}), GatewayUnavailable) + assert isinstance(parse_gateway_error(409, {}), IdempotencyConflict) + err = parse_gateway_error(500, {}) + assert type(err) is ToolGatewayError + assert err.retryable is True + assert parse_gateway_error(400, {}).retryable is False + + +def test_envelope_parser_reads_nested_envelope_and_is_total_on_garbage(): + err = parse_gateway_error( + 409, + {"error": {"code": "IDEMPOTENCY_CONFLICT", "message": "key reused"}, "requestId": "req_1"}, + ) + assert err.code == "IDEMPOTENCY_CONFLICT" + assert err.request_id == "req_1" + assert str(err) == "key reused" + # garbage bodies never raise + for body in (None, "plain text", 42, ["list"]): + assert isinstance(parse_gateway_error(502, body), ToolGatewayError) + + +# --------------------------------------------------------------------------- +# config gate +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw,expected", + [ + (None, True), # absent -> default enabled + (True, True), + (False, False), + ({"enabled": True}, True), + ({"enabled": False}, False), + ({"enabled": "false"}, False), + ({"enabled": "yes"}, True), + ({}, True), + ("garbage", True), # unknown shape -> default, never raises + ], +) +def test_connector_config_from_raw(raw, expected): + assert ConnectorConfig.from_raw(raw).enabled is expected + + +def test_connectors_available_requires_both_legs_and_fails_closed(): + on = lambda: ConnectorConfig(enabled=True) + off = lambda: ConnectorConfig(enabled=False) + assert connectors_available(config_loader=on, entitlement_check=lambda: True) is True + assert connectors_available(config_loader=on, entitlement_check=lambda: False) is False + assert connectors_available(config_loader=off, entitlement_check=lambda: True) is False + + def boom(): + raise RuntimeError("portal exploded") + + assert connectors_available(config_loader=on, entitlement_check=boom) is False + assert connectors_available(config_loader=boom, entitlement_check=lambda: True) is False diff --git a/tests/tools/test_tool_search_multiquery.py b/tests/tools/test_tool_search_multiquery.py index ec5fc6e353..0f45c31e47 100644 --- a/tests/tools/test_tool_search_multiquery.py +++ b/tests/tools/test_tool_search_multiquery.py @@ -241,7 +241,8 @@ class TestCatalogRanking: assert search_catalog(catalog, "list", limit=1) == [catalog[0]] def test_precomputed_corpus_stats_preserve_results(self, issue_defs): - from tools.tool_search import _corpus_stats, build_catalog, search_catalog + from tools.tool_search import build_catalog, search_catalog + from tools.tool_search_catalog import _corpus_stats catalog = build_catalog(issue_defs) expected = search_catalog(catalog, "create issues", limit=3) diff --git a/tools/connections_tool.py b/tools/connections_tool.py new file mode 100644 index 0000000000..b8e573c375 --- /dev/null +++ b/tools/connections_tool.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +"""Manage remote connector accounts served through the tool gateway. + +``manage_connections`` is the never-deferred surface for connection +lifecycle: + +- ``status`` β€” which connectors exist for this account and whether each is + connected (read-only). +- ``connect`` / ``reconnect`` β€” start (or restart) an authorization flow. + The gateway returns a connect link, passed through UN-redacted: the model + shows it to the user, who opens it in a browser. Each connector's + ``instruction`` text is surfaced once per session, not on every call. +- ``wait`` β€” block inside the call until the named connectors report + connected, or the budget runs out. A model has no clock: told to wait it + says "I'll check back in a minute" and its next action lands immediately, + so guidance produced a burst of polls rather than a paced one. Waiting + inside the call cannot be skipped and works the same on every platform. + +Scope: gateway connectors ONLY. Local MCP servers stay with ``setup_mcp``, +which still exists and still works. An earlier draft folded ``install`` / +``enable`` / ``authorize`` in here, but the desktop consent card arrives +through a per-tool interception branch keyed on the name ``setup_mcp`` +(agent/tool_executor.py, agent/agent_runtime_helpers.py) and +``registry.dispatch`` never forwards a ``callback``. So the fold could only +ever return the "use the terminal" fallback while its schema advertised the +consent flow β€” a promise with no delivery path. + +De-authentication is deliberately NOT exposed to the model: disconnecting +an account is a user decision, made in the portal dashboard. + +Availability: gated by the portal sign-in the managed tools already use +(``check_fn``), so signed-out sessions see exactly today's behavior. +""" + +import json +import logging +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +from tools.registry import registry, tool_error + +logger = logging.getLogger(__name__) + +_CONNECTOR_ACTIONS = ("status", "connect", "reconnect", "wait") + +# (session_id, connector) pairs whose `instruction` text has already been +# shown. Keyed per session, not per process: the gateway multiplexes many +# sessions through one process, and guidance suppressed for session A must +# still reach session B. Module-level dict + lock is the house idiom +# (browser_use `_pending_create_keys` precedent); an unknown session keys +# on "" and degrades to per-process, never crashes. +_seen_instructions: set = set() +_seen_instructions_lock = threading.Lock() + +# session_id -> {connector slug: monotonic time the connect call addressed +# it}. Same keying and same idiom as `_seen_instructions` above, for the same +# reason. Honest scope: this records the moment THIS TOOL handed the model a +# link (or confirmed the connector already active) β€” it cannot prove the model +# relayed the link to the user. Two guards ride on it: a wait for a connector +# this session never addressed at all is refused (nothing to wait for), and a +# wait arriving within seconds of the mint β€” the connect-and-wait-in-one-batch +# shape, where no message with the links can have reached the user yet β€” is +# bounced as a never-error nudge instead of a silent multi-minute block. +_rendered_links: Dict[str, Dict[str, float]] = {} +_rendered_links_lock = threading.Lock() + +# The just-minted window: a wait that begins this soon after its links were +# minted can only come from the same tool batch (a real turn boundary costs a +# model round trip). Bounced, not served β€” the user has not seen the links. +_LINKS_JUST_MINTED_SECONDS = 2.0 + +# How long a `wait` runs by default, and the bounds it is clamped into. The +# floor keeps a wait long enough to be worth the round trip; the ceiling keeps +# one call inside a span a user reads as "a moment" β€” the model can always ask +# and wait again. Honest scope: the budget bounds the loop's own decisions +# (sleeps, and whether another poll starts); a poll that has already begun +# runs to the transport's own per-request timeout, so a hung gateway can +# overrun the ceiling by one poll's worth. +_WAIT_DEFAULT_SECONDS = 120.0 +_WAIT_MIN_SECONDS = 5.0 +_WAIT_MAX_SECONDS = 180.0 +# The gap between polls, and so the notice delay on a connector coming live. +# Every poll is a live `v1/connectors` read β€” nothing is cached, because the +# whole point of the loop is to see a change made outside this process. +_POLL_GAP_SECONDS = 5.0 +# The budget is counted as it is spent β€” the waits plus the time each poll +# actually takes β€” rather than read off a wall clock. A slow gateway therefore +# costs polls instead of overrunning the call, and the loop stays testable +# without a fake clock. +# +# Waits are taken in slices so they stay answerable. Nothing outside a tool can +# end a call that has already started β€” the executor only checks for an +# interrupt between tools β€” so a tool that blocks this long watches the flag +# itself, and touches the activity heartbeat so the gateway's inactivity +# timeout does not kill the session underneath it. +_POLL_WAIT_SLICE_SECONDS = 1.0 + +_WAIT_UNFINISHED_NOTE = ( + "This is NOT an error: the user simply has not finished connecting yet. " + "ASK THE USER what they want to do β€” keep waiting (call wait again), " + "continue without the pending apps, or get fresh connect links (action " + "'connect'). Do not retry silently and do not treat the pending apps as " + "broken." +) + + +def _connectors_available() -> bool: + try: + from tools.tool_gateway.config import connectors_available + + return connectors_available() + except Exception: + return False + + +def _default_client(): + from tools.tool_gateway.client import ConnectorClient + + return ConnectorClient() + + +def _clamp_timeout(raw: Any) -> tuple: + """Return (seconds, note). *note* is None unless the ask was clamped.""" + try: + asked = _WAIT_DEFAULT_SECONDS if raw is None else float(raw) + except (TypeError, ValueError): + return _WAIT_DEFAULT_SECONDS, None + if asked > _WAIT_MAX_SECONDS: + return _WAIT_MAX_SECONDS, ( + f"timeout_seconds was capped at {int(_WAIT_MAX_SECONDS)}s " + f"(asked for {asked:g}s). Call wait again to keep waiting." + ) + if asked < _WAIT_MIN_SECONDS: + return _WAIT_MIN_SECONDS, ( + f"timeout_seconds was raised to the {int(_WAIT_MIN_SECONDS)}s " + f"minimum (asked for {asked:g}s)." + ) + return asked, None + + +def _rendered_for( + session_id: Optional[str], rendered: Dict[str, Dict[str, float]] +) -> Dict[str, float]: + with _rendered_links_lock: + return dict(rendered.get(str(session_id or ""), {})) + + +def _record_rendered( + session_id: Optional[str], + connector: str, + rendered: Dict[str, Dict[str, float]], + *, + never_fresh: bool = False, +) -> None: + # Lowercased on the way in: the wait matcher and the input path both + # lowercase, and a case drift here would turn into a permanent refusal. + # `never_fresh` records a connector with no link to show (already active): + # membership holds, but the just-minted bounce can never fire for it. + stamp = float("-inf") if never_fresh else time.monotonic() + with _rendered_links_lock: + rendered.setdefault(str(session_id or ""), {})[connector.lower()] = stamp + + +def _wait_between_polls(seconds: float, activity_state: Dict[str, Any]) -> bool: + """Hold the call open until the next poll; False if the user interrupted.""" + from tools.interrupt import is_interrupted + + try: + from tools.environments.base import touch_activity_if_due + except Exception: + touch_activity_if_due = None + + remaining = seconds + while remaining > 0: + if is_interrupted(): + return False + if touch_activity_if_due is not None: + try: + touch_activity_if_due(activity_state, "waiting for connections") + except Exception: + pass + this_slice = min(_POLL_WAIT_SLICE_SECONDS, remaining) + time.sleep(this_slice) + remaining -= this_slice + return True + + +def _wait_for_connections( + client: Any, + connectors: List[str], + *, + timeout_seconds: float, + timeout_note: Optional[str], +) -> str: + """Poll the gateway until the named connectors are live, or time runs out. + + Never reports a wait outcome as an error. A connector the user has not + finished authorizing is an ordinary, expected state β€” the model's next move + is a question to the user, not a repair. + """ + wanted = set(connectors) + activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()} + spent = 0.0 + live_entries: List[Dict[str, Any]] = [] + pending: List[str] = list(connectors) + + def result(status: str, note: str) -> str: + payload: Dict[str, Any] = { + "status": status, + "connectors": live_entries, + "pending": pending, + "note": note, + } + if timeout_note: + payload["timeout_note"] = timeout_note + return json.dumps(payload, ensure_ascii=False) + + consecutive_errors = 0 + + while True: + poll_started = time.monotonic() + try: + items = client.list_connectors() + except Exception: + # A transient gateway blip costs one poll, never the whole wait. + # Three in a row means the gateway is genuinely down mid-wait β€” + # still not the model's error: report what the last good poll saw + # and hand the decision back, same as a timeout. + spent += time.monotonic() - poll_started + consecutive_errors += 1 + if consecutive_errors >= 3: + return result( + "timeout", + "The connector gateway stopped answering while waiting; " + "still not confirmed: " + ", ".join(pending) + ". " + + _WAIT_UNFINISHED_NOTE, + ) + if spent >= timeout_seconds: + return result( + "timeout", + f"Waited about {int(spent)}s; still not connected: " + f"{', '.join(pending)}. " + _WAIT_UNFINISHED_NOTE, + ) + gap = min(_POLL_GAP_SECONDS, timeout_seconds - spent) + if not _wait_between_polls(gap, activity_state): + return result( + "interrupted", + "The user interrupted the wait; still not connected: " + f"{', '.join(pending)}. " + _WAIT_UNFINISHED_NOTE, + ) + spent += gap + continue + consecutive_errors = 0 + spent += time.monotonic() - poll_started + + live_entries = [] + live_slugs = set() + for item in items or (): + if not isinstance(item, dict): + continue + slug = str(item.get("connector", "")).lower() + if slug in wanted and item.get("connected"): + live_entries.append(item) + live_slugs.add(slug) + pending = [c for c in connectors if c not in live_slugs] + + if not pending: + return result( + "connected", + "All requested apps are connected. Go ahead and use them.", + ) + if spent >= timeout_seconds: + return result( + "timeout", + f"Waited about {int(spent)}s; still not connected: " + f"{', '.join(pending)}. " + _WAIT_UNFINISHED_NOTE, + ) + # The last gap before the budget line is the REMAINDER, not a full + # gap: requiring room for a whole gap silently halved short asks (a 6s + # ask returned after one poll and zero sleep) and undershot every + # budget by one gap (120s asks exited near 115s). + gap = min(_POLL_GAP_SECONDS, timeout_seconds - spent) + if not _wait_between_polls(gap, activity_state): + # Interrupted mid-wait: answer with what the last poll saw rather + # than spending a round trip the user has just asked us to stop for. + return result( + "interrupted", + "The user interrupted the wait; still not connected: " + f"{', '.join(pending)}. " + _WAIT_UNFINISHED_NOTE, + ) + spent += gap + + +def manage_connections( + args: Dict[str, Any], + *, + client_factory: Optional[Callable[[], Any]] = None, + seen_instructions: Optional[set] = None, + rendered_links: Optional[Dict[str, Dict[str, float]]] = None, + session_id: Optional[str] = None, +) -> str: + """Dispatch one ``manage_connections`` action. Returns a JSON string.""" + action = str(args.get("action") or "status").strip().lower() + + if action not in _CONNECTOR_ACTIONS: + return tool_error( + f"action must be one of {', '.join(_CONNECTOR_ACTIONS)}. " + "Local MCP servers are set up with setup_mcp, not here. " + "Disconnecting an account is done by the user in the Nous Portal " + "dashboard, not through this tool." + ) + + raw_connectors = args.get("connectors") + if isinstance(raw_connectors, str): + raw_connectors = [raw_connectors] + connectors: List[str] = [] + if isinstance(raw_connectors, list): + for c in raw_connectors: + c = str(c or "").strip().lower() + if c and c not in connectors: + connectors.append(c) + + try: + client = (client_factory or _default_client)() + if action == "status": + items = client.list_connectors() + if connectors: + wanted = set(connectors) + items = [i for i in items if str(i.get("connector", "")).lower() in wanted] + return json.dumps( + { + "connectors": items, + "hint": ( + "connected=false means calls to that connector will return " + "CONNECTION_REQUIRED. Use action 'connect' to get an " + "authorization link for the user." + ), + }, + ensure_ascii=False, + ) + + if not connectors: + return tool_error( + f"'{action}' requires 'connectors': the connector slugs to authorize " + "(e.g. [\"gmail\"]). Use action 'status' to list them." + ) + + rendered = rendered_links if rendered_links is not None else _rendered_links + if action == "wait": + shown = _rendered_for(session_id, rendered) + never_shown = [c for c in connectors if c not in shown] + if never_shown: + return tool_error( + "wait refused: this session never obtained a connect link " + f"for {', '.join(never_shown)}, so there is nothing to " + "wait for. Call action 'connect' for those connectors first " + "and put the links in front of the user, then wait." + ) + just_minted = [ + c + for c in connectors + if time.monotonic() - shown[c] < _LINKS_JUST_MINTED_SECONDS + ] + if just_minted: + # The connect that minted these links ran moments ago β€” same + # tool batch, so no message carrying them has reached the user + # yet. Bounce (never an error) instead of blocking a spinner. + return json.dumps( + { + "status": "pending", + "connectors": [], + "pending": list(connectors), + "note": ( + "Not waiting yet: the connect links for " + f"{', '.join(just_minted)} were minted moments ago, " + "in this same turn β€” the user has not seen them. " + "Send your message showing the links FIRST, then " + "call wait on your next turn." + ), + }, + ensure_ascii=False, + ) + timeout_seconds, timeout_note = _clamp_timeout(args.get("timeout_seconds")) + return _wait_for_connections( + client, + connectors, + timeout_seconds=timeout_seconds, + timeout_note=timeout_note, + ) + + response = client.connections(connectors, reinitiate=(action == "reconnect")) + seen = seen_instructions if seen_instructions is not None else _seen_instructions + results = [] + for entry in response.get("results", []): + connector = str(entry.get("connector") or "") + out: Dict[str, Any] = { + "connector": connector, + "status": entry.get("status"), + } + if entry.get("connect_url"): + out["connect_url"] = entry["connect_url"] + out["note"] = ( + "Show this link to the user; they open it in a browser to " + "authorize. Then use action 'wait' to hold for the " + "connection instead of guessing when they are done." + ) + _record_rendered(session_id, connector, rendered) + elif entry.get("status") == "active": + # Already authorized: the gateway mints no link for a live + # connection. This connector is still ADDRESSED by this call β€” + # record it, or the documented connect-then-wait sequence + # refuses on the success case and loops the model through + # fresh mints that can never fill the record. + out["note"] = "Already connected β€” no link needed." + # never_fresh: there is no link the user must see before a + # wait, so the just-minted bounce must not fire for this one β€” + # an immediate wait legitimately returns connected on poll one. + _record_rendered(session_id, connector, rendered, never_fresh=True) + instruction = entry.get("instruction") + if instruction: + seen_key = (str(session_id or ""), connector) + with _seen_instructions_lock: + if seen_key not in seen: + seen.add(seen_key) + out["instruction"] = instruction + results.append(out) + return json.dumps( + {"results": results, "summary": response.get("summary", {})}, + ensure_ascii=False, + ) + except Exception as exc: + # Registered tools go through the registry's catch-wrap, but keep the + # message model-actionable rather than a raw traceback. + logger.debug("manage_connections %s failed: %s", action, exc) + return tool_error( + f"The connector gateway request failed: {exc}. " + "If this persists, the user can manage connections in the Nous Portal." + ) + + +MANAGE_CONNECTIONS_SCHEMA = { + "name": "manage_connections", + "description": ( + "Manage remote connector accounts (Gmail, Linear, Notion, ...) served " + "through the tool gateway. Actions: " + "'status' lists connectors and whether each is connected; 'connect' " + "starts an authorization for the given connectors and returns a link " + "for the USER to open in a browser (never open it yourself); " + "'reconnect' restarts a broken authorization; " + "'wait' blocks until the given connectors report connected. Pass " + "SEVERAL slugs in one call to get all authorization links at once. " + "When a connector tool " + "call returns CONNECTION_REQUIRED, use 'connect' and show the link. " + "Send the message that shows the user the links FIRST; on your NEXT " + "turn call 'wait' with those same slugs instead of guessing when the " + "user is done β€” it polls for you (a wait in the same turn as the " + "connect is bounced, because the user cannot have seen the links " + "yet). 'wait' requires 'connectors', and only accepts connectors this " + "session already addressed with 'connect' (already-connected apps " + "count). A 'timeout' or 'interrupted' result is NOT an " + "error: the user has not finished connecting, so ask them whether to " + "keep waiting, continue without those apps, or get fresh links. " + "Local MCP servers are configured separately. " + "This tool can NOT disconnect, delete, or revoke an account β€” that is " + "deliberately user-only. When asked, say so and direct the user to " + "the Nous Portal (their org's Connectors page) or the desktop app." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": list(_CONNECTOR_ACTIONS), + "description": "Defaults to status.", + }, + "connectors": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Connector slugs. REQUIRED for connect, reconnect and wait " + "(e.g. [\"gmail\", \"linear\"]); optional filter for status." + ), + }, + "timeout_seconds": { + "type": "integer", + "description": ( + "For action 'wait' only: how long to hold the call open. " + f"Defaults to {int(_WAIT_DEFAULT_SECONDS)}, clamped to " + f"{int(_WAIT_MIN_SECONDS)}-{int(_WAIT_MAX_SECONDS)}. Ask for " + "more and the result carries a 'timeout_note' saying the cap " + "was applied; call wait again to keep waiting." + ), + }, + }, + "required": [], + }, +} + + +registry.register( + name="manage_connections", + toolset="connections", + schema=MANAGE_CONNECTIONS_SCHEMA, + # Registry dispatch does not re-run check_fn: enforce the off switch for + # stale schemas without rebuilding a conversation's cached tool list. + handler=lambda args, **kw: ( + manage_connections(args, session_id=kw.get("session_id")) + if _connectors_available() + else tool_error("Connectors are not available in this session.") + ), + check_fn=_connectors_available, + emoji="πŸ”—", +) diff --git a/tools/connector_search.py b/tools/connector_search.py new file mode 100644 index 0000000000..e32074f75f --- /dev/null +++ b/tools/connector_search.py @@ -0,0 +1,128 @@ +"""Connector (remote) leg of the tool-search bridge. + +Connector tools live on the managed tool gateway, not in the local registry. +``tool_search`` asks the gateway for hits per query and ranks them in the same +BM25 pass as local tools; ``tool_describe`` fetches their schemas by name. +Every failure path (signed out, config off, gateway dark, bad shapes) yields +empty results so local search behaves exactly as without connectors (D32). +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Iterable, List, Optional + +from tools.tool_gateway.names import format_connector_name, is_connector_name, vendor_slug_candidates +from tools.tool_search_catalog import CatalogEntry, _fn, _tokenize + +logger = logging.getLogger(__name__) + + +def connections_in_scope(tool_defs: Iterable[Dict[str, Any]]) -> bool: + """The session granted connections and its availability check passed.""" + return any(_fn(td).get("name") == "manage_connections" for td in tool_defs) + + +def _connector_entry(name: str, connector: str, slug: str, schema: Dict[str, Any]) -> CatalogEntry: + """A gateway hit as a catalog document, so it ranks in the same BM25 pass as local + tools. The search text is the connector name, the slug's words and the description: + the same fields a local entry indexes, so the rarest-token gate treats both alike.""" + description = str(schema.get("description") or "") + input_schema = schema.get("input_schema") + parameters = input_schema if isinstance(input_schema, dict) else {} + tool_def = {"type": "function", "function": { + "name": name, "description": description, "parameters": parameters}} + text = f"{connector} {slug.replace('_', ' ')} {description}" + return CatalogEntry(name=name, description=description, schema=tool_def, + source="connectors", source_name=connector, _tokens=_tokenize(text)) + + +def connector_entries_by_group( + queries: List[str], + connector_search: Optional[Any] = None, +) -> List[List[CatalogEntry]]: + """Remote connector hits for ``dispatch_tool_search`` as catalog entries, one list per + query, in the gateway's order. + + Correlation with the remote response is by ARRAY POSITION only: the wire ``index`` field + is 1-based vendor passthrough on the search route and is never read. + """ + per_query: List[List[CatalogEntry]] = [[] for _ in queries] + try: + if connector_search is None: + from tools.tool_gateway.bridge import connector_search_hits as connector_search + hits = connector_search([{"use_case": q} for q in queries]) or {} + schemas = hits.get("schemas") + groups = hits.get("results") + if not isinstance(schemas, dict) or not isinstance(groups, list): + return per_query + for position, group in enumerate(groups[: len(queries)]): + if not isinstance(group, dict): + continue + # Correlate by position, then verify the echoed use_case when the + # gateway provides one, never the wire index (NS-734). A + # mismatched echo means the response groups don't line up with + # our queries; drop the group rather than mis-attribute hits. + echoed = group.get("use_case") + if isinstance(echoed, str) and echoed and echoed != queries[position]: + continue + slugs = group.get("tools") if isinstance(group.get("tools"), list) else [] + picked: Dict[str, tuple[str, CatalogEntry]] = {} # name -> (slug, entry), gateway order + for slug in slugs: + schema = schemas.get(slug) + if not isinstance(schema, dict) or not schema.get("connector"): + continue # cannot compose a callable name without its connector + # Lowercase the connector half at composition: the search + # route leaks vendor-cased connector slugs for custom + # toolkits (live-verified 2026-08-25: connections say + # custom_nous_lab_deepwiki while schemas say + # CUSTOM_NOUS_LAB_DEEPWIKI in the SAME response), and the + # gateway's policy gates compare case-sensitively against + # the lowercase catalog form. Tool slugs stay verbatim. + # No-op once the gateway normalizes its own surface. + slug = str(slug) + connector = str(schema["connector"]).lower() + name = format_connector_name(connector, slug) + prior = picked.get(name) + if prior is not None and prior[0] != slug: + # Composition is not injective: GMAIL_X and a literal X on gmail + # both compose to connectors__gmail__X, and describe and execute + # decode that name to GMAIL_X first. Keep the twin the name + # reaches; describing the other under this name would run a + # different tool. + reaches = vendor_slug_candidates(connector, name.split("__", 2)[2])[0] + logger.warning("connector %s: vendor slugs %s and %s both compose to %s, which reaches %s", + connector, prior[0], slug, name, reaches) + if slug != reaches: + continue + elif prior is not None: + continue + picked[name] = (slug, _connector_entry(name, str(schema["connector"]), slug, schema)) + per_query[position] = [entry for _, entry in picked.values()] + except Exception: + logger.debug("connector search merge failed silently (D32)", exc_info=True) + return [[] for _ in queries] + return per_query + + +def remote_schemas_for( + names: List[str], + current_tool_defs: List[Dict[str, Any]], + connector_describe: Optional[Any] = None, +) -> Dict[str, Dict[str, Any]]: + """Schemas for the ``connectors__*`` names in ``names``, keyed by name, for + ``dispatch_tool_describe``. Empty when no connector names were asked for, when + connections are out of scope, or on any gateway failure; the caller then reports + those names as ``not_found``.""" + connector_names = [n for n in names if is_connector_name(n)] + if not connector_names or not connections_in_scope(current_tool_defs): + return {} + try: + if connector_describe is None: + from tools.tool_gateway.bridge import connector_describe + remote = connector_describe(connector_names) + if isinstance(remote, dict) and isinstance(remote.get("tools"), dict): + return remote["tools"] + except Exception: + logger.debug("connector describe merge failed silently (D32)", exc_info=True) + return {} diff --git a/tools/managed_gateway_auth.py b/tools/managed_gateway_auth.py new file mode 100644 index 0000000000..df503fc65b --- /dev/null +++ b/tools/managed_gateway_auth.py @@ -0,0 +1,78 @@ +"""First-party gateway bearer trust, ported from connectors-only 43608084f4.""" + +import logging +from typing import Callable, Optional +from urllib.parse import urlsplit + +from tools.managed_tool_gateway import build_vendor_gateway_url, read_nous_access_token + +logger = logging.getLogger(__name__) + + +def managed_gateway_origin() -> str: + """Origin for on-origin managed vendors and media uploads.""" + return build_vendor_gateway_url("tool") + + +def connector_gateway_origin() -> str: + """Separate connector deployment; honors CONNECTOR_GATEWAY_URL.""" + return build_vendor_gateway_url("connector") + + +def is_managed_nous_gateway_url( + url: object, + gateway_builder: Optional[Callable[[str], str]] = None, +) -> bool: + """True when ``url`` is on one of the first-party gateway origins we build. + + Both first-party surfaces count: the media/on-origin-vendor host + (:func:`managed_gateway_origin`) and the connectors host + (:func:`connector_gateway_origin`). Each is compared as an exact + ``(scheme, netloc)`` pair β€” never as a name or a domain suffix β€” so + ``evil-connector-gateway.nousresearch.com.attacker.dev`` and an ``http`` + downgrade of a real host both stay outside the set. + + Anything granting a URL extra trust β€” our bearer, reading files off disk to + upload β€” must gate on this, so an arbitrary URL can never inherit it. + """ + if not isinstance(url, str) or not url.strip(): + return False + + build_origin = gateway_builder or build_vendor_gateway_url + try: + expected = { + urlsplit(build_origin(label))[:2] + for label in ("tool", "connector") + } + actual = urlsplit(url.strip()) + except ValueError: + return False + + return bool(actual.scheme) and (actual.scheme, actual.netloc) in expected + + +def managed_gateway_auth_headers( + url: object, + gateway_builder: Optional[Callable[[str], str]] = None, + token_reader: Optional[Callable[[], Optional[str]]] = None, +) -> dict: + """Live auth headers for a managed gateway URL, or ``{}`` when not managed. + + Read fresh on every call rather than cached: a Nous access token expires + within the hour, and a long session would otherwise keep presenting a dead + bearer. Returns ``{}`` rather than raising when no token is available, so a + caller can report "sign in" instead of sending an unauthenticated request. + """ + if not is_managed_nous_gateway_url(url, gateway_builder): + return {} + + resolved_token_reader = token_reader or read_nous_access_token + try: + token = resolved_token_reader() + except Exception as exc: # pragma: no cover β€” defensive + logger.debug("Managed gateway token read failed for %s: %s", url, exc) + return {} + if not isinstance(token, str) or not token.strip(): + return {} + + return {"Authorization": f"Bearer {token.strip()}"} diff --git a/tools/mcp_tool_agent.py b/tools/mcp_tool_agent.py index 6942ee9e57..5e8af44da0 100644 --- a/tools/mcp_tool_agent.py +++ b/tools/mcp_tool_agent.py @@ -186,13 +186,21 @@ def _merge_preserving_prefix(current_defs: list, new_defs: list, registered_name """Fold a fresh tool snapshot into a live one without moving existing bytes. Ordered by ``current_defs`` (the cached request prefix): a name in both keeps its slot but takes the fresh schema; a name only in the live list is kept if still registered (``check_fn`` - flapped), else dropped; a name only in the fresh list is appended at the tail.""" + flapped), else dropped; a name only in the fresh list is appended at the tail. + + The bridge tools keep their BUILT entry, not the fresh one: ``tool_search``'s description + is derived from the session (deferred count, listing, whether ``manage_connections`` was + present), so a late MCP server or a ``check_fn`` flap would rewrite it every turn. Search + reads the live catalog at dispatch, so the stale count costs nothing.""" + from tools.tool_search_catalog import BRIDGE_TOOL_NAMES fresh = {_def_name(entry): entry for entry in new_defs if _def_name(entry)} merged = [] for entry in current_defs: name = _def_name(entry) replacement = fresh.pop(name, None) - if replacement is not None: + if name in BRIDGE_TOOL_NAMES: + merged.append(entry) + elif replacement is not None: merged.append(replacement) elif name and name in registered_names: merged.append(entry) diff --git a/tools/tool_gateway/__init__.py b/tools/tool_gateway/__init__.py new file mode 100644 index 0000000000..d0d59b7a2e --- /dev/null +++ b/tools/tool_gateway/__init__.py @@ -0,0 +1,73 @@ +"""Connector tool-gateway package: typed client-side plumbing for remote tools. + +This package owns everything hermes-agent needs to talk to the managed tool +gateway's connector routes (search / schemas / execute / connections) and to +merge remote execute results back into ``tool_call`` result arrays. + +Layering rules (enforced by review, not imports β€” keep them true): + +- ``wire.py`` is a leaf: pydantic v2 models for the four gateway routes plus + route path constants. No pydantic model may escape the wire/client layer; + everything above sees plain dicts and frozen dataclasses. +- ``errors.py`` is stdlib-only: the ``ToolGatewayError`` family, the ONE + gateway error-envelope parser, and the single producer of the + connection-required payload shown to the model. +- ``names.py`` is stdlib-only: the ``connectors____`` name + codec. Parsing never raises β€” a malformed name is a per-entry error and + sibling calls still run. +- ``config.py``: the ``tools.connectors`` config gate. Availability fails + closed: config flag AND the managed Nous tools entitlement. +- ``merge.py`` is PURE: partition / splice / render with no I/O and no + exceptions. Position in the original ``calls[]`` array is the only + correlation key β€” the wire ``index`` field is never trusted. +- ``client.py`` / ``bridge.py``: the HTTP client and the only module core + imports. Every bridge entry point is TOTAL β€” it catches its own + exceptions, because the bridge branch bypasses the registry's catch-wrap. + Approval is settled by the core BEFORE the bridge is called; denied + entries never reach it. + +Core reaches this package through ``model_tools_connectors.py``, which +dispatches one gateway request per connector entry via ``bridge.run_remote`` +and re-enters core dispatch for each entry so per-tool policy fires against +the composed ``connectors__`` name. +""" + +from tools.tool_gateway.config import ( + MAX_CALLS_PER_DISPATCH, + ConnectorConfig, + connectors_available, +) +from tools.tool_gateway.errors import ( + GatewayAuthError, + GatewayUnavailable, + IdempotencyConflict, + ToolGatewayError, + parse_gateway_error, + render_connection_required, +) +from tools.tool_gateway.names import ( + CONNECTOR_BATCH_SENTINEL, + CONNECTOR_NAME_PREFIX, + ConnectorName, + format_connector_name, + is_connector_name, + parse_connector_name, +) + +__all__ = [ + "CONNECTOR_BATCH_SENTINEL", + "CONNECTOR_NAME_PREFIX", + "ConnectorConfig", + "ConnectorName", + "GatewayAuthError", + "GatewayUnavailable", + "IdempotencyConflict", + "MAX_CALLS_PER_DISPATCH", + "ToolGatewayError", + "connectors_available", + "format_connector_name", + "is_connector_name", + "parse_connector_name", + "parse_gateway_error", + "render_connection_required", +] diff --git a/tools/tool_gateway/bridge.py b/tools/tool_gateway/bridge.py new file mode 100644 index 0000000000..e757bbd185 --- /dev/null +++ b/tools/tool_gateway/bridge.py @@ -0,0 +1,227 @@ +"""The one module core code imports for connector dispatch. + +Two legs, both TOTAL: each entry point catches its own exceptions and returns +a structured value, because the bridge branch in core dispatch bypasses the +registry's catch-wrap. An exception escaping this module is a bug. + +Availability leg: :func:`connector_search_hits` and :func:`connector_describe` +feed tool_search and tool_describe. Silent degradation (D32): every failure +returns ``{}``. Signed out, config off, or a dark gateway must leave local +search behaving exactly as it does today. + +Transport leg: :func:`run_remote` sends one gateway execute request for the +planned entries it is handed and splices the results back by slot. +``model_tools_connectors`` calls it once per connector entry, after core +dispatch has already run scope, hook, approval and middleware policy against +that entry's composed ``connectors__`` name. Vendor slug restoration and the +single literal-slug retry live here; partition and envelope assembly live in +``merge.py``. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace as dataclass_replace +from typing import Any, Callable, Optional, Sequence + +from tools.tool_gateway.config import connectors_available +from tools.tool_gateway.errors import GatewayUnavailable, ToolGatewayError +from tools.tool_gateway.merge import fill_remote_failure, splice_remote_results +from tools.tool_gateway.names import parse_connector_name, vendor_slug_candidates + +logger = logging.getLogger(__name__) + +__all__ = ["connector_describe", "connector_search_hits", "run_remote"] + + +def _default_client_factory(): + from tools.tool_gateway.client import ConnectorClient + + return ConnectorClient() + + +def connector_search_hits( + queries: Sequence[dict[str, Any]], + *, + availability: Optional[Callable[[], bool]] = None, + client_factory: Optional[Callable[[], Any]] = None, +) -> dict[str, Any]: + """Remote hits for tool_search, or ``{}`` on EVERY failure path (D32). + + A connector problem must never change local search behavior: the caller + treats ``{}`` as "no remote results" and proceeds exactly as today. + """ + try: + available = (availability or connectors_available)() + if not available or not queries: + return {} + client = (client_factory or _default_client_factory)() + return client.search(list(queries)) or {} + except GatewayUnavailable: + # Connectors dark for this principal β€” the expected quiet path. + logger.debug("Connector search skipped: gateway dark") + return {} + except Exception as exc: + logger.debug("Connector search failed silently (D32): %s", exc) + return {} + + +def connector_describe( + names: Sequence[str], + *, + availability: Optional[Callable[[], bool]] = None, + client_factory: Optional[Callable[[], Any]] = None, +) -> dict[str, Any]: + """Schemas for ``connectors__*`` names, or ``{}`` on EVERY failure path (D32). + + Returns ``{"tools": {: {"description", "parameters"}}}`` + keyed by the ORIGINAL composed names. Names the gateway does not resolve + are simply absent β€” the caller's not_found handling covers them. The + gateway's schemas route takes bare vendor slugs, so every deterministic + recovery candidate is requested; mapping back to the ``connectors__`` + name uses the caller's own parse, never the response. + """ + try: + available = (availability or connectors_available)() + if not available: + return {} + # Candidate sets from different names can nominate the SAME vendor + # slug (one name's literal is another's prefixed primary), so the + # slug->name mapping cannot be global. Resolution is per name: each + # name takes the schema of its own best-ranked candidate that the + # gateway resolved. First occurrence wins only for a DUPLICATED + # composed name. + wanted: dict[str, tuple[str, ...]] = {} + request_slugs: list[str] = [] + for name in names: + parsed = parse_connector_name(name) + if parsed is None or parsed.raw in wanted: + continue + candidates = vendor_slug_candidates(parsed.connector, parsed.tool) + wanted[parsed.raw] = candidates + for slug in candidates: + if slug not in request_slugs: + request_slugs.append(slug) + if not wanted: + return {} + client = (client_factory or _default_client_factory)() + response = client.schemas(request_slugs) or {} + schemas = response.get("schemas") if isinstance(response.get("schemas"), dict) else {} + tools: dict[str, Any] = {} + for composed, candidates in wanted.items(): + schema = next( + (schemas[slug] for slug in candidates + if isinstance(schemas.get(slug), dict)), + None, + ) + if schema is None: + continue + tools[composed] = { + "description": str(schema.get("description") or ""), + "parameters": schema.get("input_schema") or {}, + } + return {"tools": tools} + except GatewayUnavailable: + logger.debug("Connector describe skipped: gateway dark") + return {} + except Exception as exc: + logger.debug("Connector describe failed silently (D32): %s", exc) + return {} + + +def run_remote( + planned, + dispatch_id: Optional[str], + *, + availability: Optional[Callable[[], bool]], + client_factory: Optional[Callable[[], Any]], +) -> list[dict[str, Any]]: + try: + available = (availability or connectors_available)() + except Exception: + available = False + if not available: + # The model addressed connector names while connectors are off/dark β€” + # per-entry unknown-tool errors, exactly like any unknown tool name. + return fill_remote_failure( + planned, + "Unknown tool: connectors are not available in this session.", + code="TOOL_NOT_FOUND", + ) + + # Composition cuts only the conventional toolkit prefix. Restore that + # exact prefix before crossing the wire; literal recovery below covers + # the convention's exceptions without probing entries that succeeded. + wire_planned = [ + dataclass_replace( + plan, + tool=vendor_slug_candidates(plan.connector, plan.tool)[0], + ) + for plan in planned + ] + try: + client = (client_factory or _default_client_factory)() + remote_results = client.execute(wire_planned) + entries = splice_remote_results(planned, remote_results) + except ToolGatewayError as exc: + logger.debug( + "Connector execute for dispatch %s failed (%s): %s", + dispatch_id, + exc.code, + exc, + ) + return fill_remote_failure( + planned, f"The connector gateway request failed: {exc}" + ) + except Exception as exc: + logger.warning( + "Connector execute for dispatch %s failed unexpectedly: %s", + dispatch_id, + exc, + ) + return fill_remote_failure( + planned, "The connector gateway request failed unexpectedly." + ) + + fallback_slots: list[int] = [] + fallback_planned = [] + for slot, (plan, entry) in enumerate(zip(planned, entries)): + primary, literal = vendor_slug_candidates(plan.connector, plan.tool) + error = entry.get("error") if isinstance(entry, dict) else None + if ( + primary != literal + and isinstance(error, dict) + and error.get("code") == "TOOL_NOT_FOUND" + ): + fallback_slots.append(slot) + fallback_planned.append(dataclass_replace(plan, tool=literal)) + if not fallback_planned: + return entries + + # One literal pass only: retry confirmed misses together, then splice + # those slots alone so successful and non-not-found siblings stay fixed. + try: + fallback_results = client.execute(fallback_planned) + fallback_entries = splice_remote_results(fallback_planned, fallback_results) + except ToolGatewayError as exc: + logger.debug( + "Connector execute fallback for dispatch %s failed (%s): %s", + dispatch_id, + exc.code, + exc, + ) + fallback_entries = fill_remote_failure( + fallback_planned, f"The connector gateway request failed: {exc}" + ) + except Exception as exc: + logger.warning( + "Connector execute fallback for dispatch %s failed unexpectedly: %s", + dispatch_id, + exc, + ) + fallback_entries = fill_remote_failure( + fallback_planned, "The connector gateway request failed unexpectedly." + ) + for slot, fallback_entry in zip(fallback_slots, fallback_entries): + entries[slot] = fallback_entry + return entries diff --git a/tools/tool_gateway/client.py b/tools/tool_gateway/client.py new file mode 100644 index 0000000000..e0b6f20291 --- /dev/null +++ b/tools/tool_gateway/client.py @@ -0,0 +1,306 @@ +"""HTTP client for the connector routes on the managed tool gateway. + +Constructed per dispatch β€” a portal access token expires within the hour, so +auth headers are read fresh on every call (the ``managed_gateway_auth_headers`` +idiom). Sync ``requests`` on purpose: the bridge branch cannot reach the +registry's async bridge, so every call here runs on the calling thread. + +Injectable seams (``transport`` / ``endpoint_resolver`` / ``header_provider``) +default to the real ones; tests inject fakes instead of patching modules. + +Retry policy (D29): at most ONE retry, on transport failure or 5xx only, +reusing the SAME ``x-idempotency-key``. The key is a local variable in +:meth:`ConnectorClient.execute` β€” one dispatch is one call frame, nothing +outside it ever retries the same dispatch, so scope guarantees same-key-on- +retry with no store to clean up. 409 means the key was reused with a +different body β€” always a client bug, never retried. + +Only the gateway's execute route supports idempotency keys, so only routes +that are safe to repeat are retried: search and schemas are read-only, and +execute dedupes on its key. The connections route is state-changing WITHOUT +dedup support (it starts or restarts an authorization flow), so it is never +retried automatically β€” a lost response surfaces as an error the caller can +deliberately re-ask. + +Wire models stay inside this module: callers receive plain dicts shaped for +``merge.splice_remote_results``. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, Callable, Optional, Protocol, Sequence + +import requests + +from tools.tool_gateway import wire +from tools.tool_gateway.errors import ( + GatewayAuthError, + GatewayUnavailable, + ToolGatewayError, + parse_gateway_error, +) +from tools.tool_gateway.merge import PlannedCall + +logger = logging.getLogger(__name__) + +__all__ = ["ConnectorClient", "Transport"] + +DEFAULT_TIMEOUT_SECONDS = 30.0 +# One execute request carries up to MAX_CALLS_PER_DISPATCH remote tool runs; +# measured batch latency is seconds, not minutes, but give slow tools room. +EXECUTE_TIMEOUT_SECONDS = 60.0 +# Search rides the availability path of EVERY tool_search once connectors are +# lit. A hung gateway degrades silently to local-only results, with no retry. +# Measured: one request with 6 use_cases takes about 7 s, so an 8 s budget sat +# on the edge and cut real answers off; 30 s tolerates a slow gateway and still +# bounds the wait. Schemas (tool_describe) is user-initiated; a short budget +# with one retry keeps its worst case at 2x this value. +SEARCH_TIMEOUT_SECONDS = 30.0 +SCHEMAS_TIMEOUT_SECONDS = 10.0 + +_MAX_RETRIES = 1 # D29: at most one retry, same key. + + +class Transport(Protocol): + """The slice of ``requests`` the client uses; tests inject a fake.""" + + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict] = None, + json: Optional[dict] = None, + timeout: Optional[float] = None, + ) -> Any: ... + + +def _default_transport() -> Transport: + return requests # module satisfies the protocol + + +def _default_endpoint_resolver() -> Optional[str]: + """The connectors origin, or ``None`` when none resolves (scheme misconfig). + + Asks the connectors host resolver directly. This used to go through + ``managed_vendor_endpoints("connectors")``, which invented a vendor that + does not exist β€” and then discarded the ``base_url``/``upload_path`` it + built for it. Connector routes are their own deployment's own paths + (``v1/connectors/*``) on its own host, not a vendor passthrough and not the + media host. + """ + from tools.managed_gateway_auth import connector_gateway_origin + + try: + return connector_gateway_origin() or None + except ValueError: + # Misconfigured TOOL_GATEWAY_SCHEME: there is no origin to call. + return None + + +def _default_header_provider(url: str) -> dict: + from tools.managed_gateway_auth import managed_gateway_auth_headers + + return managed_gateway_auth_headers(url) + + +class ConnectorClient: + """One dispatch's connection to the gateway's connector routes.""" + + def __init__( + self, + *, + transport: Optional[Transport] = None, + endpoint_resolver: Optional[Callable[[], Optional[str]]] = None, + header_provider: Optional[Callable[[str], dict]] = None, + ) -> None: + self._transport = transport or _default_transport() + self._endpoint_resolver = endpoint_resolver or _default_endpoint_resolver + self._header_provider = header_provider or _default_header_provider + + # -- routes --------------------------------------------------------- + + def search(self, queries: Sequence[dict[str, Any]]) -> dict[str, Any]: + """POST v1/connectors/search. Returns the response as a plain dict.""" + body = wire.ConnectorSearchRequest( + queries=[wire.ConnectorSearchQuery(**q) for q in queries] + ).model_dump(by_alias=True, exclude_none=True) + payload = self._post( + wire.CONNECTOR_SEARCH_PATH, body, + timeout=SEARCH_TIMEOUT_SECONDS, retries=0, + ) + parsed = wire.ConnectorSearchResponse.model_validate(payload) + return parsed.model_dump() + + def schemas(self, tools: Sequence[str]) -> dict[str, Any]: + """POST v1/connectors/schemas.""" + body = wire.ConnectorSchemasRequest(tools=list(tools)).model_dump( + by_alias=True + ) + payload = self._post( + wire.CONNECTOR_SCHEMAS_PATH, body, timeout=SCHEMAS_TIMEOUT_SECONDS + ) + return wire.ConnectorSchemasResponse.model_validate(payload).model_dump() + + def connections( + self, connectors: Sequence[str], *, reinitiate: bool = False + ) -> dict[str, Any]: + """POST v1/connectors/connections. Never auto-retried: this route + starts/restarts authorization flows and the gateway offers no dedup + key for it β€” a blind retry could double-submit a restart.""" + body = wire.ConnectorConnectionsRequest( + connectors=list(connectors), reinitiate=reinitiate + ).model_dump(by_alias=True) + payload = self._post(wire.CONNECTOR_CONNECTIONS_PATH, body, retries=0) + return wire.ConnectorConnectionsResponse.model_validate(payload).model_dump() + + def list_connectors(self) -> list[dict[str, Any]]: + """GET v1/connectors, following pagination. Read-only. + + Returns the raw item dicts (``{"connector", "enabled", "connected"}`` + today; tolerant of additions). The page size cap is the gateway's. + """ + items: list[dict[str, Any]] = [] + cursor: Optional[str] = None + for _ in range(20): # generous page bound; the catalog is small + path = f"{wire.CONNECTORS_PATH}?limit=50" + if cursor: + path += f"&cursor={cursor}" + payload = self._request("GET", path, None) + if not isinstance(payload, dict): + break + page = payload.get("items") + if isinstance(page, list): + items.extend(entry for entry in page if isinstance(entry, dict)) + cursor = payload.get("nextCursor") + if not cursor: + break + return items + + def execute(self, planned: Sequence[PlannedCall]) -> list[dict[str, Any]]: + """POST v1/connectors/execute β€” ONE request for the whole slice. + + Returns one dict per wire result, in wire order (slot ``i`` is the + response to request ``tools[i]``): ``{"data": ..., "error": None | + {code, message, connector, connect_url, hint}}``. Length mismatches + are the merge layer's problem, by design. + """ + body = wire.ConnectorExecuteRequest( + tools=[ + wire.ConnectorExecuteCall( + connector=plan.connector, tool=plan.tool, arguments=plan.arguments + ) + for plan in planned + ] + ).model_dump(by_alias=True) + # Local by design (no store): one dispatch = one call frame, and a + # transport retry below re-presents this same key by scope. + idempotency_key = str(uuid.uuid4()) + payload = self._post( + wire.CONNECTOR_EXECUTE_PATH, + body, + timeout=EXECUTE_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, + ) + parsed = wire.ConnectorExecuteResponse.model_validate(payload) + return [_result_dict(result) for result in parsed.results] + + # -- plumbing ------------------------------------------------------- + + def _post( + self, + path: str, + body: dict[str, Any], + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + idempotency_key: Optional[str] = None, + retries: int = _MAX_RETRIES, + ) -> Any: + return self._request( + "POST", path, body, + timeout=timeout, idempotency_key=idempotency_key, retries=retries, + ) + + def _request( + self, + method: str, + path: str, + body: Optional[dict[str, Any]], + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + idempotency_key: Optional[str] = None, + retries: int = _MAX_RETRIES, + ) -> Any: + origin = self._endpoint_resolver() + if not origin: + raise GatewayUnavailable( + "no tool gateway origin resolves", code="NO_ORIGIN" + ) + url = f"{origin.rstrip('/')}/{path}" + + last_error: Optional[ToolGatewayError] = None + for attempt in range(1 + retries): + headers = dict(self._header_provider(url)) + if not headers: + # No usable portal token; an unauthenticated request would + # only 401 β€” fail fast with the same meaning. + raise GatewayAuthError( + "no portal access token available", code="NO_TOKEN", status=401 + ) + headers["Content-Type"] = "application/json" + if idempotency_key: + headers["x-idempotency-key"] = idempotency_key + + try: + response = self._transport.request( + method, url, headers=headers, json=body, timeout=timeout + ) + except Exception as exc: + last_error = ToolGatewayError( + f"transport failure: {exc}", code="TRANSPORT_ERROR", retryable=True + ) + logger.debug( + "Connector %s attempt %d transport failure: %s", path, attempt + 1, exc + ) + continue + + status = int(getattr(response, "status_code", 0)) + if 200 <= status < 300: + return response.json() + + error = parse_gateway_error(status, _safe_json(response)) + if error.retryable and attempt < retries: + last_error = error + logger.debug( + "Connector %s attempt %d got %d; retrying with same key", + path, + attempt + 1, + status, + ) + continue + raise error + + assert last_error is not None # loop ran at least once + raise last_error + + +def _result_dict(result: wire.ConnectorExecuteResult) -> dict[str, Any]: + error = None + if result.error is not None: + error = {"code": result.error.code, "message": result.error.message} + if result.error.connector: + error["connector"] = result.error.connector + if result.error.connect_url: + error["connect_url"] = result.error.connect_url + if result.error.hint: + error["hint"] = result.error.hint + return {"data": result.data, "error": error} + + +def _safe_json(response: Any) -> Any: + try: + return response.json() + except Exception: + return getattr(response, "text", None) diff --git a/tools/tool_gateway/config.py b/tools/tool_gateway/config.py new file mode 100644 index 0000000000..f12ae70795 --- /dev/null +++ b/tools/tool_gateway/config.py @@ -0,0 +1,107 @@ +"""Configuration gate for connector tools. + +Mirrors the ``ToolSearchConfig`` idiom in ``tools/tool_search.py``: a frozen +dataclass built by a tolerant ``from_raw`` so a typo in user config degrades +to defaults instead of breaking the agent. + +Availability is a two-leg AND that fails closed: + connectors_available() = config flag AND managed_nous_tools_enabled() +The config flag is the user's off switch; the entitlement leg is the portal +sign-in every managed tool already gates on. The gateway remains authoritative: +404 routes degrade to local-only, and execution refusals reach the caller. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Callable, Optional + +logger = logging.getLogger(__name__) + +__all__ = [ + "MAX_CALLS_PER_DISPATCH", + "ConnectorConfig", + "connectors_available", + "load_config", +] + +# Bound the connector entries one tool_call dispatch may carry β€” the same +# constant family as tool_search's _MAX_QUERIES_PER_CALL. Context management, +# not a wire limit: the gateway's own batch cap (25) is unreachable from +# here by design, so there is no chunking code anywhere. +MAX_CALLS_PER_DISPATCH = 10 + +_FALSE_STRINGS = frozenset({"false", "0", "no", "off", ""}) + + +@dataclass(frozen=True) +class ConnectorConfig: + """Resolved ``tools.connectors`` configuration.""" + + enabled: bool = True + + @classmethod + def from_raw(cls, raw: Any) -> "ConnectorConfig": + """Build a config from a raw dict / bool / None. + + Tolerant by design: unknown shapes and garbage values fall back to + the default (enabled) rather than raising. The effective gate for + signed-out users is the entitlement leg, not this flag. + """ + if isinstance(raw, bool): + return cls(enabled=raw) + if isinstance(raw, dict): + return cls(enabled=_coerce_bool(raw.get("enabled"), True)) + return cls() + + +def _coerce_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if value is None: + return fallback + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() not in _FALSE_STRINGS + return fallback + + +def load_config() -> ConnectorConfig: + """Load connector config from the user config file.""" + try: + from hermes_cli.config import load_config_readonly as _load + + cfg = _load() or {} + tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {} + if not isinstance(tools_cfg, dict): + tools_cfg = {} + return ConnectorConfig.from_raw(tools_cfg.get("connectors")) + except Exception as e: + logger.debug("Failed to load connector config: %s", e) + return ConnectorConfig.from_raw(None) + + +def connectors_available( + config_loader: Optional[Callable[[], ConnectorConfig]] = None, + entitlement_check: Optional[Callable[[], bool]] = None, +) -> bool: + """True when connector routes may be attempted at all. Fails closed. + + Any exception in either leg counts as unavailable β€” this function is on + the tool_search availability path, where a connector problem must never + become a model-visible error. + """ + try: + resolved_loader = config_loader or load_config + if not resolved_loader().enabled: + return False + if entitlement_check is None: + from tools.tool_backend_helpers import managed_nous_tools_enabled + + entitlement_check = managed_nous_tools_enabled + return bool(entitlement_check()) + except Exception as e: + logger.debug("Connector availability check failed: %s", e) + return False diff --git a/tools/tool_gateway/errors.py b/tools/tool_gateway/errors.py new file mode 100644 index 0000000000..bc0706662c --- /dev/null +++ b/tools/tool_gateway/errors.py @@ -0,0 +1,141 @@ +"""Typed errors for the connector tool gateway, plus THE envelope parser. + +House idiom: one ``RuntimeError`` base with a small set of subclasses, one +per condition a caller actually branches on (``microsoft_graph_auth.py`` / +``image_source.py`` precedent). HTTP-level failures use the gateway's nested +error envelope ``{"error": {"code", "message", ...}, "requestId"}`` and are +parsed in exactly one place: :func:`parse_gateway_error`. + +Per-tool errors inside a 200 execute envelope are NOT exceptions β€” they are +result entries, rendered by ``merge.py`` (CONNECTION_REQUIRED payloads via +:func:`render_connection_required`, the single producer of the connect-link +dict shown to the model; the link is deliberately not redacted). + +stdlib-only: this module must not import pydantic or any sibling module. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +__all__ = [ + "GatewayAuthError", + "GatewayUnavailable", + "IdempotencyConflict", + "ToolGatewayError", + "parse_gateway_error", + "render_connection_required", +] + + +class ToolGatewayError(RuntimeError): + """A connector gateway request failed at the HTTP level. + + ``retryable`` encodes the retry policy decision (at most one retry, same + idempotency key, transport failures and 5xx only) so the client never + re-derives it from the status code. + """ + + def __init__( + self, + message: str, + *, + code: str = "GATEWAY_ERROR", + status: Optional[int] = None, + request_id: Optional[str] = None, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.code = code + self.status = status + self.request_id = request_id + self.retryable = retryable + + +class GatewayAuthError(ToolGatewayError): + """401/403 β€” the portal token is missing, expired, or not entitled.""" + + +class GatewayUnavailable(ToolGatewayError): + """404 from any connector route β€” connectors are dark for this principal. + + This is the silent-degradation signal: callers fall back to local-only + behavior and the model never sees a connector error. + """ + + +class IdempotencyConflict(ToolGatewayError): + """409 β€” the idempotency key was reused with a different body. + + Always a client bug; never retried. + """ + + +def parse_gateway_error(status: int, body: Any) -> ToolGatewayError: + """Parse an HTTP-level gateway failure into the right exception. + + The one place that understands the nested error envelope. Total: any + body shape (dict, text, ``None``) produces a usable exception rather + than raising. + """ + code = f"HTTP_{status}" + message = "" + request_id = None + if isinstance(body, Mapping): + envelope = body.get("error") + if isinstance(envelope, Mapping): + code = str(envelope.get("code") or code) + message = str(envelope.get("message") or "") + raw_request_id = body.get("requestId") + if raw_request_id is not None: + request_id = str(raw_request_id) + elif body: + message = str(body)[:500] + if not message: + message = f"tool gateway request failed with status {status}" + + kwargs = { + "code": code, + "status": status, + "request_id": request_id, + } + if status in (401, 403): + return GatewayAuthError(message, **kwargs) + if status == 404: + return GatewayUnavailable(message, **kwargs) + if status == 409: + return IdempotencyConflict(message, **kwargs) + return ToolGatewayError(message, retryable=status >= 500, **kwargs) + + +def render_connection_required( + *, + connector: Optional[str] = None, + message: Optional[str] = None, + connect_url: Optional[str] = None, + hint: Optional[str] = None, +) -> dict[str, Any]: + """Render the model-facing CONNECTION_REQUIRED payload. + + The single producer of this dict, shared by the execute merge and the + connections tool so the model always sees one shape. The connect link is + passed through un-redacted β€” the model is allowed to show it to the user. + Wording beyond a fallback message is the gateway's job; this function + does not invent instructions. + """ + payload: dict[str, Any] = { + "code": "CONNECTION_REQUIRED", + "message": message + or ( + f"The {connector} connector is not connected for this account." + if connector + else "This connector is not connected for this account." + ), + } + if connector: + payload["connector"] = connector + if connect_url: + payload["connect_url"] = connect_url + if hint: + payload["hint"] = hint + return payload diff --git a/tools/tool_gateway/merge.py b/tools/tool_gateway/merge.py new file mode 100644 index 0000000000..2b57eb7557 --- /dev/null +++ b/tools/tool_gateway/merge.py @@ -0,0 +1,258 @@ +"""Pure partition / splice / render for mixed tool_call batches. + +One ``tool_call`` invocation may mix local deferred tools and +``connectors____`` entries. This module owns the pure logic +around that: partitioning the original ``calls[]`` array, splicing remote +execute results back into place, and rendering the caller-facing result +entries. + +Contract: + +- PURE β€” no I/O, no imports above the stdlib + sibling leaf modules, and no + exceptions on any input shape. Malformed input becomes per-entry errors. +- Position in the ORIGINAL ``calls[]`` array is the only correlation key. + The wire ``index`` field is never read (execute is 0-based, search is + 1-based; trusting either is a known trap). +- A short or over-long remote response never raises: missing slots are + filled with ``PROVIDER_ERROR`` entries, surplus entries are dropped. +- Counts are recomputed over the merged array β€” the gateway's counts cover + only its slice. + +Caller-facing entry shape (mirrors the gateway's per-tool result discipline; +exactly one of ``response`` / ``error`` per entry): + + {"index": , "name": , "response": } + {"index": , "name": , "error": {code, message, ...}} +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Sequence + +from tools.tool_gateway.errors import render_connection_required +from tools.tool_gateway.names import parse_connector_name + +__all__ = [ + "Partition", + "PlannedCall", + "assemble_results", + "fill_remote_failure", + "partition_calls", + "render_remote_entry", + "splice_remote_results", +] + + +@dataclass(frozen=True) +class PlannedCall: + """A connector-bound entry, pinned to its position in the original array.""" + + position: int + name: str + connector: str + tool: str + arguments: dict[str, Any] + + +@dataclass(frozen=True) +class Partition: + """The original ``calls[]`` split by destination, positions preserved.""" + + # (position, call) for entries hermes dispatches locally. + local: tuple[tuple[int, Mapping[str, Any]], ...] + # Connector-bound entries, original order preserved: remote[i] becomes + # the gateway request's tools[i], which is also how responses correlate. + remote: tuple[PlannedCall, ...] + # Pre-rendered error entries for entries that route nowhere + # (malformed connector names). Siblings still run. + errors: tuple[dict[str, Any], ...] + + +def partition_calls(calls: Sequence[Any]) -> Partition: + """Split a ``calls[]`` array by destination. Total on any input. + + An entry routes to the gateway when its name parses as a connector + name. A name that claims the ``connectors__`` prefix but does not parse + becomes that entry's error; everything else is local. A top-level value + that is not a sequence partitions as empty. + """ + local: list[tuple[int, Mapping[str, Any]]] = [] + remote: list[PlannedCall] = [] + errors: list[dict[str, Any]] = [] + for position, call in enumerate(_as_sequence(calls)): + name = call.get("name") if isinstance(call, Mapping) else None + parsed = parse_connector_name(name) + if parsed is not None: + arguments = call.get("arguments") + remote.append( + PlannedCall( + position=position, + name=parsed.raw, + connector=parsed.connector, + tool=parsed.tool, + arguments=dict(arguments) if isinstance(arguments, Mapping) else {}, + ) + ) + continue + if isinstance(name, str) and name.startswith("connectors__"): + errors.append( + _error_entry( + position, + name, + code="TOOL_NOT_FOUND", + message=( + "Malformed connector tool name; expected " + "connectors____." + ), + ) + ) + continue + local.append((position, call if isinstance(call, Mapping) else {})) + return Partition(local=tuple(local), remote=tuple(remote), errors=tuple(errors)) + + +def render_remote_entry(planned: PlannedCall, remote: Mapping[str, Any]) -> dict[str, Any]: + """Render one gateway result (plain dict) into the caller-facing entry. + + ``remote`` is the client-layer dict for this slot: ``{"data": ..., + "error": None | {code, message, connector, connect_url, hint}}``. + CONNECTION_REQUIRED goes through the shared single-shape producer so the + connect link renders identically everywhere. + """ + error = remote.get("error") if isinstance(remote, Mapping) else None + if not isinstance(error, Mapping): + data = remote.get("data") if isinstance(remote, Mapping) else None + return {"index": planned.position, "name": planned.name, "response": data} + + code = str(error.get("code") or "PROVIDER_ERROR") + message = str(error.get("message") or "The gateway reported an error.") + if code == "CONNECTION_REQUIRED": + payload = render_connection_required( + connector=_opt_str(error.get("connector")) or planned.connector, + message=message, + connect_url=_opt_str(error.get("connect_url")), + hint=_opt_str(error.get("hint")), + ) + else: + payload = {"code": code, "message": message} + connector = _opt_str(error.get("connector")) + if connector: + payload["connector"] = connector + hint = _opt_str(error.get("hint")) + if hint: + payload["hint"] = hint + return {"index": planned.position, "name": planned.name, "error": payload} + + +def splice_remote_results( + planned: Sequence[PlannedCall], + remote_results: Optional[Sequence[Any]], +) -> list[dict[str, Any]]: + """Map gateway results back onto planned positions, by array slot only. + + ``remote_results[i]`` corresponds to ``planned[i]`` β€” the request was + built from ``planned`` in order. Missing slots (short response, or no + response at all) fill with ``PROVIDER_ERROR``; surplus slots have + nothing to correlate to and are dropped. A top-level value that is not + a sequence counts as no response at all. + """ + results = _as_sequence(remote_results) + entries: list[dict[str, Any]] = [] + for slot, plan in enumerate(_as_sequence(planned)): + if slot < len(results) and isinstance(results[slot], Mapping): + entries.append(render_remote_entry(plan, results[slot])) + else: + entries.append( + _error_entry( + plan.position, + plan.name, + code="PROVIDER_ERROR", + message="The gateway returned no result for this call.", + ) + ) + return entries + + +def fill_remote_failure( + planned: Sequence[PlannedCall], + message: str, + *, + code: str = "PROVIDER_ERROR", +) -> list[dict[str, Any]]: + """Render the same error into every planned slot. + + For request-level failures (the HTTP envelope path): every connector + entry in the batch gets the error, local siblings are untouched. + """ + return [ + _error_entry(plan.position, plan.name, code=code, message=message) + for plan in planned + ] + + +def assemble_results( + total: int, + *entry_groups: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Merge rendered entries back into original order and recompute counts. + + ``entry_groups`` are any number of entry lists (local, remote, partition + errors), each entry carrying its original position in ``index``. Slots + nothing claimed β€” a bug upstream, but this function is total β€” fill with + ``PROVIDER_ERROR``; duplicate claims keep the first and drop the rest. + """ + try: + slot_count = max(0, int(total)) + except (TypeError, ValueError): + slot_count = 0 + slots: list[Optional[dict[str, Any]]] = [None] * slot_count + for group in entry_groups: + for entry in _as_sequence(group): + if not isinstance(entry, Mapping): + continue + index = entry.get("index") + if isinstance(index, int) and 0 <= index < len(slots) and slots[index] is None: + slots[index] = dict(entry) + merged: list[dict[str, Any]] = [] + for position, entry in enumerate(slots): + if entry is None: + entry = _error_entry( + position, + "", + code="PROVIDER_ERROR", + message="No result was produced for this call.", + ) + merged.append(entry) + error_count = sum(1 for entry in merged if "error" in entry) + return { + "results": merged, + "success_count": len(merged) - error_count, + "error_count": error_count, + "total_count": len(merged), + } + + +def _error_entry(position: int, name: str, *, code: str, message: str) -> dict[str, Any]: + return { + "index": position, + "name": name, + "error": {"code": code, "message": message}, + } + + +def _as_sequence(value: Any) -> Sequence[Any]: + """Normalize a top-level input to a sequence; garbage becomes empty. + + str/bytes are excluded β€” iterating a stray string as a calls array + would fabricate one entry per character. + """ + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return value + return () + + +def _opt_str(value: Any) -> Optional[str]: + if isinstance(value, str) and value: + return value + return None diff --git a/tools/tool_gateway/names.py b/tools/tool_gateway/names.py new file mode 100644 index 0000000000..cbc1caab7b --- /dev/null +++ b/tools/tool_gateway/names.py @@ -0,0 +1,105 @@ +"""Codec for ``connectors____`` bridge names. + +Remote connector tools are never registered in the model-facing tools array; +the model addresses them through ``tool_call`` using composed names. Composition +strips the repeated toolkit prefix from vendor tool slugs; wire slugs are +reconstructed through :func:`vendor_slug_candidates`. This module is the ONLY +place that composes or parses those names β€” responses are correlated by array +position, never by re-parsing names. + +Parsing rules: + +- ``split("__", 2)`` exactly: tool slugs legitimately contain underscores + (``GMAIL_SEND_EMAIL``), so ``rsplit`` or an unbounded split would corrupt + them. +- Case is preserved: the gateway lowercases connector slugs itself, and the + tool segment is otherwise untouched. Prefix removal is exactly reversible, + including partial prefix matches such as ``granola`` + + ``GRANOLA_MCP_GET_MEETINGS``: decoding prepends exactly what encoding cut. +- :func:`parse_connector_name` returns ``None`` and never raises β€” a + malformed name is a per-entry error and sibling calls still run. +- Composition is deliberately NOT injective: ``GMAIL_X`` and a literal ``X`` + on connector ``gmail`` both compose to ``connectors__gmail__X``, and that + name decodes to the prefixed slug first everywhere (describe, execute), so + the literal twin is unreachable. Short names are worth more than a marker + for a pair no vendor catalog is known to carry, and the client cannot know + a vendor's slug set. Search, the one place that sees both twins, keeps the + reachable one and logs a WARNING instead of describing the literal under a + name that runs the prefixed tool. + +stdlib-only leaf module. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +__all__ = [ + "CONNECTOR_BATCH_SENTINEL", + "CONNECTOR_NAME_PREFIX", + "ConnectorName", + "format_connector_name", + "is_connector_name", + "parse_connector_name", + "vendor_slug_candidates", +] + +CONNECTOR_NAME_PREFIX = "connectors__" + +# Planner sentinel: stands for "a tool_call carrying connector entries" in +# parallel-safety checks. It is not itself a callable name β€” it has only two +# segments, so parse_connector_name() rejects it by construction. +CONNECTOR_BATCH_SENTINEL = "connectors__execute" + + +@dataclass(frozen=True) +class ConnectorName: + """A parsed ``connectors____`` identifier.""" + + raw: str + connector: str + tool: str + + +def is_connector_name(name: object) -> bool: + """True when ``name`` claims to be a connector tool name. + + A claim, not a guarantee: a True here routes the entry to connector + handling, where a failed parse becomes that entry's error. + """ + return isinstance(name, str) and name.startswith(CONNECTOR_NAME_PREFIX) + + +def parse_connector_name(name: object) -> Optional[ConnectorName]: + """Parse a composed name into its parts, or ``None`` if malformed. + + Never raises. Requires exactly three non-empty segments under a + 2-bounded split, so the tool slug keeps any internal underscores. + """ + if not isinstance(name, str): + return None + parts = name.split("__", 2) + if len(parts) != 3: + return None + prefix, connector, tool = parts + if prefix != "connectors" or not connector or not tool: + return None + return ConnectorName(raw=name, connector=connector, tool=tool) + + +def format_connector_name(connector: str, tool: str) -> str: + """Compose a model-facing name, stripping a repeated toolkit prefix.""" + prefix = f"{connector.upper()}_" + if tool.startswith(prefix): + tool = tool[len(prefix):] + return f"{CONNECTOR_NAME_PREFIX}{connector}__{tool}" + + +def vendor_slug_candidates(connector: str, tool: str) -> tuple[str, ...]: + """Return wire-slug candidates in deterministic recovery order. + + Prefixed-first restores every slug the encoder stripped; the literal + covers slugs that never conformed and therefore composed verbatim. + """ + return (f"{connector.upper()}_{tool}", tool) diff --git a/tools/tool_gateway/wire.py b/tools/tool_gateway/wire.py new file mode 100644 index 0000000000..6652fe7bdf --- /dev/null +++ b/tools/tool_gateway/wire.py @@ -0,0 +1,177 @@ +"""Wire models for the tool gateway's connector routes. + +Hand-written pydantic v2 models for the four routes hermes-agent consumes +(search, schemas, execute, connections), mirroring the gateway's frozen +contract. Field names are snake_case attributes with camelCase wire aliases; +requests serialize with ``model_dump(by_alias=True)``. + +Rules: + +- ``extra="ignore"`` everywhere: the gateway may add fields; we must not + break when it does. +- These models never escape the wire/client layer. ``merge.py`` and above + operate on plain dicts produced by the client. +- The ``index`` field on execute results is parsed but NEVER used for + correlation β€” execute is 0-based while search is 1-based, and the safe + rule is array position only. +""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# Route paths, relative to the gateway origin. +CONNECTORS_PATH = "v1/connectors" +CONNECTOR_SEARCH_PATH = f"{CONNECTORS_PATH}/search" +CONNECTOR_SCHEMAS_PATH = f"{CONNECTORS_PATH}/schemas" +CONNECTOR_EXECUTE_PATH = f"{CONNECTORS_PATH}/execute" +CONNECTOR_CONNECTIONS_PATH = f"{CONNECTORS_PATH}/connections" + +# The gateway refuses execute/connections batches larger than this. Unreachable +# in practice: the hermes-side dispatch cap (config.MAX_CALLS_PER_DISPATCH) is +# lower by design, so there is deliberately no chunking code. +WIRE_BATCH_MAX = 25 + +# Per-tool error codes inside a 200 execute envelope. A per-tool failure is a +# result, never an HTTP error; HTTP-level failures use the error envelope +# handled by errors.parse_gateway_error. +ConnectorErrorCode = Literal[ + "TOOL_NOT_ALLOWED", + "CONNECTION_REQUIRED", + "TOOL_NOT_FOUND", + "PROVIDER_ERROR", +] + + +class _Wire(BaseModel): + """Base for all wire models: tolerant parsing, alias-aware both ways.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +# --- POST v1/connectors/search --------------------------------------------- + + +class ConnectorSearchQuery(_Wire): + use_case: str = Field(alias="useCase") + known_fields: Optional[str] = Field(default=None, alias="knownFields") + + +class ConnectorSearchRequest(_Wire): + queries: list[ConnectorSearchQuery] + + +class ConnectorToolSchema(_Wire): + connector: str + tool: str + description: str = "" + input_schema: dict[str, Any] = Field(default_factory=dict, alias="inputSchema") + + +class ConnectorSearchResult(_Wire): + # NOTE: 1-based vendor passthrough on this route; do not correlate on it. + index: int + use_case: str = Field(default="", alias="useCase") + tools: list[str] = Field(default_factory=list) + related_tools: list[str] = Field(default_factory=list, alias="relatedTools") + connectors: list[str] = Field(default_factory=list) + guidance: Optional[str] = None + plan_steps: Optional[list[str]] = Field(default=None, alias="planSteps") + pitfalls: Optional[list[str]] = None + error: Optional[str] = None + + +class ConnectorConnectionStatus(_Wire): + connector: str + connected: bool = False + description: str = "" + + +class ConnectorSearchResponse(_Wire): + results: list[ConnectorSearchResult] = Field(default_factory=list) + schemas: dict[str, ConnectorToolSchema] = Field(default_factory=dict) + connections: list[ConnectorConnectionStatus] = Field(default_factory=list) + next_steps: list[str] = Field(default_factory=list, alias="nextSteps") + + +# --- POST v1/connectors/schemas --------------------------------------------- + + +class ConnectorSchemasRequest(_Wire): + tools: list[str] + + +class ConnectorSchemasResponse(_Wire): + schemas: dict[str, ConnectorToolSchema] = Field(default_factory=dict) + not_found: list[str] = Field(default_factory=list, alias="notFound") + suggestions: dict[str, list[str]] = Field(default_factory=dict) + + +# --- POST v1/connectors/execute --------------------------------------------- + + +class ConnectorToolError(_Wire): + code: ConnectorErrorCode + message: str + connector: Optional[str] = None + connect_url: Optional[str] = Field(default=None, alias="connectUrl") + hint: Optional[str] = None + + +class ConnectorExecuteCall(_Wire): + connector: str + tool: str + arguments: dict[str, Any] = Field(default_factory=dict) + + +class ConnectorExecuteRequest(_Wire): + tools: list[ConnectorExecuteCall] + + +class ConnectorExecuteResult(_Wire): + # NOTE: 0-based on this route (unlike search); still never correlated on. + index: int = 0 + connector: str = "" + tool: str = "" + data: Any = None + error: Optional[ConnectorToolError] = None + + +class ConnectorExecuteResponse(_Wire): + # 200 even when every tool failed; per-tool errors ride inside results. + results: list[ConnectorExecuteResult] = Field(default_factory=list) + success_count: int = Field(default=0, alias="successCount") + error_count: int = Field(default=0, alias="errorCount") + total_count: int = Field(default=0, alias="totalCount") + + +# --- POST v1/connectors/connections ------------------------------------------ + + +class ConnectorConnectionsRequest(_Wire): + connectors: list[str] + reinitiate: bool = False + + +class ConnectorConnectionResult(_Wire): + connector: str + status: Literal["active", "initiated", "failed"] + connect_url: Optional[str] = Field(default=None, alias="connectUrl") + instruction: Optional[str] = None + reinitiated: bool = False + + +class ConnectorConnectionsSummary(_Wire): + total: int = 0 + active: int = 0 + initiated: int = 0 + failed: int = 0 + + +class ConnectorConnectionsResponse(_Wire): + results: list[ConnectorConnectionResult] = Field(default_factory=list) + summary: ConnectorConnectionsSummary = Field( + default_factory=ConnectorConnectionsSummary + ) diff --git a/tools/tool_search.py b/tools/tool_search.py index 1ac0f2967a..5dce3b60e8 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -19,12 +19,19 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple from tools.registry import tool_error from tools.tool_search_catalog import ( BRIDGE_TOOL_NAMES, CHARS_PER_TOKEN, TOOL_CALL_NAME, TOOL_DESCRIBE_NAME, TOOL_SEARCH_NAME, - CatalogEntry, _corpus_stats, _fn, _listing_group_label, _registry_entry, _registry_toolset, + CatalogEntry, _fn, _listing_group_label, _registry_entry, _registry_toolset, build_catalog, build_catalog_listing_with_form, search_catalog) -from tools.tool_search_validation import validate_deferred_call_args +from tools.tool_search_validation import normalize_tool_call_entries, validate_deferred_call_args +from tools.connector_search import connections_in_scope, connector_entries_by_group, remote_schemas_for +from tools.tool_gateway.names import CONNECTOR_BATCH_SENTINEL, is_connector_name logger = logging.getLogger("tools.tool_search") -_MAX_QUERIES_PER_CALL = _MAX_DESCRIBE_NAMES_PER_CALL = 10 # bound the work one bridge call requests +# Bound the work one bridge call requests. Search is capped at the gateway's +# own limit: the connector search route answers 7 use_cases per request and +# returns HTTP 502 for 8 or more (measured 2026-09-09), and one local call +# maps to one gateway request. Describe has no such remote limit. +_MAX_QUERIES_PER_CALL = 7 +_MAX_DESCRIBE_NAMES_PER_CALL = 10 @dataclass(frozen=True) @@ -180,11 +187,15 @@ def estimate_tokens_from_schemas(tool_defs: Iterable[Dict[str, Any]]) -> int: def should_activate(config: ToolSearchConfig, deferrable_tokens: int, - context_length: Optional[int]) -> bool: + context_length: Optional[int], *, connections_granted: bool = False) -> bool: """``"off"`` never activates; ``"on"``/``"auto"`` activate whenever any deferrable tool exists ("auto" is reserved for a future budget-gated mode β€” do not distinguish them without that design). ``context_length`` is kept for caller compatibility.""" - return config.enabled != "off" and deferrable_tokens > 0 + if config.enabled == "off": + return False + if deferrable_tokens > 0: + return True + return connections_granted def listing_token_budget(config: ToolSearchConfig, context_length: Optional[int]) -> int: @@ -203,17 +214,29 @@ def _bridge_schema(name: str, description: str, properties: Dict[str, Any], "parameters": {"type": "object", "properties": properties, "required": required}}} -def _search_description(deferred_count: int, listing: Optional[str], listing_form: str) -> str: - """tool_search bridge description with the listing embedded (framing per ``listing_form``).""" +_CONNECTIONS_HINT = ( + " Names starting with `connectors__` are tools of remote connector accounts " + "(Gmail, Linear, Notion, ...); `manage_connections` checks whether an account " + "is connected and gets the authorization link when it is not.") + + +def _search_description(deferred_count: int, listing: Optional[str], listing_form: str, + connections_granted: bool = False) -> str: + """tool_search bridge description with the listing embedded (framing per ``listing_form``). + ``connections_granted`` adds the one sentence that ties ``connectors__`` names to + ``manage_connections``; without that tool in the session the sentence would name a tool + the model cannot call.""" desc = ( - f"Search {deferred_count} additional tools that are loaded on demand. " - "Takes a list of queries searched in parallel against the same " + (f"Search {deferred_count} additional tools that are loaded on demand. " + if deferred_count else "Search remote connector tools (email, calendars, issue trackers, and more). ") + + "Takes a list of queries searched in parallel against the same " "catalog; send one query per distinct capability you need. Returns " "matching tool names grouped per query plus a shared map with each " "tool's description. Follow with " f"`{TOOL_DESCRIBE_NAME}` to load full parameter schemas, " f"then `{TOOL_CALL_NAME}` to invoke. Tools listed at the top of this " - "system prompt are already available and do not need to be searched.") + "system prompt are already available and do not need to be searched." + + (_CONNECTIONS_HINT if connections_granted else "")) if not listing: return desc if listing_form == "groups": @@ -237,14 +260,14 @@ def _search_description(deferred_count: int, listing: Optional[str], listing_for def bridge_tool_schemas(deferred_count: int, listing: Optional[str] = None, - listing_form: str = "") -> List[Dict[str, Any]]: + listing_form: str = "", connections_granted: bool = False) -> List[Dict[str, Any]]: """Bridge tool schemas injected in place of deferred tools; kept short β€” every byte is paid every turn. ``listing`` is embedded in the tool_search description; per-tool forms say "skip search when you see the exact name", "groups" says search is mandatory.""" return [ _bridge_schema( TOOL_SEARCH_NAME, - _search_description(deferred_count, listing, listing_form), + _search_description(deferred_count, listing, listing_form, connections_granted), { "queries": { "type": "array", @@ -274,17 +297,28 @@ def bridge_tool_schemas(deferred_count: int, listing: Optional[str] = None, ), _bridge_schema( TOOL_CALL_NAME, - "Invoke a deferred tool by name with the given arguments. Argument shape " - f"matches the tool's schema (see `{TOOL_DESCRIBE_NAME}`). Policy, hooks, " - "and approvals run exactly as for any directly-listed tool.", + "Invoke deferred tools. Takes `calls`, an array of {name, arguments} " + "β€” one entry per invocation; a single call is an array of one. " + "Local tools require one entry per tool_call. Only connectors__ names " + "may be batched together; mixed and multi-local batches are rejected. " + "Connector entries execute individually with results in input order. " + f"Argument shapes match each tool's schema (see `{TOOL_DESCRIBE_NAME}`). " + "Policy, hooks, and approvals run as for directly-listed tools.", { - "name": {"type": "string", "description": "Exact tool name to invoke."}, - "arguments": { - "type": "object", - "description": "Arguments for the tool, matching its schema.", + "calls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Exact tool name to invoke."}, + "arguments": {"type": "object", "description": "Arguments matching the tool schema."}, + }, + "required": ["name", "arguments"], + }, + "description": "One local invocation, or one or more connector invocations. Never mix local and connector tools.", }, }, - ["name", "arguments"], + ["calls"], ), ] @@ -310,7 +344,11 @@ def assemble_tool_defs(tool_defs: List[Dict[str, Any]], *, context_length: Optio incoming = [td for td, name in zip(tool_defs, _tool_def_names(tool_defs)) if name not in BRIDGE_TOOL_NAMES] visible, deferrable = classify_tools(incoming, config.effective_defer_tools) + connections_granted = connections_in_scope(incoming) if not deferrable: + if should_activate(config, 0, context_length, connections_granted=connections_granted): + return AssemblyResult(tool_defs=incoming + bridge_tool_schemas(0, connections_granted=connections_granted), + activated=True, tier=2) return AssemblyResult(tool_defs=incoming, activated=False) deferrable_tokens = estimate_tokens_from_schemas(deferrable) if not should_activate(config, deferrable_tokens, context_length): @@ -323,7 +361,8 @@ def assemble_tool_defs(tool_defs: List[Dict[str, Any]], *, context_length: Optio if config.listing != "off": listing, listing_form = build_catalog_listing_with_form( deferrable, max_tokens=listing_budget) - bridge = bridge_tool_schemas(len(deferrable), listing=listing, listing_form=listing_form) + bridge = bridge_tool_schemas(len(deferrable), listing=listing, listing_form=listing_form, + connections_granted=connections_granted) tier = 1 if listing_form in ("full", "names", "mixed") else 2 logger.info( "tool_search activated (tier %d): %d core/visible tools kept, %d deferred " @@ -339,6 +378,17 @@ def is_bridge_tool(name: str) -> bool: return name in BRIDGE_TOOL_NAMES +def _clip_description(text: str, cap: int = 500) -> str: + """Cap a record description, marking the cut so it reads as deliberate. + + A bare slice ends mid-word ("apply exponential bac") and looks like + corruption; the ellipsis says "there is more β€” tool_describe has it". + 500 keeps 9 in 10 vendor connector descriptions whole and every first + sentence (measured p90 575, first-sentence max 329 over 353 tools). + """ + return text if len(text) <= cap else text[:cap] + "…" + + def _shared_tool_record(entry: CatalogEntry) -> Dict[str, Any]: """One record for the shared ``tools`` map (per-query groups carry names only); ``required`` lets the model attempt a trivial call without a ``tool_describe`` round-trip.""" @@ -347,9 +397,7 @@ def _shared_tool_record(entry: CatalogEntry) -> Dict[str, Any]: except (TypeError, KeyError, AttributeError): required = [] return {"source": entry.source, "source_name": entry.source_name, - # 500 keeps 9 in 10 vendor tool descriptions whole and every first - # sentence (measured p90 575, first-sentence max 329 over 353 tools). - "description": (entry.description or "")[:500], + "description": _clip_description(entry.description or ""), "required": [r[:64] for r in (required if isinstance(required, list) else []) if isinstance(r, str)][:32]} @@ -382,11 +430,15 @@ def _string_list_arg(args: Dict[str, Any], key: str, *, dedupe: bool, max_items: def dispatch_tool_search(args: Dict[str, Any], *, current_tool_defs: List[Dict[str, Any]], - config: Optional[ToolSearchConfig] = None) -> str: + config: Optional[ToolSearchConfig] = None, + connector_search: Optional[Any] = None) -> str: """Execute the ``tool_search`` bridge tool -> JSON ``{queries, total_available, results: [{query, matches: [names]}], tools: {name: {source, source_name, description, - required}}}``. ``limit`` applies PER QUERY; empty groups get ``available_sources`` + - ``hint`` so a lexical miss is not mistaken for a missing capability.""" + required}}}``. ``limit`` is the total PER QUERY across local and connector tools: the + gateway's hits for a query join the local catalog as documents and one BM25 pass ranks + them together, so a connector tool that answers the query is never starved by local + tools that share one word with it. Empty groups get ``available_sources`` + ``hint`` so + a lexical miss is not mistaken for a missing capability.""" config = config or load_config() queries, err = _string_list_arg(args, "queries", dedupe=False, max_items=_MAX_QUERIES_PER_CALL, retry_hint="Retry with fewer, more targeted queries.") @@ -396,16 +448,20 @@ def dispatch_tool_search(args: Dict[str, Any], *, current_tool_defs: List[Dict[s limit = (config.search_default_limit if raw_limit is None else _clamped_int(raw_limit, config.search_default_limit, 1, config.max_search_limit)) catalog = build_catalog(_deferrable_in(current_tool_defs)) + remote_entries: List[List[CatalogEntry]] = [[] for _ in queries] + if connections_in_scope(current_tool_defs): + remote_entries = connector_entries_by_group(queries, connector_search=connector_search) results: List[Dict[str, Any]] = [] tools_map: Dict[str, Dict[str, Any]] = {} - corpus_stats = _corpus_stats(catalog) available_sources = _available_source_summary(catalog) if catalog else [] - for query in queries: - hits = search_catalog(catalog, query, limit=limit, corpus_stats=corpus_stats) + for position, query in enumerate(queries): + corpus = catalog + remote_entries[position] + hits = search_catalog(corpus, query, limit=limit) for h in hits: tools_map.setdefault(h.name, _shared_tool_record(h)) - group: Dict[str, Any] = {"query": query, "matches": [h.name for h in hits]} - if not hits and catalog: + matches = [h.name for h in hits] + group: Dict[str, Any] = {"query": query, "matches": matches} + if not matches and catalog: group["available_sources"] = available_sources group["hint"] = ( "This query returned no lexical matches, but the sources above " @@ -413,12 +469,14 @@ def dispatch_tool_search(args: Dict[str, Any], *, current_tool_defs: List[Dict[s "tool_search with the service name plus a concrete action or " "object before concluding the capability is unavailable.") results.append(group) - return json.dumps({"queries": queries, "total_available": len(catalog), "results": results, + remote_count = sum(1 for name in tools_map if is_connector_name(name)) + return json.dumps({"queries": queries, "total_available": len(catalog) + remote_count, "results": results, "tools": tools_map}, ensure_ascii=False) def dispatch_tool_describe(args: Dict[str, Any], *, current_tool_defs: List[Dict[str, Any]], - config: Optional[ToolSearchConfig] = None) -> str: + config: Optional[ToolSearchConfig] = None, + connector_describe: Optional[Any] = None) -> str: """Execute the ``tool_describe`` bridge tool -> JSON ``{tools: {name: {description, parameters}}, not_found: [...] (unknown / not in this assembly; never fails the call), errors: {name: msg} (registered but non-deferrable)}``. Duplicates dedupe silently.""" @@ -430,14 +488,22 @@ def dispatch_tool_describe(args: Dict[str, Any], *, current_tool_defs: List[Dict return err deferrable = _deferrable_in(current_tool_defs) by_name = {name: _fn(td) for td, name in zip(deferrable, _tool_def_names(deferrable)) if name} + remote_schemas = remote_schemas_for(names, current_tool_defs, connector_describe) + tools: Dict[str, Dict[str, Any]] = {} not_found: List[str] = [] errors: Dict[str, str] = {} for name in names: fn = by_name.get(name) + remote_fn = remote_schemas.get(name) if fn is not None: tools[name] = {"description": fn.get("description", ""), "parameters": fn.get("parameters", {})} + elif isinstance(remote_fn, dict): + tools[name] = {"description": str(remote_fn.get("description", "")), + "parameters": remote_fn.get("parameters", {})} + elif is_connector_name(name): + not_found.append(name) elif _registry_entry(name) is not None and not is_deferrable_tool_name( name, load_config_readonly().effective_defer_tools): # Registered but bridge/core/GUI-surface: a real name, wrong door. @@ -465,26 +531,39 @@ def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]: def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]: - """Parse a ``tool_call`` invocation -> (underlying_name, args, error_msg); ``(None, {}, msg)`` - on error. Shared by dispatch, display and the trajectory recorder so all three agree.""" - name = str(args.get("name") or "").strip() - if not name: - return None, {}, "tool_call requires a 'name' argument" - if name in BRIDGE_TOOL_NAMES: - return None, {}, f"tool_call cannot invoke '{name}' (it is itself a bridge tool)" - raw_args = args.get("arguments") - if isinstance(raw_args, str): - try: - raw_args = json.loads(raw_args) - except json.JSONDecodeError as e: - return None, {}, f"tool_call 'arguments' is not valid JSON: {e}" - raw_args = {} if raw_args is None else raw_args - if not isinstance(raw_args, dict): - return None, {}, "tool_call 'arguments' must be an object" + """Parse a ``tool_call`` invocation into (underlying_name, args, error_msg). + + Used by: + * the dispatcher in ``model_tools.handle_function_call``, + * the display layer (so the activity feed shows the underlying tool), + * the trajectory recorder. + + A connector-only batch resolves + to ``(CONNECTOR_BATCH_SENTINEL, {"calls": [...]}, None)``: the batch is + one dispatch unit owned by the ``model_tools`` bridge branch, and the + sentinel is what planners/display layers see. A single local entry keeps + the historical single-tool contract unchanged. + + On parse error, returns ``(None, {}, error_message)``. + """ + entries, err = normalize_tool_call_entries(args) + if err: + return None, {}, err + + if len(entries) > 1 and any(not is_connector_name(e["name"]) for e in entries): + return None, {}, ( + "Local tools require one entry per tool_call; mixed and multi-local batches are not supported." + ) + if is_connector_name(entries[0]["name"]): + return CONNECTOR_BATCH_SENTINEL, {"calls": entries}, None + + name = entries[0]["name"] + raw_args = entries[0]["arguments"] if not is_deferrable_tool_name(name, load_config_readonly().effective_defer_tools): return None, {}, ( f"'{name}' is not a deferrable tool. If it appears in the model-facing tools " - "list already, call it directly instead of via tool_call.") + "list already, call it directly instead of via tool_call." + ) return name, raw_args, None @@ -495,7 +574,8 @@ __all__ = [ "build_catalog_listing_with_form", "listing_token_budget", "search_catalog", "bridge_tool_schemas", "assemble_tool_defs", "is_bridge_tool", "dispatch_tool_search", "dispatch_tool_describe", "resolve_underlying_call", "scoped_deferrable_names", - "validate_deferred_call_args"] + "validate_deferred_call_args", "normalize_tool_call_entries", + "CONNECTOR_BATCH_SENTINEL", "is_connector_name"] # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- diff --git a/tools/tool_search_validation.py b/tools/tool_search_validation.py index 68be500e93..a2ee82b1e0 100644 --- a/tools/tool_search_validation.py +++ b/tools/tool_search_validation.py @@ -6,9 +6,10 @@ import copy import json import logging import re -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Tuple from tools.registry import tool_error +from tools.tool_search_catalog import BRIDGE_TOOL_NAMES logger = logging.getLogger("tools.tool_search") @@ -133,3 +134,46 @@ def validate_deferred_call_args(name: str, args: Dict[str, Any]) -> Optional[str except Exception: # pragma: no cover β€” never block dispatch on validator bugs logger.debug("validate_deferred_call_args failed for %s", name, exc_info=True) return None + + +def normalize_tool_call_entries(args: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Optional[str]]: + """Normalize ``tool_call`` arguments into a ``calls[]`` list of entries. + + Accepts the advertised batch shape ``{"calls": [{"name", "arguments"}, ...]}`` + and, tolerantly, the legacy single shape ``{"name": ..., "arguments": ...}`` + (a single call is a batch of one). Each entry's ``arguments`` is coerced to + a dict (JSON strings parsed, ``None`` β†’ ``{}``). Returns ``(entries, None)`` + or ``([], error_message)``. + """ + raw_calls = args.get("calls") + if raw_calls is None: + # Legacy single shape. + if not str(args.get("name") or "").strip(): + return [], "tool_call requires 'calls' (an array of {name, arguments})" + raw_calls = [{"name": args.get("name"), "arguments": args.get("arguments")}] + if isinstance(raw_calls, dict): + raw_calls = [raw_calls] + if not isinstance(raw_calls, list) or not raw_calls: + return [], "tool_call 'calls' must be a non-empty array of {name, arguments}" + + entries: List[Dict[str, Any]] = [] + for position, raw in enumerate(raw_calls): + if not isinstance(raw, dict): + return [], f"tool_call calls[{position}] must be an object with 'name' and 'arguments'" + name = str(raw.get("name") or "").strip() + if not name: + return [], f"tool_call calls[{position}] requires a 'name'" + if name in BRIDGE_TOOL_NAMES: + return [], f"tool_call cannot invoke '{name}' (it is itself a bridge tool)" + raw_args = raw.get("arguments") + if raw_args is None: + raw_args = {} + if isinstance(raw_args, str): + try: + raw_args = json.loads(raw_args) + except json.JSONDecodeError as e: + return [], f"tool_call calls[{position}].arguments is not valid JSON: {e}" + if not isinstance(raw_args, dict): + return [], f"tool_call calls[{position}].arguments must be an object" + entries.append({"name": name, "arguments": raw_args}) + return entries, None diff --git a/toolsets.py b/toolsets.py index d4a33dcf53..f8b7c44d30 100644 --- a/toolsets.py +++ b/toolsets.py @@ -34,6 +34,8 @@ _HERMES_CORE_TOOLS = [ "kanban_unblock", "kanban_attach", "kanban_attach_url", "kanban_attachments", "computer_use", + # Service-gated connector account status and authorization links. + "manage_connections", ] # Webhook payloads are untrusted third-party content: no file/system execution. @@ -124,6 +126,7 @@ TOOLSETS = { "memory": _ts("Persistent memory across sessions (personal notes + user profile)", ["memory"]), "context_engine": _ts("Runtime tools exposed by the active context engine"), "session_search": _ts("Search and recall past conversations with summarization", ["session_search"]), + "connections": _ts("Remote connector discovery, execution, and account authorization", ["manage_connections"]), "project": _ts("Desktop Projects β€” create/switch named workspaces (GUI sessions only)", ["desktop_project"]), "bot_room": _ts("Verified text-only Group Chat turn capabilities"), diff --git a/website/docs/user-guide/features/tool-gateway.md b/website/docs/user-guide/features/tool-gateway.md index ee828cbb46..4dbbc68743 100644 --- a/website/docs/user-guide/features/tool-gateway.md +++ b/website/docs/user-guide/features/tool-gateway.md @@ -188,8 +188,16 @@ TOOL_GATEWAY_DOMAIN=your-domain.example.com TOOL_GATEWAY_SCHEME=https TOOL_GATEWAY_USER_TOKEN=your-token # normally auto-populated from Portal login FIRECRAWL_GATEWAY_URL=https://... # override one endpoint specifically +TOOL_GATEWAY_URL=http://127.0.0.1:3009 # pin the shared managed origin exactly +CONNECTOR_GATEWAY_URL=http://127.0.0.1:3009 # pin the connectors origin exactly ``` +Every host is named `{label}-gateway.`, and `TOOL_GATEWAY_DOMAIN` / `TOOL_GATEWAY_SCHEME` reshape **all** of them; a `{LABEL}_GATEWAY_URL` pins one host exactly and skips the derivation: + +- `{vendor}-gateway.` β€” per-vendor passthroughs (Firecrawl, BFL, ...). +- `tool-gateway.` β€” the shared managed origin: the vendors hosted on the gateway itself plus media uploads. +- `connector-gateway.` β€” the connectors API (`/v1/connectors/*`), its own deployment. See [Tool Search β†’ Connectors](./tool-search.md#connectors-remote-tools). + These knobs exist for custom infrastructure setups (enterprise deployments, dev environments). Regular subscribers never set them. ## FAQ diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md index 189243177c..ead7b6c5a7 100644 --- a/website/docs/user-guide/features/tool-search.md +++ b/website/docs/user-guide/features/tool-search.md @@ -30,11 +30,15 @@ When Tool Search activates for a turn, the model sees three new tools in place of the deferred ones: ``` -tool_search(queries, limit?) β€” search the deferred-tool catalog (one or more queries) -tool_describe(names) β€” load the full schemas for one or more tools -tool_call(name, arguments) β€” invoke a deferred tool +tool_search(queries, limit?) search the deferred-tool catalog (one or more queries) +tool_describe(names) load the full schemas for one or more tools +tool_call(calls) invoke deferred tools; `calls` is an array of {name, arguments} ``` +`calls` takes one entry per invocation; a single local call is an array of +one. Only `connectors__` names may be batched together; mixed and +multi-local batches are rejected. + A typical interaction looks like: ``` @@ -49,7 +53,8 @@ Model: tool_search(["create a github issue", "send a slack message"]) Model: tool_describe(["mcp_github_create_issue", "mcp_slack_post_message"]) β†’ { tools: { mcp_github_create_issue: { parameters: { ... } }, mcp_slack_post_message: { parameters: { ... } } } } -Model: tool_call("mcp_github_create_issue", { title: "...", body: "..." }) +Model: tool_call({ calls: [{ name: "mcp_github_create_issue", + arguments: { title: "...", body: "..." } }] }) β†’ { ok: true, issue_number: 42 } ``` @@ -130,6 +135,51 @@ tools: tool_search: true # equivalent to {enabled: auto} ``` +## Connectors (remote tools) + +When you are signed in to the Nous Portal, the bridge additionally reaches +**connectors** β€” remote tools served by the managed tool gateway. They are +never registered locally: `tool_search` sends each query to the gateway, adds +the gateway's hits to the local catalog as documents (tagged +`source: "connectors"`, named `connectors____`), and ranks +both with the same BM25 pass and the same rarest-token rule, so `limit` +caps the group as a whole and a connector tool that answers the query is +never pushed out by local tools that share one word with it. The gateway +call is bounded at 30 seconds; a slow or dark gateway degrades to local +results only. `tool_describe` fetches connector schemas from the gateway, +and `tool_call` sends each connector entry in a batch as its own gateway +request, in input order (a tool name the gateway does not know under its +conventional slug is retried once under the literal slug, so an entry can +cost two requests). If a connector ever shipped both `GMAIL_X` and a literal +`X`, both would compose to `connectors__gmail__X`, which runs `GMAIL_X`; +search keeps that twin, drops the other, and logs a warning. Results splice back into the batch's original order +with recomputed counts. + +```yaml +tools: + connectors: + enabled: true # false β€” never touch connector routes; the bridge + # behaves exactly as if the feature didn't exist +``` + +Signed out (or when the gateway does not serve connectors for your +account), everything above is invisible: local search behaves exactly as +described in the rest of this page, with no errors shown to the model. + +A connector call that needs an account you haven't linked returns a +`CONNECTION_REQUIRED` error carrying a connect link. The `manage_connections` +tool (available on the same condition as the connector bridge) lists +connectors and their connection state, starts an authorization, and can wait +for the user to finish it; disconnecting an account is done by the user in +the Portal. + +`tool_call` accepts a batch: `calls` is an array of `{name, arguments}` +entries (a single call is an array of one). Each connector entry in a batch +is dispatched as its own gateway request, one after another; local deferred +tools stay one entry per `tool_call`. Approvals settle per entry before +dispatch, and a `/stop` between entries leaves the unstarted ones unsent +(their slots report `INTERRUPTED`). + ## When NOT to use it Tool Search trades a fixed per-turn token cost (the three bridge tool