Connector tools (Gmail, Linear, Notion, ...) are searchable and callable through tool_search for signed-in Nous users (#106842)

* feat: add session-scoped connector access for onboarding

* fix(connectors): availability is the config flag AND the portal entitlement — no free-tier leg

The port carried a third availability leg from hermes-magic: a stored guest
(free-tier) identity short-circuits the managed-tool entitlement check. That
leg reads hermes_cli.anon_auth, which does not exist on hermes-agent main, so
connectors_available() raised ImportError inside its fail-closed try and the
whole connector surface was silently dark on a plain upstream checkout.

On this tree availability is the two-leg AND the design started with:
tools.connectors.enabled AND managed_nous_tools_enabled(). The free-tier leg
is a hermes-magic concern and belongs in hermes-magic's own delta over this
branch, next to the identity it depends on. Its integration test goes with it.

* docs(tool-search): connectors section — remote tools through the bridge

The squashed port carried the code but not the user-facing docs. Restores the
Connectors section of the Tool Search page and the connector-gateway host /
CONNECTOR_GATEWAY_URL override on the Tool Gateway page, updated for the
manage_connections tool and the pure-connector batch rule.

* fix(tool-search): connector tools rank with local tools in one pass instead of taking leftover slots

dispatch_tool_search ran BM25 over the local catalog, filled `limit` slots,
then appended connector hits only into slots left empty. On a 300-tool
catalog no slot was ever empty, so with Gmail and Google Calendar connected
"send gmail email" returned five betterstack tools and zero connector tools.

The gateway's hits for a query now become catalog entries (connector name,
slug words, description as the search text) and join the local catalog for
that query's BM25 pass. One ranking, one rarest-token admission rule for both
sources, `limit` as the total per query. The merge loop and the separate
record builder for connector hits are gone; `_shared_tool_record` serves both
sources.

The gateway search timeout rises from 8 s to 30 s. One request with six
use_cases measured 7 s, so 8 s sat on the edge and cut real answers off; the
failure path is unchanged (local-only results, no error to the model).

Live, 311 local tools + gateway, before -> after:
  "send gmail email":           5 betterstack tools -> gmail SEND_EMAIL, CREATE_EMAIL_DRAFT
  "read google calendar events": 5 betterstack tools -> googlecalendar EVENTS_LIST_ALL_CALENDARS
  "linear create issue", "betterstack incident": unchanged
Benchmark (25 labelled queries): connector recall 0.09 -> 0.82, precision@5
0.18 -> 0.59, false positives on absent intents 17 -> 2.

* refactor(tool-search): connector leg into tools/connector_search.py

tools/tool_search.py is a facade. The connector leg (gateway hits as catalog
entries for tool_search, remote schemas for tool_describe, the
connections_in_scope gate) was appended to it by the port. It now lives in
its own sibling, tools/connector_search.py, and the facade imports the three
entry points: connections_in_scope, connector_entries_by_group,
remote_schemas_for.

No behaviour change. The tool_describe remote block became
remote_schemas_for(names, current_tool_defs, connector_describe) with the
same inputs, the same silent-degradation contract and the same injection
seam the tests already use.

* fix(tool-search): at most 7 queries per call, the gateway's search limit

One tool_search call sends all its queries to the connector gateway as one
search request. The gateway answers 7 use_cases per request and returns
HTTP 502 for 8 or more (measured 2026-09-09, re-measured with one-word
use_cases: it is a count limit, not a size limit). With the client cap at
10, a model sending 8 to 10 queries lost every connector hit for that call
and saw local-only results with no error.

The shared constant splits: _MAX_QUERIES_PER_CALL = 7 for search,
_MAX_DESCRIBE_NAMES_PER_CALL = 10 for describe, which has no remote count
limit. Eight or more queries now get the existing "too many queries" retry
hint before any request is made. No chunking: one call, one request.

* fix(tool-search): the model is told that connectors__ names are manage_connections accounts

tool_search results carry names like connectors__gmail__CREATE_EMAIL_DRAFT and
manage_connections is the tool that checks and connects those accounts, but
nothing told the model the two are the same thing. A model that hit
CONNECTION_REQUIRED had to infer the fix on its own.

The tool_search description gains one sentence making the link, added at
assembly only when manage_connections is in the session's tools. Signed out
or with connectors off the tool is absent and the description is unchanged,
so it never names a tool the model cannot call. This follows the existing
rule for cross-tool references (tools/AGENTS.md): they are added dynamically
from the session's actual tool set, never hardcoded in a schema.

Tool defs are fixed for the life of a conversation, so the description is
byte-stable per conversation; this is a one-time prefix change.

Live, real get_tool_definitions() against a signed-in home: sentence present.
Same home with auth.json removed: manage_connections absent, sentence absent.

* fix(connectors): /stop halts a connector batch before the next remote call

dispatch_connector_batch runs every remote entry of a tool_call batch in
sequence. The executor only checks the interrupt flag between tools, and
the whole batch is one tool to it, so a /stop landing during entry 1 of
20 still sent the other 19 to the gateway.

The loop now reads tools.interrupt.is_interrupted before each dispatch.
Once set, it stops calling handle_function_call and fills every unstarted
slot with the loop's existing error-slot shape, code INTERRUPTED and the
message "Stopped by the user before this call was made.", so the result
envelope stays valid and the counts stay honest. Entries already
dispatched keep their real results.

Test: three connector calls where the fake client sets the interrupt on
the first execute. The client sees exactly one call and slots 2 and 3
carry INTERRUPTED. Red on the base branch, green with the fix.

* test(connections): schema assertions become dispatch contracts

test_schema_documents_wait_and_its_timeout froze description fragments
("REQUIRED", "can NOT disconnect", "Nous Portal"). A wording edit fails
it while a real regression (a disconnect that reaches the gateway) does
not. That is a snapshot of prose, not a behaviour contract.

Delete it. The requirement that wait needs connectors is already covered
by test_wait_requires_connectors. The user-only disconnect boundary is
now asserted as behaviour: action disconnect with a connector returns an
error and the fake client records no call. That replaces the earlier
de-authenticate test, which only checked that the word "dashboard"
appeared in the error text.

Test count in the file goes from 26 to 25.

* docs(tool-search): connector batches are one gateway request per entry

The user guide said a connector batch travels as one gateway request. It
does not: model_tools_connectors.dispatch_connector_batch re-enters core
dispatch per entry, and each entry becomes its own execute request in
bridge._run_remote (plus at most one literal-slug retry when the gateway
reports TOOL_NOT_FOUND under the conventional slug). The docstrings in
tools/tool_gateway/bridge.py and tools/tool_gateway/__init__.py still
described the abandoned V1 plan and claimed nothing outside the package
imports it.

Rewrite those sentences to match the code: one request per entry, in
input order, dispatched from model_tools_connectors.py, with the per-entry
approval and interrupt behaviour that motivated the split. The guide also
still showed the single-call shape tool_call(name, arguments); both
places now show the `calls: [{name, arguments}]` array the schema
advertises and note that a single local call is an array of one.

Docs only, no test.

* fix(tools): the between-turns refresh never rewrites the bridge tools

The per-turn MCP refresh folds a fresh tool snapshot into the live array
with preserve_prefix: order and membership stay, but a name present in both
takes the fresh schema. That is right for ordinary tools, whose schema is a
constant. tool_search is the one tool whose description is derived from the
session: the deferred-tool count, the embedded listing, and, on this branch,
whether manage_connections was present. A late MCP server or one failed
portal lookup (manage_connections' check_fn fails closed) changed those bytes
on the next turn, and every byte after tool_search in the cached prefix was
re-prefilled. The array also contradicted itself in that case: the flapping
manage_connections was carried forward while the description lost its hint.

The bridge entries now keep the bytes they were built with for the life of
the conversation. Nothing is lost: tool_search reads the live catalog at
dispatch, so tools that arrived late are still found; connector availability
is checked at dispatch too. The compaction-boundary rebuild (content_aware,
the one sanctioned cache break) still refreshes the description.

Consequence: connector exposure in the prompt is decided once, at agent
build, by whether the user was signed in then. That is the intended
contract.

* refactor(tool-search): normalize_tool_call_entries lives with the other argument validation

The port appended the tool_call argument parser to the tool_search facade.
The family already has tools/tool_search_validation.py for exactly this
work (schema validation of deferred call arguments), so the parser moves
there and the facade imports it. No behaviour change; the one test that
imported it now imports from the defining module.

* refactor(connectors): delete the unused batch dispatcher; _run_remote becomes run_remote

bridge.dispatch_calls and its helpers (_dispatch_calls_inner, _run_pre_dispatch,
_run_local, _error_slot, _maybe_parse_json) and the LocalDispatch / PreDispatch
seams had no production caller. Connector dispatch runs through
model_tools_connectors: dispatch_connector_batch re-enters handle_function_call
once per entry, so scope, hook, approval and middleware policy fire against each
composed name inside core dispatch, and dispatch_connector_call hands the single
planned entry to the bridge's transport function. Only tests called the batch
dispatcher, and they exercised policy seams that production never wires.

The transport function is the module's real entry point, so it drops the
underscore: _run_remote becomes run_remote, body unchanged. The module
docstring now describes the two legs that exist (availability with D32 silent
degradation, and run_remote) instead of the injected seams. Imports that only
the deleted code used are gone; merge.py is untouched because every export
still has a caller.

Tests that drove dispatch_calls are deleted where they covered the removed
seams (pre_dispatch blocks and rewrites, local_dispatch classification, mixed
batches). The literal-slug fallback, the per-entry transport failure, and the
hook rewrite reaching the gateway request body are re-targeted at
handle_function_call('tool_call', ...) with the fake client swapped in at
bridge._default_client_factory, the same seam test_connector_dispatch_policy
uses. Each re-targeted test fails when the retry is disabled in run_remote.

* fix(connectors): search keeps the twin a colliding name reaches, and says so

format_connector_name strips the toolkit prefix, so GMAIL_FETCH_PROFILE and a
literal FETCH_PROFILE on gmail both compose to connectors__gmail__FETCH_PROFILE.
describe and execute decode that name to the prefixed slug first, so the
literal twin is unreachable under it. If a vendor ever shipped both, search
could describe the literal under a name that runs the prefixed tool.

Search is the one place that sees both twins in one response. It now keeps
the twin the name reaches and drops the other with a WARNING that names both
slugs, whichever the gateway listed first. Short names stay; no marker, no
per-process map, no change to describe or execute. No such pair exists in the
live catalog today; the guard turns a silent alias into a logged one.
This commit is contained in:
Siddharth Balyan
2026-09-10 02:21:16 +05:30
committed by GitHub
parent cf4b78e91f
commit b4d04eb8fd
36 changed files with 4854 additions and 72 deletions

View File

@@ -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:

View File

@@ -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."

View File

@@ -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',

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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__<connector>__<tool>."))
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)

66
model_tools_connectors.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
) == {}

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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)

514
tools/connections_tool.py Normal file
View File

@@ -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="🔗",
)

128
tools/connector_search.py Normal file
View File

@@ -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 {}

View File

@@ -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()}"}

View File

@@ -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)

View File

@@ -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__<connector>__<tool>`` 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",
]

View File

@@ -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": {<composed name>: {"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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

258
tools/tool_gateway/merge.py Normal file
View File

@@ -0,0 +1,258 @@
"""Pure partition / splice / render for mixed tool_call batches.
One ``tool_call`` invocation may mix local deferred tools and
``connectors__<connector>__<tool>`` 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": <position>, "name": <original name>, "response": <data>}
{"index": <position>, "name": <original 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__<connector>__<tool>."
),
)
)
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

105
tools/tool_gateway/names.py Normal file
View File

@@ -0,0 +1,105 @@
"""Codec for ``connectors__<connector>__<tool>`` 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__<connector>__<tool>`` 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)

177
tools/tool_gateway/wire.py Normal file
View File

@@ -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
)

View File

@@ -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) ----

View File

@@ -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

View File

@@ -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"),

View File

@@ -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.<domain>`, 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.<domain>` — per-vendor passthroughs (Firecrawl, BFL, ...).
- `tool-gateway.<domain>` — the shared managed origin: the vendors hosted on the gateway itself plus media uploads.
- `connector-gateway.<domain>` — 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

View File

@@ -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__<connector>__<tool>`), 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