Files
hermes-agent/tools/connections_tool.py
Siddharth Balyan b4d04eb8fd 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.
2026-09-10 02:21:16 +05:30

515 lines
22 KiB
Python

#!/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="🔗",
)