from __future__ import annotations import json import logging import time from typing import Any, Callable, Dict, List, Optional, Tuple from tools.connectors.contract import Actor, SettleReason, TargetState, allowed 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.registry import tool_error logger = logging.getLogger(__name__) # The account route carries its own 180/min budget, so one read per pending target per second stays # inside it and still flips the card within a second of the user finishing at the vendor. WATCH_TICK_SECONDS = 1.0 # A read never outlives the operation, never asks for less than one second, and never holds the # loop for more than ten: Continue must be able to return the tool while a gateway hangs. _MIN_READ_SECONDS = 1.0 _MAX_READ_SECONDS = 10.0 # The six-state account vocabulary -> the state that read ends the attempt in, and who caused it. # `pending` is not here: it is the attempt still running, and moves nothing. _ACCOUNT_OUTCOME: Dict[str, Tuple[TargetState, Actor]] = { "active": (TargetState.connected, Actor.backend_watcher), "failed": (TargetState.failed, Actor.backend_watcher), "revoked": (TargetState.failed, Actor.backend_watcher), "inactive": (TargetState.failed, Actor.backend_watcher), # The link's TTL ran out; the gateway reports it, the clock caused it. "expired": (TargetState.expired, Actor.clock), } 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." ) def managed_client(): from tools.connectors.gateway.client import ConnectorClient return ConnectorClient() def managed_kind(client: Any, action: str, force: bool) -> Kind: return Kind(prepare=_prepare(client, action, force), observe=lambda operation: _observe(client, operation), note=NOTE) def _status_by_slug(client: Any) -> Dict[str, Dict[str, Any]]: """The toolkit list, by slug. Only the reconnect repair check reads it: it answers "is this app already connected" before any account exists for the watcher to read.""" return {str(i.get("connector", "")).lower(): i for i in client.list_connectors() if isinstance(i, dict)} def mint(client: Any, operation: ConnectionOperation, names: List[str], *, reinitiate: bool, actor: Actor) -> None: """Mint links for ``names`` and apply the gateway's per-app answer to the operation. ``actor`` is the watcher on the first mint and the user on Try again. The operation id rides along so the vendor's done page can name it on the way back to the desktop.""" from tools.connectors.gateway.client import return_to_args if not names: return response = client.connections(names, reinitiate=reinitiate, **return_to_args(op=operation.op_id)) for entry in response.get("results", []): name = str(entry.get("connector") or "").lower() target = operation.target(name) if target is None: continue status = str(entry.get("status") or "") detail = str(entry.get("status_reason") or "") connection_id = entry.get("connection_id") if status == "active": operation.transition(name, TargetState.initiated, actor) operation.transition(name, TargetState.connected, Actor.backend_watcher, connection_id=connection_id) elif status == "initiated": if not connection_id: logger.warning("connector %s: the mint named no account, so the watcher cannot read it; " "only the card or the deadline can end the row", name) operation.transition( name, TargetState.initiated, actor, connect_url=entry.get("connect_url"), connection_id=connection_id, detail=detail, ) elif target.state == TargetState.failed: # Failed again: no state change to emit, but the old link is dead and the vendor's text is new. operation.refresh(name, connect_url=None, detail=detail) elif target.state == TargetState.expired: # The table has no expired → failed; the re-mint attempt is the user's, so step through initiated. operation.transition(name, TargetState.initiated, actor) operation.transition(name, TargetState.failed, Actor.backend_watcher, detail=detail) operation.refresh(name, connect_url=None, detail=detail) else: # `detail` is the vendor's text or empty; the state itself is never written into it (the card prints it). operation.transition(name, TargetState.failed, Actor.backend_watcher, detail=detail) def _status_for(client: Any, target: Target, *, timeout: float) -> Optional[Dict[str, Any]]: """The one route the watcher reads: that target's own account row. ``None`` means "nothing to apply this tick" — no account to read, a rate-limit still in force, an account the gateway does not know yet (404 until the deadline), or a read that failed. A 429 is raised to the tick: its budget is the principal's, so it is not this one target's to wait out.""" if not target.connection_id or time.time() < target.next_read_at: return None try: return client.account_status(target.connection_id, timeout=timeout) except RateLimited: raise except Exception as exc: logger.debug("connector account read failed for %s: %s", target.name, exc) return None def _apply_read(operation: ConnectionOperation, target: Target, status: str, reason: str) -> None: """Apply one account read to one target. The RPC thread can move the row while the read is in flight — a Skip resolves it, a Continue freezes the whole result — and the read then has no live row to move: it is dropped, not raised into the tool result (that would end the watch with the operation still open and no card to answer it). Any other refusal is a real contract violation.""" outcome = _ACCOUNT_OUTCOME.get(status) if outcome is None: return to, actor = outcome try: if allowed(target.kind, target.state, to) is None: # No edge from pending: the read is itself the witness that the attempt started. operation.transition(target.name, TargetState.initiated, Actor.backend_watcher) operation.transition(target.name, to, actor, detail=reason or target.detail) except IllegalTransition: if not operation.settled and _live(target): raise logger.debug("connector %s: %s read dropped, the row is %s", target.name, status, target.state.value) def _live(target: Target) -> bool: """Only a live attempt (pending, initiated) can be advanced by a gateway read; a failed or expired link waits for the user, and a resolved row is done.""" return target.state in (TargetState.pending, TargetState.initiated) def _park(operation: ConnectionOperation, until: float) -> None: """A 429 is per principal, not per account: every live target waits out the same Retry-After.""" for target in operation.targets: if _live(target): target.next_read_at = until def _observe(client: Any, operation: ConnectionOperation) -> None: """One account read per live target per tick, sequential: this is the only thread reading them. A 429 ends the tick: the next read would spend the same refused budget.""" for target in operation.targets: # A settled op is frozen; a row that is not live waits for the user or is done. if operation.settled or not _live(target): continue timeout = min(_MAX_READ_SECONDS, max(_MIN_READ_SECONDS, operation.remaining_seconds())) try: row = _status_for(client, target, timeout=timeout) except RateLimited as exc: _park(operation, time.time() + exc.retry_after) return if row is None: continue _apply_read(operation, target, str(row.get("status") or "").lower(), str(row.get("statusReason") or "")) def _mark_misrouted(operation: ConnectionOperation) -> None: unminted = [t for t in operation.targets if t.state == TargetState.failed and not t.connection_id] if not unminted: return catalog = catalog_names() candidates = [t for t in unminted if t.name in catalog] if not candidates: return hosted = hosted_names() if hosted is None: return misrouted = [t for t in candidates if t.name not in hosted] for target in misrouted: operation.refresh(target.name, connect_url=None, actor=Actor.backend_watcher, detail=misrouted_to_hosted_error(target.name)) if misrouted and len(misrouted) == len(operation.targets): operation.settle(SettleReason.all_resolved) def _prepare(client: Any, action: str, force: bool) -> Callable[[ConnectionOperation], None]: def prepare(operation: ConnectionOperation) -> None: names = [t.name for t in operation.targets] if action == "connect": mint(client, operation, names, reinitiate=False, actor=Actor.backend_watcher) _mark_misrouted(operation) return if force: # The re-mint names a new account; the watcher reads that one, never the old row. mint(client, operation, names, reinitiate=True, actor=Actor.backend_watcher) _mark_misrouted(operation) return status = _status_by_slug(client) repair = [] for name in names: if status.get(name, {}).get("connected"): operation.transition(name, TargetState.initiated, Actor.backend_watcher) operation.transition(name, TargetState.connected, Actor.backend_watcher) else: repair.append(name) mint(client, operation, repair, reinitiate=True, actor=Actor.backend_watcher) _mark_misrouted(operation) 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) _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" payload["note"] = ( "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." ) return json.dumps(payload, ensure_ascii=False) def run_managed_action( action: str, connectors: List[str], args: Dict[str, Any], *, client_factory: Optional[Callable[[], Any]] = None, session_id: Optional[str] = None, tool_call_id: Optional[str] = None, connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None, connectors_available: Optional[Callable[[], bool]] = None, ) -> str: if connectors_available is not None and not connectors_available(): return tool_error("Connectors are not available in this session.") try: 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 tool_error( f"'{action}' requires 'connectors': the connector slugs to authorize (e.g. [\"gmail\"]). " "Use action 'status' to list them." ) 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 run_operation( [Target(n, "connector", action) for n in connectors], 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, ) except Exception as exc: 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." )