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:
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
66
model_tools_connectors.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
537
tests/tools/test_connections_tool.py
Normal file
537
tests/tools/test_connections_tool.py
Normal 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
|
||||
707
tests/tools/test_connector_bridge_wiring.py
Normal file
707
tests/tools/test_connector_bridge_wiring.py
Normal 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
|
||||
) == {}
|
||||
122
tests/tools/test_connector_dispatch_policy.py
Normal file
122
tests/tools/test_connector_dispatch_policy.py
Normal 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"]
|
||||
72
tests/tools/test_connector_local_batches.py
Normal file
72
tests/tools/test_connector_local_batches.py
Normal 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()
|
||||
70
tests/tools/test_connector_names.py
Normal file
70
tests/tools/test_connector_names.py
Normal 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
|
||||
86
tests/tools/test_connector_session_scope.py
Normal file
86
tests/tools/test_connector_session_scope.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
331
tests/tools/test_tool_gateway_client.py
Normal file
331
tests/tools/test_tool_gateway_client.py
Normal 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
|
||||
306
tests/tools/test_tool_gateway_merge.py
Normal file
306
tests/tools/test_tool_gateway_merge.py
Normal 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
|
||||
@@ -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
514
tools/connections_tool.py
Normal 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
128
tools/connector_search.py
Normal 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 {}
|
||||
78
tools/managed_gateway_auth.py
Normal file
78
tools/managed_gateway_auth.py
Normal 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()}"}
|
||||
@@ -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)
|
||||
|
||||
73
tools/tool_gateway/__init__.py
Normal file
73
tools/tool_gateway/__init__.py
Normal 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",
|
||||
]
|
||||
227
tools/tool_gateway/bridge.py
Normal file
227
tools/tool_gateway/bridge.py
Normal 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
|
||||
306
tools/tool_gateway/client.py
Normal file
306
tools/tool_gateway/client.py
Normal 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)
|
||||
107
tools/tool_gateway/config.py
Normal file
107
tools/tool_gateway/config.py
Normal 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
|
||||
141
tools/tool_gateway/errors.py
Normal file
141
tools/tool_gateway/errors.py
Normal 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
258
tools/tool_gateway/merge.py
Normal 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
105
tools/tool_gateway/names.py
Normal 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
177
tools/tool_gateway/wire.py
Normal 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
|
||||
)
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user