Files
hermes-agent/tools/connectors/managed.py
Siddharth Balyan d0ef95ec50 fix(connectors): a repair by account id targets exactly that account
Review findings on the id-based repair:
- an open repair of one unnamed account was reused for another, because
  the operation lookup matched only connector and alias; it now matches
  the repaired account id too
- a session retry carrying another account's id re-minted the open
  target; it is refused like a retry naming another alias
- reconnect by label restarted a healthy unnamed account; only a repair
  a surface asked for by id skips the healthy check
- reconnect by label with force sent the label as an alias; the label
  now resolves to the account id first
2026-10-11 17:21:07 +05:30

385 lines
20 KiB
Python

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, ToolGatewayError
from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target
from tools.connectors.run import Kind, run_operation
from tools.connectors.targets import HostedTarget, 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. "
"If you named an account, tell the user its name and that they can rename 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,
connection_id: Optional[str] = None) -> 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. ``connection_id`` names the one
existing account a repair restarts; the gateway then needs no alias to find it. A repair without
one (Try again) reuses the account the named target already minted, so a rename in between
cannot point the retry at another login."""
from tools.connectors.gateway.client import return_to_args
if not names:
return
# One aliased target per call, so the first named target is the request's.
named = next((t for t in operation.targets if t.name in names and t.alias), None)
if reinitiate and connection_id is None:
repaired = next((t.repair_id for t in operation.targets if t.name in names and t.repair_id), None)
connection_id = repaired or (named.connection_id if named is not None else None)
alias = None if connection_id or named is None else named.alias
response = client.connections(names, reinitiate=reinitiate, alias=alias, connection_id=connection_id,
**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
# A named account is checked on its own row, read before force so a label resolves to its
# account either way: the connector-wide flag reports any account.
named = _alias_status(operation.targets)
if force:
# The re-mint names a new account; the watcher reads that one, never the old row.
account = next((s["connection_id"] for s in (named or {}).values() if s.get("connection_id")), None)
mint(client, operation, names, reinitiate=True, actor=Actor.backend_watcher, connection_id=account)
_mark_misrouted(operation)
return
status = named or _status_by_slug(client)
repair = []
for name in names:
target = operation.target(name)
if target is not None and target.repair_id and name not in (named or {}):
# A surface asked to repair one account by id: its Reconnect is the user's choice, and
# the connector's other accounts say nothing about this one.
repair.append(name)
elif 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)
# A named target is alone in its call, so at most one account id applies.
account = next((status[n].get("connection_id") for n in repair if status.get(n, {}).get("connection_id")), None)
mint(client, operation, repair, reinitiate=True, actor=Actor.backend_watcher, connection_id=account)
_mark_misrouted(operation)
return prepare
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, "connection_id": str | None}}`` for an aliased target, read from
that account's own row (``connection_id`` is None when no account has that name yet); ``None``
when no target is aliased. A failed read raises: guessing "not connected" would send the user
through a new login for an account that may be healthy."""
aliased = [t for t in targets if t.alias]
if not aliased:
return None
rows = portal_accounts()
status: dict[str, dict[str, Any]] = {}
for t in aliased:
mine = [r for r in rows if r.get("connector") == t.name and not r.get("disabled")]
row = next((r for r in mine if r.get("alias") == t.alias), None)
if row is None:
# An unnamed account is addressed by its label, as rename does; it stays unnamed.
row = next((r for r in mine if not r.get("alias") and r.get("label") == t.alias), None)
if row is not None:
t.alias, t.repair_id = None, row.get("connectionId")
status[t.name] = {"connected": bool(row) and row.get("status") == "active",
"connection_id": row.get("connectionId") if row else None}
return status
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"
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."
)
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 ToolGatewayError 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:
entry = {"alias": row.get("alias"), "label": row.get("label"), "status": row.get("status"),
"active": row.get("active")}
if row.get("disabled"):
entry["retired"] = True
by_slug.setdefault(str(row.get("connector", "")).lower(), []).append(entry)
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; address an unnamed account by its label. A retired account was "
"replaced by a reconnect and is kept only for the record.")}, 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
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,
targets: list[HostedTarget],
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:
if action == "rename":
return _rename_result(targets[0])
client = (client_factory or managed_client)()
if action == "status":
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."
)
force = bool(args.get("force", False))
session_key = operation_session_key(session_id)
if connection_callback is None:
return _no_card_result(client, action, targets, force, session_key)
return run_operation(
_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,
)
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."
)