From 0ca69688b046e71f77210baaba88dfc865989396 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan Date: Thu, 8 Oct 2026 17:35:26 +0530 Subject: [PATCH] feat(connectors): connect, repair, rename and list named accounts connect sends the alias so the gateway adds or reuses that account. reconnect with an alias checks that account's own row instead of the connector-wide connected flag, which reports any account. rename resolves the alias (or the label of an unnamed account) to its id and renames it through Nous Portal; an unknown name lists the valid ones. status lists each connector's accounts and stays usable when that read fails. --- tools/connectors/gateway/client.py | 7 +- tools/connectors/managed.py | 116 ++++++++++++++++++++++++----- tools/connectors/portal/client.py | 8 +- tools/connectors/tool.py | 2 +- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/tools/connectors/gateway/client.py b/tools/connectors/gateway/client.py index 2fdd7bdd1e..d2d1569c10 100644 --- a/tools/connectors/gateway/client.py +++ b/tools/connectors/gateway/client.py @@ -103,12 +103,13 @@ class ConnectorClient: return wire.ConnectorSchemasResponse.model_validate(payload).model_dump() def connections( - self, connectors: Sequence[str], *, reinitiate: bool = False, + self, connectors: Sequence[str], *, reinitiate: bool = False, alias: Optional[str] = None, return_to: Optional[str] = None, op: Optional[str] = None, ) -> dict[str, Any]: - """Never retry: the gateway cannot deduplicate authorization starts.""" + """Never retry: the gateway cannot deduplicate authorization starts. ``alias`` names the one + account the request connects (or, with ``reinitiate``, repairs).""" body = wire.ConnectorConnectionsRequest( - connectors=list(connectors), reinitiate=reinitiate, return_to=return_to, op=op, + connectors=list(connectors), reinitiate=reinitiate, alias=alias, return_to=return_to, op=op, ).model_dump(by_alias=True, exclude_none=True) payload = self._post(wire.CONNECTOR_CONNECTIONS_PATH, body, retries=0) return wire.ConnectorConnectionsResponse.model_validate(payload).model_dump() diff --git a/tools/connectors/managed.py b/tools/connectors/managed.py index 141c333c27..a9ced507e0 100644 --- a/tools/connectors/managed.py +++ b/tools/connectors/managed.py @@ -10,7 +10,7 @@ from tools.connectors.gateway.config import operation_session_key from tools.connectors.gateway.errors import RateLimited from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target from tools.connectors.run import Kind, run_operation -from tools.connectors.targets import catalog_names, hosted_names, misrouted_to_hosted_error +from tools.connectors.targets import HostedTarget, catalog_names, hosted_names, misrouted_to_hosted_error from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -38,7 +38,8 @@ _ACCOUNT_OUTCOME: dict[str, tuple[TargetState, Actor]] = { NOTE = ( "Settled once. connected → use the app now; skipped → the user chose Not now, do not connect it " "or route around it; not_connected → ask the user what to do, never re-mint on your own. " - "A later request from the USER for that same app is not a re-ask — run it." + "A later request from the USER for that same app is not a re-ask — run it. " + "If you named an account, tell the user its name and that they can rename it." ) @@ -66,7 +67,9 @@ def mint(client: Any, operation: ConnectionOperation, names: list[str], *, reini if not names: return - response = client.connections(names, reinitiate=reinitiate, **return_to_args(op=operation.op_id)) + # One aliased target per call, so the alias of the first named target is the request's. + alias = next((t.alias for t in operation.targets if t.name in names and t.alias), None) + response = client.connections(names, reinitiate=reinitiate, alias=alias, **return_to_args(op=operation.op_id)) for entry in response.get("results", []): name = str(entry.get("connector") or "").lower() target = operation.target(name) @@ -200,7 +203,8 @@ def _prepare(client: Any, action: str, force: bool) -> Callable[[ConnectionOpera mint(client, operation, names, reinitiate=True, actor=Actor.backend_watcher) _mark_misrouted(operation) return - status = _status_by_slug(client) + # A named account is checked on its own row: the connector-wide flag reports any account. + status = _alias_status(operation.targets) or _status_by_slug(client) repair = [] for name in names: if status.get(name, {}).get("connected"): @@ -214,8 +218,33 @@ def _prepare(client: Any, action: str, force: bool) -> Callable[[ConnectionOpera return prepare -def _no_card_result(client: Any, action: str, names: list[str], force: bool, session_id: str) -> str: - operation = DetachedOperation([Target(n, "connector", action) for n in names], session_key=session_id) +def portal_accounts() -> list[dict[str, Any]]: + from tools.connectors.portal.client import PortalConnectorClient + + return PortalConnectorClient().list_accounts() + + +def _alias_status(targets: list[Target]) -> Optional[dict[str, dict[str, Any]]]: + """``{slug: {"connected": bool}}`` for an aliased target, read from that account's own row; + ``None`` when no target is aliased. A failed read counts as not connected, so the repair mints.""" + aliased = [t for t in targets if t.alias] + if not aliased: + return None + try: + rows = portal_accounts() + except Exception as exc: + logger.debug("connector accounts read for reconnect failed: %s", exc) + rows = [] + return {t.name: {"connected": any(r.get("connector") == t.name and r.get("alias") == t.alias + and r.get("status") == "active" for r in rows)} for t in aliased} + + +def _targets(targets: list[HostedTarget], action: str) -> list[Target]: + return [Target(t.name, "connector", action, alias=t.alias) for t in targets] + + +def _no_card_result(client: Any, action: str, targets: list[HostedTarget], force: bool, session_id: str) -> str: + operation = DetachedOperation(_targets(targets, action), session_key=session_id) _prepare(client, action, force)(operation) payload = operation.result(with_urls=True) payload["status"] = "initiated" if any(t.state == TargetState.initiated for t in operation.targets) else "settled" @@ -223,12 +252,69 @@ def _no_card_result(client: Any, action: str, names: list[str], force: bool, ses "Show each connect_url to the user; they open it in a browser to authorize. Ask them to tell you " "when they are done, then check with action 'status'. Do not call connect again for the same app." ) + if any(t.alias for t in targets): + payload["note"] += " You named this account: tell the user its name and that they can rename it." return json.dumps(payload, ensure_ascii=False) +def _status_result(client: Any, connectors: list[str]) -> str: + items = client.list_connectors() + if connectors: + wanted = set(connectors) + items = [i for i in items if str(i.get("connector", "")).lower() in wanted] + try: + rows = portal_accounts() + except Exception as exc: + # The account list is extra detail; the connector list alone still answers status. + logger.debug("connector accounts for status failed: %s", exc) + else: + by_slug: dict[str, list[dict[str, Any]]] = {} + for row in rows: + by_slug.setdefault(str(row.get("connector", "")).lower(), []).append( + {"alias": row.get("alias"), "label": row.get("label"), "status": row.get("status"), + "active": row.get("active")}) + items = [dict(i, accounts=by_slug.get(str(i.get("connector", "")).lower(), [])) for i in items] + return json.dumps({"connectors": items, "hint": ( + "connected=false means calls to that connector will return CONNECTION_REQUIRED. " + "Use action 'connect' to start an authorization. 'accounts' lists each account by its name " + "(alias) and the vendor's label.")}, ensure_ascii=False) + + +def _rename_result(target: HostedTarget) -> str: + """Rename the one account of ``target.name`` called ``target.alias`` (or, when unnamed, labelled + so) to ``target.to``. Synchronous: no operation and no card.""" + from tools.connectors.gateway.errors import GatewayUnavailable, IdempotencyConflict, ToolGatewayError + from tools.connectors.portal.client import PortalConnectorClient + + rows = [r for r in portal_accounts() if str(r.get("connector", "")).lower() == target.name] + matches = [r for r in rows if r.get("alias") == target.alias] or [ + r for r in rows if not r.get("alias") and r.get("label") == target.alias] + if len(matches) != 1: + names = sorted({str(r.get("alias") or r.get("label")) for r in rows}) + found = f"Its accounts are: {', '.join(names)}." if names else f"The user has no {target.name} account." + what = "No" if not matches else "More than one" + return tool_error(f"{what} {target.name} account is called {target.alias!r}. {found}") + try: + account = PortalConnectorClient().rename_account(matches[0]["connectionId"], target.to) + except IdempotencyConflict: + return tool_error(f"The name {target.to!r} is already used by another {target.name} account. Pick another name.") + except GatewayUnavailable as exc: + if exc.code == "connection_not_found": + return tool_error(f"That {target.name} account no longer exists. Use action status to list accounts.") + return tool_error("This Nous Portal cannot rename connector accounts yet.") + except ToolGatewayError as exc: + if exc.status == 405: + return tool_error("This Nous Portal cannot rename connector accounts yet.") + raise + return json.dumps({ + "renamed": {"connector": account["connector"], "alias": account.get("alias"), "label": account["label"]}, + "notice": f"Tell the user their {target.name} account {target.alias!r} is now called {target.to!r}.", + }, ensure_ascii=False) + + def run_managed_action( action: str, - connectors: list[str], + targets: list[HostedTarget], args: dict[str, Any], *, client_factory: Optional[Callable[[], Any]] = None, @@ -240,16 +326,12 @@ def run_managed_action( if connectors_available is not None and not connectors_available(): return tool_error("Connectors are not available in this session.") try: + if action == "rename": + return _rename_result(targets[0]) client = (client_factory or managed_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 start an authorization.")}, ensure_ascii=False) - if not connectors: + return _status_result(client, [t.name for t in targets]) + if not targets: return tool_error( f"'{action}' requires 'connectors': the connector slugs to authorize (e.g. [\"gmail\"]). " "Use action 'status' to list them." @@ -257,9 +339,9 @@ def run_managed_action( force = bool(args.get("force", False)) session_key = operation_session_key(session_id) if connection_callback is None: - return _no_card_result(client, action, connectors, force, session_key) + return _no_card_result(client, action, targets, force, session_key) return run_operation( - [Target(n, "connector", action) for n in connectors], + _targets(targets, action), managed_kind(client, action, force), session_key=session_key, tool_call_id=tool_call_id, tick_seconds=WATCH_TICK_SECONDS, connection_callback=connection_callback, with_urls_in_result=False, diff --git a/tools/connectors/portal/client.py b/tools/connectors/portal/client.py index 6bed3662b3..090e52c745 100644 --- a/tools/connectors/portal/client.py +++ b/tools/connectors/portal/client.py @@ -15,7 +15,7 @@ from tools.connectors.gateway.errors import ( ToolGatewayError, parse_gateway_error, ) -from tools.connectors.gateway.wire import ConnectorAccountsResponse, RemovedConnectorAccount +from tools.connectors.gateway.wire import ConnectorAccount, ConnectorAccountsResponse, RemovedConnectorAccount from tools.connectors.portal.errors import InvalidConnectorSlug, PortalConnectorUnavailable, PortalToolsUnavailable from tools.connectors.portal.wire import ( ConnectorCatalogResponse, @@ -128,6 +128,12 @@ class PortalConnectorClient: inventory = self._parse(ConnectorAccountsResponse, response, status, PortalConnectorUnavailable, "portal accounts unavailable") return [account.model_dump(by_alias=True) for account in inventory.accounts] + def rename_account(self, connection_id: str, alias: str) -> dict[str, Any]: + path = f"/api/v1/connectors/accounts/{quote(connection_id, safe='')}" + response, status = self._request("PATCH", path, {"alias": alias}) + account = self._parse(ConnectorAccount, response, status, PortalConnectorUnavailable, "portal accounts unavailable") + return account.model_dump(by_alias=True) + def delete_account(self, connection_id: str) -> dict[str, Any]: path = f"/api/v1/connectors/accounts/{quote(connection_id, safe='')}" response, status = self._request("DELETE", path) diff --git a/tools/connectors/tool.py b/tools/connectors/tool.py index 09750e8e02..59528d18fc 100644 --- a/tools/connectors/tool.py +++ b/tools/connectors/tool.py @@ -39,7 +39,7 @@ def manage_connections( ) return run_managed_action( - action, [t.name for t in managed], args, + action, managed, args, client_factory=client_factory, session_id=session_id, tool_call_id=tool_call_id, connection_callback=connection_callback, connectors_available=connectors_available, )