Desktop connect runs on the connection operation: one card, no link to the model, no renderer polling (NS-868) (#110574)

* refactor(connectors): cut comments that restate the code

Connector modules (tools/connectors, tui_gateway connector RPCs, desktop
connector card/store) keep only comments that carry a non-derivable why or
a cross-module contract. No behaviour change.

* feat(connectors): managed connect runs on the connection operation

Managed `connect` / `reconnect` mint one ConnectionOperation for every target and, on a
desktop session, block the tool turn until the operation settles; the result is per-target
outcomes and never carries a connect link. Off the desktop the result carries the links and
returns at once (PR3 delivers them as their own message).

Why: the previous leg handed the model a URL and a `wait` verb, and the renderer ran its own
2s poller on top of the backend's 5s one; both walked the whole gateway catalog at two vendor
calls per page to read one row (~3 Composio calls/s per pending target). A hidden composer
message started the model's `wait` on the user's behalf. None of it was observable from the
operation the MCP leg already used.

What the operation looks like now:
- `contract.py`: TargetState / Actor / SettleReason enums and the `(kind, from) -> {to: actor}`
  transition table. `operation.transition()` enforces it; a card cannot claim a managed
  target `connected`, only the backend watcher can.
- `live.py`: one open operation per session, found by `op_id`. `connectors.operation.status`
  reads it, `connection.respond` drives it, `pending_connection` on resume replays it.
- `run.py`: the one lifecycle for both target kinds (prepare -> card -> wake/observe loop ->
  settle -> result). The managed `observe` hook polls the gateway list once per tick for the
  whole operation; the exact-status route replaces that call when the gateway ships it.
- `connection.update` is emitted on every transition and on settlement; registered in the
  shared event contract with the operation vocabulary typed on the TS side.
- `wait`, `_rendered_links`, `_seen_instructions`, the just-minted bounce and `_clamp_timeout`
  are deleted. `force` on `reconnect` always reinitiates; plain `reconnect` repairs only what
  the gateway reports disconnected.
- `connections.wait_timeout_seconds` is removed from config defaults, the example and the
  docs. The deadline is `OPERATION_DEADLINE_SECONDS = 300` in `operation.py`; the key was
  added on this unmerged train so no migration is needed.
- Wire model: `statusReason` parsed on connection results; the seven-state `connectionStatus`
  is typed on list items and an unknown value fails validation; `CONNECTION_REQUIRED` carries
  `connect_card_available` instead of the link when the session platform is `desktop`.

Session platform, not callback presence, decides whether a card exists: the GUI bridge
attaches callbacks to every backend session, terminal TUI included.

* feat(desktop): connector card subscribes to the connection operation

The card renders from the backend's operation instead of driving its own: `connector-flow.ts`
(the renderer's 2s `connectors.list` poller, its 120s client deadline and `keepWaiting`) is
deleted, and both hidden composer submits in `connector-tool.tsx` go with it. The model is
never nudged into a `wait`; the tool call is blocked on the backend until the operation
settles.

- `connection-request.ts` is the operation store: keyed by `op_id`, one entry per session,
  `applyOperationStatus` / `applyConnectionUpdate` as pure reducers, `respond` leaves the
  entry in place (the backend answers with `connection.update`), `ConnectionTargetOutcome`
  is a discriminated union the backend's transition table accepts.
- `input-requests.ts` applies `connection.update`; `connection.expire` and the resume
  snapshot correlate by `op_id` (a snapshot has no `request_id`).
- `ConnectorOffer` renders one `ConnectorCard` per target from a single
  `Record<ConnectionTargetState, phase>` table; Connect opens the stored link, Try again on
  failed / expired reissues through `connectors.connect` on the open operation, Not now is a
  per-target `skipped`, Continue settles. A settled operation renders `ConnectorSummary` rows
  with no live control.
- `tool-render-class.ts`: `manage_connections` renders the card regardless of
  `HERMES_GUEST_ONBOARDING`; the flag still gates the onboarding flow, not the card. The
  backend gate already decided admission; a card only exists because the tool was admitted.
- `mcp-setup-tool.tsx` speaks the same outcome vocabulary (connected / skipped / failed).
- `ConnectorRow.connectionStatus` is the seven-state literal union, not `string | null`.
- The guided-onboarding poller (`first-build-connectors.ts`) keeps its own row/phase types
  and compiles unchanged; PR3 moves it onto the operation.

anti-slop: no net-new findings (17 touched files vs 11d1a12472).

* fix(connectors): the card never parks the tool thread; every update carries the snapshot

Found by the pre-PR adversarial review and a real-path E2E test (both left in the tree).

- The desktop `connection_callback` was still `_block("connection.request", ...)`, which parked
  the tool thread on a private request-id Event until a `_respond` that no longer exists for
  this event. `connection.respond` settled the operation but the tool waited its full deadline
  before the watcher loop even started. The callback now only emits the card; the operation's
  own wake loop is the wait. The MCP leg's blocking bridge goes with it: the card answers
  through `connection.respond` like every other card.
- `connection.request` and every `connection.update` frame carry the full target snapshot
  (state, link, detail). The initial mint happened before the card existed, so the renderer
  never saw the links and Connect stayed disabled; a Continue settlement stamped
  `not_connected` on the backend while the card still showed `initiated`. The store now
  overlays the snapshot; no state is reconstructed from deltas.
- The `connection.update` emitter is a class-level `on_change` slot on the operation, set
  once by `register()` (a second `register()` no longer stacks wrappers); session lookup takes
  `_sessions_lock`; a re-minted link on an `initiated` target goes through `refresh_link()`
  and emits, instead of a bare attribute write.
- `session.interrupt` is checked before the first observe, so an interrupted call settles
  `interrupt`, not `all_resolved`.
- A gateway list reporting `expired` for an initiated target is recorded with actor `clock`
  (the contract's owner of that edge); it raised `IllegalTransition` before.
- Dead `keepWaiting` i18n keys from the deleted renderer poller removed.

tests/tui_gateway/test_connector_operation_e2e.py runs the desktop lifecycle through the real
tool, registry, gateway RPC handlers and callback bridge with only the HTTP client faked.

* docs(connectors): prompts and docs describe the operation, not the deleted wait verb

The onboarding prompts told the model to call action="wait" with timeout_seconds and to
expect a hidden [setup]/[connectors] note; both are gone. tool-search.md and
toolsets-reference.md said the model gets a connect link on the desktop. tui_gateway/AGENTS.md
gains the connection-operation row of the surface table.

* fix(connectors): the panel re-mints only a dead link

Try again on a failed or expired target mints a fresh link on the open operation. A waiting
target keeps the link it was minted with; the card reopens it and connectors.connect refuses
to spend a second mint (LINK_STILL_VALID). The unused refresh_link() goes. The package
docstring names the new siblings; the nine-name public surface is unchanged.

* test(connectors): the local-batch test answers the operation the way the card does

The callback stopped returning an answer in f782b26d98 (the card answers through
connection.respond); this test still returned one and waited out the 300s deadline in CI.

* ci: retrigger

* fix(connectors): the desktop card appears outside guided onboarding

Live on a signed-in macOS desktop, the two-app connect never showed a card. Three
defects, each hidden by a test that bound state the running app never binds.

The backend read the surface from HERMES_SESSION_PLATFORM only. The desktop and TUI
gateway bind it as HERMES_SESSION_SOURCE (_set_session_context), so session_platform()
was "" and managed connects took the off-desktop branch: links in the model's message,
no operation. session_platform() now reads platform, then source. The E2E test binds
through server._set_session_context instead of set_session_vars(platform="desktop").

The renderer routed manage_connections to the card only under isOnboardingEnabled(),
the HERMES_GUEST_ONBOARDING launch flag, in message-parts.tsx and the run splitter in
fallback.tsx. tool-render-class.ts had already dropped that gate in this PR; the two
routers had not. Both now route on the tool name alone.

ConnectorTool resolved the session owner by the runtime id. Owner routes, hints and
session rows are keyed by the stored id, so in registry topology the owner never
resolved and the card rendered null while the tool blocked. It now resolves by the
stored id, matching the PR1.5 card and every other owner lookup.

message-parts-connectors.test.tsx mounts the real Fallback router with the onboarding
flag off and distinct runtime/stored ids; red before each renderer fix, green after.

* style(connectors): shorter comments, no module mock in the card router test

The router test mocked isOnboardingEnabled to false; jsdom has no preload bridge, so the
real function already returns false. Comments that restated the code are cut to one line.

anti-slop: no net-new findings (25 touched files)

* fix(connectors): Connect on a waiting row opens the stored link

ConnectorCard derived the button's loading state from the phase label, so a managed row that
read "Finish connecting in your browser" (every row, since links are minted up front) had a
disabled Connect button. Nothing on the desktop could open the sign-in link; every managed
connect ended skipped, not_connected, or at the deadline.

The card now takes `busy` for "the action itself is running" and keeps `phase` as a label.
The MCP card passes its in-flight flag; the connector card passes the re-mint wait. Red before:
the Connect button on an initiated row rendered disabled and a click opened nothing.

* fix(connectors): a settled card stays dead; the card binds to its tool call only

A second connect for the same apps revived the finished card on the old tool row. The
connection.request payload carried no id, so the renderer fell back to matching rows by
connector names, and any row with those names qualified, settled or not.

The operation now records the model's tool_call_id and sends it in connection.request and in
the resume snapshot. The card binds to the tool row with that id and to nothing else; the
name-match fallback is deleted. A payload without the id is rejected by the store.

`reason` is removed from the tool: it was the only text the card ever showed from the model
and its absence forked a second tool part, since `reason` doubled as the row-correlation key
in tool-parts.ts. The card never needed it.

`connection.expire` is deleted from the contract and from _EXPIRING_REQUESTS: the card is
raised with _emit, not _block, so nothing has emitted it since the operation lifecycle landed.

Sid's rule of record: a resolved card is fully dead; no path brings it back.

* fix(connectors): the watch loop settles once, on time, and never raises into the result

Three findings from the live review, one loop.

Continue racing a finished sign-in: the loop ran the gateway read, then settled. A read that
returned `connected` for an already-settled or failed target raised IllegalTransition out of
the tool and the model got a generic error instead of the per-app outcomes. The read now skips
targets that are not live (pending, initiated) and skips a settled operation; the loop checks
`settled` after every read.

Settle reason as row text: `settle()` wrote `continue`/`deadline` into each unresolved target's
`detail`, and the card printed it in red. The reason stays on the operation only.

Stop and the deadline waited for the next tick: `/stop` sets a per-thread flag with no wake
hook, so the sleep is sliced at 250 ms and the flag and clock are read each slice. The clock is
also checked before each read, not only after.

Tests: a failed mint that later reads connected settles cleanly; Continue during a read keeps
the settled result; no reason in detail; an interrupt settles within the same second.

* fix(connectors): MCP setup off the desktop returns unavailable instead of blocking

run_mcp_operation treated a non-None connection_callback as "a card exists". Every tui_gateway
session has that callback, the Ink TUI included, so an MCP install from the terminal UI blocked
until the 300 s deadline while the docs promised `unavailable` with the terminal commands.

The MCP path now reads the session surface the same way the managed path does; the callback is
never the predicate. Test binds the surface to `tui` with the callback attached.

* fix(connectors): a failed Try again shows the failure, not the old dead link

The panel's re-mint ignored the gateway's per-app status and moved the row to `initiated` with
whatever link came back, `None` included, so a mint that failed again rendered as waiting on the
link that had already died.

One reader of a mint response now serves both the first mint and Try again
(`managed.mint`, with the actor as a parameter). A repeated failure keeps the row `failed`,
drops the link, and carries the vendor's new text through `operation.refresh`, which emits a
frame without a state change so the card redraws.

* fix(connectors): a forced reconnect waits for the new sign-in before it reports connected

`reconnect` with `force: true` is the account switch. The vendor keeps the old account active
while the new link waits, so the first list read after the mint said `connected` and the
operation settled at once: the new link was dropped and the model was told the switch was done.

A forced target is marked awaiting_new_attempt after the mint. The watcher ignores its row until
the list shows the new attempt (`connectionStatus: initiated`) once, then trusts `connected`.

* fix(connectors): the operation registers under the gateway session key

The tool registered the operation under the agent's session_id; every RPC (connection.respond,
connectors.operation.status, the panel's connectors.connect) and the update emitter looked it up
by the gateway's session key. Those agree until compaction rotates the agent id mid-turn; then
the card's clicks find nothing, no update reaches it, and the tool waits out the deadline.

The registration key is now the bound HERMES_SESSION_KEY, with the agent id as the fallback for
callers with no gateway (unit tests, a bare CLI). The E2E passes a rotated agent id and drives
the card by the gateway key.

* fix(connectors): the forced-reconnect gate reads any non-active row; a failed re-mint of an expired row is failed

Three follow-ups from the verification of the fix pass.

The awaiting_new_attempt gate cleared only on the literal `connectionStatus: initiated`. The
field is optional on the wire and `initializing`, `failed`, `expired` are valid values, so a
forced reconnect could wait the full 300 s and swallow a failed new attempt. The gate now holds
only while the row still reads as the old account (`connected` or `active`) and releases on
anything else.

Try again on an `expired` row whose re-mint fails raised IllegalTransition (no expired → failed
edge). The re-mint steps through `initiated` as the user's attempt, then `failed`, then drops the
dead link.

`detail` never carries a state name any more: `failed` as detail rendered as the row label and
made agent/display.py tag the settled result as a tool error. Only vendor text goes there.

`connection.expire` removed from the renderer's unscoped-stream set; nothing emits it.
This commit is contained in:
Siddharth Balyan authored and GitHub committed 2026-09-15 00:41:14 +05:30
1 parent d105376b21
commit ee2f5629b8
84 files changed
+4079 -2961

No files matched your search

+1 -2
View File
@@ -156,7 +156,7 @@ def _manage_connections(agent, args: dict, ctx: InlineToolContext) -> Any:
from tools.connectors.gateway import config as gateway_config
return manage_connections(
args, session_id=getattr(agent, "session_id", None),
args, session_id=getattr(agent, "session_id", None), tool_call_id=ctx.tool_call_id,
connection_callback=getattr(agent, "connection_callback", None),
connectors_available=gateway_config.connectors_available,
)
@@ -168,7 +168,6 @@ def _setup_mcp_shim(agent, args: dict, ctx: InlineToolContext) -> Any:
return _manage_connections(agent, {
"action": args.get("action", "install"),
"connectors": [{"name": args.get("server", ""), "mcp": True}],
"reason": args.get("reason", ""),
}, ctx)
+1 -1
View File
@@ -786,7 +786,7 @@ def _resolve_sequential_tool_timeout() -> float | None:
# 420 s deadline every real batch "timed out" while its children ran on as orphans, and the orchestrator
# spent the following hours polling transcripts (measured: 332 timeouts, ~$4k of orchestrator turns in
# one run).
# ``manage_connections`` waits on connections.wait_timeout_seconds; the generic deadline
# ``manage_connections`` waits on the connection operation's own deadline; the generic deadline
# would return tool_timeout while its approval card is still open.
_SEQUENTIAL_DEADLINE_EXEMPT_TOOLS = frozenset({"delegate_task", "manage_connections"})
@@ -1,7 +1,12 @@
import type { ConnectionRequestPayload, ConnectionUpdatePayload, GatewayEvent } from '@hermes/shared'
import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'
import { connectionRequestToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-connection'
import { translateNow } from '@/i18n'
import { settlePendingClarifyToolCall } from '@/lib/chat-messages'
import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'
import { $connectionRequests, clearConnectionRequest } from '@/store/connection-request'
import { normalizeConnectionRequest, setConnectionRequest, updateConnectionRequest } from '@/store/connection-request'
import { dispatchNativeNotification } from '@/store/native-notifications'
import {
$approvalRequests,
$secretRequests,
@@ -20,6 +25,15 @@ import { forgetServerRequest } from '@/store/server-requests'
import type { GatewayEventContext } from './types'
type ConnectionRequestEvent = GatewayEvent<'connection.request'> & { payload: ConnectionRequestPayload }
type ConnectionUpdateEvent = GatewayEvent<'connection.update'> & { payload: ConnectionUpdatePayload }
const isConnectionRequestEvent = (event: GatewayEvent): event is ConnectionRequestEvent =>
event.type === 'connection.request' && event.payload !== undefined
const isConnectionUpdateEvent = (event: GatewayEvent): event is ConnectionUpdateEvent =>
event.type === 'connection.update' && event.payload !== undefined
/** The blocking-input family arrives as server→client REQUESTS (see
* `server-requests.ts`); the one EVENT in the family is `request.cancel`, the
* backend withdrawing an open request (timeout / interrupt / session close):
@@ -29,6 +43,39 @@ import type { GatewayEventContext } from './types'
export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, occurredAt } = ctx
if (isConnectionRequestEvent(event)) {
// Park per-session and upsert a stable tool row so the card renders even if tool.start was missed.
const request = normalizeConnectionRequest(event.payload, sessionId ?? null)
if (request) {
setConnectionRequest(request)
if (sessionId) {
deps.upsertToolCall(sessionId, connectionRequestToolPayload(request), 'running')
deps.updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: request.targets.map(target => target.name).join(', '),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
return true
}
if (isConnectionUpdateEvent(event)) {
updateConnectionRequest(sessionId ?? null, event.payload)
if (event.payload.settled && sessionId) {
deps.updateSessionState(sessionId, state => ({ ...state, needsInput: false }))
}
return true
}
if (event.type !== 'request.cancel') {
return false
}
@@ -81,12 +128,6 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
clearVaultSaveLoginRequest(sessionId, id)
} else if ($vaultUnlockRequests.get()[key]?.requestId === id) {
clearVaultUnlockRequest(sessionId, id)
} else if ($connectionRequests.get()[key]?.requestId === id) {
clearConnectionRequest(id, sessionId)
if (sessionId) {
deps.updateSessionState(sessionId, state => ({ ...state, needsInput: false }))
}
}
return true
@@ -1,8 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $connectionRequests } from '@/store/connection-request'
import { resetServerRequestsForTests } from '@/store/server-requests'
import { $toursEnabled } from '@/store/tours'
import { handleServerRequest } from './server-requests'
@@ -24,26 +22,22 @@ function deliver(method: string, params: Record<string, unknown>, activeSessionI
}
describe('connection request routing', () => {
afterEach(() => {
$connectionRequests.set({})
resetServerRequestsForTests()
})
it('does not route connection operations through the server-request rail', () => {
const { handled, respond } = deliver(
'connection',
{
deadline_at: 1_800_000_000,
op_id: 'op-1',
session_id: 'session-a',
targets: [{ action: 'install', kind: 'mcp', name: 'linear' }],
timeout_seconds: 60,
tool_call_id: 'call-1'
},
'session-a'
)
it('parks a connection request and replaces a replayed request with the same id', () => {
const params = {
deadline_at: 1_800_000_000,
op_id: 'op-1',
reason: 'Install Linear',
session_id: 'session-a',
targets: [{ action: 'install', kind: 'mcp', name: 'linear' }],
timeout_seconds: 60
}
expect(deliver('connection', params, 'session-a').handled).toBe(true)
expect($connectionRequests.get()['session-a']).toMatchObject({ opId: 'op-1', requestId: 'srq-1' })
expect(deliver('connection', { ...params, op_id: 'op-2' }, 'session-a').handled).toBe(true)
expect($connectionRequests.get()['session-a']?.opId).toBe('op-2')
expect(handled).toBe(false)
expect(respond).not.toHaveBeenCalled()
})
})
@@ -1,15 +1,11 @@
import type { ServerRequestMap } from '@hermes/shared'
import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'
import { connectionRequestToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-connection'
import { translateNow } from '@/i18n'
import { restorePendingClarifyToolCall } from '@/lib/chat-messages'
import type { PreviewActAction } from '@/lib/preview-act/act-in-page'
import type { TourAction, TourStep } from '@/lib/tour'
import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import { normalizeConnectionRequest, setConnectionRequest } from '@/store/connection-request'
import type { ScopedServerRequest } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import {
@@ -44,11 +40,6 @@ const loadPreviewEngine = () => {
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
/** The params of a request whose method the handler table already matched: the backend validated
* them against `ServerRequestMap[M]['params']` before sending, so the method name is the contract. */
const paramsOf = <M extends keyof ServerRequestMap>(request: ScopedServerRequest, _method: M) =>
request.params as unknown as ServerRequestMap[M]['params']
/** Answer a string-valued request with a JSON-encoded result ('' = nothing / unavailable). */
const answerValue = (request: ScopedServerRequest, result: unknown) =>
request.respond({ value: result ? JSON.stringify(result) : '' })
@@ -265,27 +256,6 @@ const vaultUnlockPrompt: Handler = ctx => {
notifyInput(ctx, translateNow('prompts.vaultUnlockTitle', displayName))
}
const connection: Handler = ctx => {
const { deps, request, sessionId } = ctx
const entry = normalizeConnectionRequest(paramsOf(request, 'connection'), request.id, sessionId || null)
if (!entry) {
request.respond({ settled_by: 'all_resolved', targets: [] })
return
}
rememberServerRequest(request)
setConnectionRequest(entry)
if (sessionId) {
deps.upsertToolCall(sessionId, connectionRequestToolPayload(entry), 'running')
}
markNeedsInput(ctx)
notifyInput(ctx, entry.reason || entry.targets.map(target => target.name).join(', '))
}
// ── Desktop-surface bridges (answered immediately, no card) ─────────────────
const terminalRead: Handler = ({ request }) => {
@@ -401,7 +371,6 @@ const tour: Handler = ({ isActiveSession, request, sessionId }) => {
export const SERVER_REQUEST_HANDLERS: Record<string, Handler> = {
approval,
clarify,
connection,
'preview.act': previewAct,
'preview.read': previewRead,
secret,
@@ -28,6 +28,7 @@ import { setSessionYolo } from '@/lib/yolo-session'
import { $clarifyRequests } from '@/store/clarify'
import { migrateSessionDraft } from '@/store/composer'
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
import { $connectionRequests } from '@/store/connection-request'
import {
openGatewayForAgent,
openGatewayForProfile,
@@ -140,6 +141,7 @@ import { singleFlightSessionResume } from '../use-prompt-actions/single-flight-r
import { sessionCreateOverrideParams, type SessionCreateOverrides, type SessionSeedMessage } from './create-overrides'
import { pendingClarifyToolPayload, restorePendingClarifyFromSnapshot } from './restore-pending-clarify'
import { projectPendingConnection, restorePendingConnectionFromSnapshot } from './restore-pending-connection'
import {
createPersistedDisplayTranscriptProvenance,
hasPersistedDisplayTranscriptProvenance,
@@ -338,6 +340,15 @@ interface FreshSessionDraftOptions {
workspaceTarget?: NewChatWorkspaceTarget
}
/** Session-state patch for a restored blocking prompt row; the first non-null projection is used. */
function livePromptStreamId(
...projections: ({ streamId: string } | null)[]
): { awaitingResponse: false; sawAssistantPayload: true; streamId: string } | Record<string, never> {
const live = projections.find(Boolean)
return live ? { awaitingResponse: false, sawAssistantPayload: true, streamId: live.streamId } : {}
}
function restorePendingApproval(response: SessionResumeResult, sessionId: string): boolean {
const pending = response.pending_approval
@@ -1201,6 +1212,7 @@ export function useSessionActions({
const activateStartedAt = Date.now() / 1000
const activateBaselineState = sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) ?? cachedViewState
const clarifyRequestIdAtActivateStart = $clarifyRequests.get()[cachedRuntimeId]?.requestId
const connectionOpIdAtActivateStart = $connectionRequests.get()[cachedRuntimeId]?.opId
try {
activated = await requestForSession<SessionResumeResult>('session.activate', {
@@ -1251,6 +1263,13 @@ export function useSessionActions({
const pendingClarify = pendingClarifyState.request
const pendingConnection = restorePendingConnectionFromSnapshot(
activated,
cachedRuntimeId,
activateStartedAt,
connectionOpIdAtActivateStart
).request
const clarifyAuthoritativelyAbsent =
pendingClarifyState.authoritativeAbsent && !$clarifyRequests.get()[cachedRuntimeId]
@@ -1316,6 +1335,7 @@ export function useSessionActions({
needsInput:
pendingApproval ||
Boolean(pendingClarify) ||
Boolean(pendingConnection) ||
(clarifyAuthoritativelyAbsent ? false : state.needsInput),
// Adopting someone else's turn: we'll stream its reply
// without ever having received its prompt, so the settle
@@ -1426,8 +1446,16 @@ export function useSessionActions({
)
: null
const pendingConnectionProjection = projectPendingConnection(
pendingClarifyProjection?.messages ?? clearedClarifyProjection?.messages ?? activatedMessages,
pendingConnection
)
const visibleActivatedMessages =
pendingClarifyProjection?.messages ?? clearedClarifyProjection?.messages ?? activatedMessages
pendingConnectionProjection?.messages ??
pendingClarifyProjection?.messages ??
clearedClarifyProjection?.messages ??
activatedMessages
releaseTranscriptView()
@@ -1450,13 +1478,7 @@ export function useSessionActions({
acceptedPersistedDisplayTranscript || hasValidProvenance
? (expectedProvenance ?? undefined)
: undefined,
...(pendingClarifyProjection
? {
awaitingResponse: false,
sawAssistantPayload: true,
streamId: pendingClarifyProjection.streamId
}
: {}),
...(livePromptStreamId(pendingConnectionProjection, pendingClarifyProjection)),
...(clearedClarifyProjection
? {
streamId: state.busy ? (clearedClarifyProjection.streamId ?? state.streamId) : null
@@ -1806,6 +1828,8 @@ export function useSessionActions({
const pendingApproval = restorePendingApproval(resumed, resumed.session_id)
const pendingClarifyState = restorePendingClarifyFromSnapshot(resumed, resumed.session_id, resumeStartedAt)
const pendingClarify = pendingClarifyState.request
const pendingConnection = restorePendingConnectionFromSnapshot(resumed, resumed.session_id, resumeStartedAt).request
const clarifyAuthoritativelyAbsent =
pendingClarifyState.authoritativeAbsent && !$clarifyRequests.get()[resumed.session_id]
@@ -1833,8 +1857,16 @@ export function useSessionActions({
)
: null
const pendingConnectionProjection = projectPendingConnection(
pendingClarifyProjection?.messages ?? clearedClarifyProjection?.messages ?? messagesForView,
pendingConnection
)
const visibleMessagesForView =
pendingClarifyProjection?.messages ?? clearedClarifyProjection?.messages ?? messagesForView
pendingConnectionProjection?.messages ??
pendingClarifyProjection?.messages ??
clearedClarifyProjection?.messages ??
messagesForView
// The eagerly painted REST page is persisted-display authority: stamp
// its provenance so the next warm switch to this session paints it
@@ -1861,7 +1893,9 @@ export function useSessionActions({
turnLive: state.turnLive || resumedRunning,
needsInput:
pendingApproval ||
Boolean(pendingClarify) || (clarifyAuthoritativelyAbsent ? false : state.needsInput),
Boolean(pendingClarify) ||
Boolean(pendingConnection) ||
(clarifyAuthoritativelyAbsent ? false : state.needsInput),
adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning,
...(inFlightRecovery.applied
? {
@@ -1874,13 +1908,7 @@ export function useSessionActions({
: {
turnStartedAt: resumedRunning && resumedTurnStartedAt !== null ? resumedTurnStartedAt : null
}),
...(pendingClarifyProjection
? {
awaitingResponse: false,
sawAssistantPayload: true,
streamId: pendingClarifyProjection.streamId
}
: {}),
...(livePromptStreamId(pendingConnectionProjection, pendingClarifyProjection)),
...(clearedClarifyProjection
? {
streamId: resumedRunning ? (clearedClarifyProjection.streamId ?? state.streamId) : null
@@ -1,16 +1,58 @@
import { type ChatMessage, type GatewayEventPayload, restorePendingBlockingToolCall } from '@/lib/chat-messages'
import type { ConnectionRequest } from '@/store/connection-request'
import {
$connectionRequests,
clearConnectionRequest,
type ConnectionRequest,
normalizeConnectionRequest,
setConnectionRequest
} from '@/store/connection-request'
import type { SessionResumeResult } from '@/types/hermes'
export interface PendingConnectionResumeState {
authoritativeAbsent: boolean
cleared: ConnectionRequest | null
request: ConnectionRequest | null
}
/** Restore a pending connection card from a resume snapshot. A missing snapshot clears only
* requests that existed before the RPC started. */
export function restorePendingConnectionFromSnapshot(
response: Pick<SessionResumeResult, 'pending_connection'>,
sessionId: string,
resumeStartedAt: number,
opIdAtStart?: string
): PendingConnectionResumeState {
const request = normalizeConnectionRequest(response.pending_connection, sessionId)
if (!request) {
const current = $connectionRequests.get()[sessionId]
const existedAtStart = Boolean(current && opIdAtStart && current.opId === opIdAtStart)
const definitelyOlder = Boolean(current?.receivedAt !== undefined && current.receivedAt < resumeStartedAt)
if (current && (existedAtStart || definitelyOlder)) {
clearConnectionRequest(current.opId, sessionId)
return { authoritativeAbsent: true, cleared: current, request: null }
}
return { authoritativeAbsent: true, cleared: null, request: null }
}
setConnectionRequest(request)
return { authoritativeAbsent: false, cleared: null, request }
}
/** Tool row for a pending operation whose `tool.start` event was missed. */
export function connectionRequestToolPayload(request: ConnectionRequest): GatewayEventPayload & { name: string } {
return {
args: {
action: request.targets[0]?.action ?? 'install',
connectors: request.targets.map(target => ({ mcp: target.kind === 'mcp', name: target.name })),
reason: request.reason
action: request.targets[0]?.action ?? (request.targets[0]?.kind === 'connector' ? 'connect' : 'install'),
connectors: request.targets.map(target => ({ mcp: target.kind === 'mcp', name: target.name }))
},
name: 'manage_connections',
tool_id: request.requestId
tool_id: request.toolCallId
}
}
@@ -751,7 +751,7 @@ type LiveSessionProjection = Pick<SessionResumeResult, 'inflight' | 'queued' | '
[safelyPersistedInflightUser]?: true
}
type ReconciledSessionResumeResponse = SessionResumeResult & {
type ReconciledSessionResumeResult = SessionResumeResult & {
[safelyPersistedInflightUser]?: true
}
@@ -1006,7 +1006,7 @@ export function dedupeInflightUserAgainstTranscript(
persistedMessages: ChatMessage[],
runtimeMessages: ChatMessage[],
projection: SessionResumeResult
): ReconciledSessionResumeResponse {
): ReconciledSessionResumeResult {
const inflightUser = projection.inflight?.user?.replace(/\s+/g, ' ').trim() ?? ''
if (!inflightUser) {
@@ -0,0 +1,173 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { connectionRequestOwnsPart, ConnectorOffer, ConnectorTool } from '@/components/assistant-ui/connector-tool'
import { I18nProvider } from '@/i18n'
import { $connectionRequests, type ConnectionRequest, setConnectionRequest } from '@/store/connection-request'
import { $gateway } from '@/store/gateway'
import { _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
const SESSION_ID = 'session-1'
const OWNER = { connectionId: 'connection-1', profile: 'default' }
const REQUEST: ConnectionRequest = {
deadlineAt: 1_800_000_000,
opId: 'operation-1',
toolCallId: 'connector-call-1',
sessionId: SESSION_ID,
settled: false,
settledBy: null,
targets: [
{
action: 'connect',
connectUrl: 'https://connect.example/gmail',
detail: '',
kind: 'connector',
name: 'gmail',
state: 'pending',
tools: []
}
]
}
function props(): ToolCallMessagePartProps {
const args = { action: 'connect', connectors: ['gmail'] }
return {
addResult: vi.fn(),
args,
argsText: JSON.stringify(args),
isError: false,
respondToApproval: vi.fn(),
result: undefined,
resume: vi.fn(),
status: { type: 'running' },
toolCallId: 'connector-call-1',
toolName: 'manage_connections',
type: 'tool-call'
}
}
function view(sessionId: string): SessionView {
return {
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(false),
$model: atom(''),
$provider: atom(''),
$reasoningEffort: atom(''),
$runtimeId: atom(sessionId),
$storedId: atom(sessionId),
$turnStartedAt: atom(null),
kind: 'primary'
}
}
function renderOffer(request = REQUEST) {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ConnectorOffer owner={OWNER} request={request} />
</I18nProvider>
)
}
function renderConnector(request = REQUEST) {
setSessionOwnerHint(SESSION_ID, OWNER)
setConnectionRequest(request)
return render(
<I18nProvider configClient={null} initialLocale="en">
<SessionViewProvider value={view(SESSION_ID)}>
<ConnectorTool {...props()} />
</SessionViewProvider>
</I18nProvider>
)
}
afterEach(() => {
cleanup()
$connectionRequests.set({})
$gateway.set(null)
_resetSessionOwnerHintsForTests({ storage: true })
vi.useRealTimers()
vi.clearAllMocks()
})
describe('ConnectorTool operation card', () => {
it('does not call connectors.list or create a timer on mount', () => {
vi.useFakeTimers()
const request = vi.fn()
// SAFETY: the store calls only `request`; the rest of the client is never touched in these tests.
$gateway.set({ request } as never)
renderOffer()
expect(request).not.toHaveBeenCalledWith('connectors.list', expect.anything())
expect(vi.getTimerCount()).toBe(0)
})
it('opens the stored link from Connect on a waiting row, without an RPC', async () => {
const request = vi.fn()
// SAFETY: the store calls only `request`; the rest of the client is never touched in these tests.
$gateway.set({ request } as never)
const openExternal = vi.fn()
// SAFETY: the card reads only `openExternal` from the preload bridge.
window.hermesDesktop = { openExternal } as never
renderConnector({ ...REQUEST, targets: [{ ...REQUEST.targets[0], state: 'initiated' }] })
const connect = await waitFor(() => screen.getByRole('button', { name: 'Connect' }))
expect(connect.hasAttribute('disabled')).toBe(false)
fireEvent.click(connect)
expect(openExternal).toHaveBeenCalledWith('https://connect.example/gmail')
expect(request).not.toHaveBeenCalledWith('connectors.connect', expect.anything())
})
it('sends Not now as a per-target skipped response', async () => {
const request = vi.fn().mockResolvedValue({ status: 'ok' })
// SAFETY: the store calls only `request`; the rest of the client is never touched in these tests.
$gateway.set({ request } as never)
renderConnector()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Not now' })).toBeTruthy()
})
fireEvent.click(screen.getByRole('button', { name: 'Not now' }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('connection.respond', {
op_id: 'operation-1',
result: { targets: [{ name: 'gmail', status: 'skipped' }] },
session_id: SESSION_ID
})
})
})
it('never binds to a tool row from a different call, even for the same apps', () => {
// A second connect for gmail opens a new operation on a new tool_call_id. The old row must stay
// dead: it is matched by id only, never by connector names.
expect(connectionRequestOwnsPart(props(), { ...REQUEST, opId: 'operation-2', toolCallId: 'connector-call-2' })).toBe(false)
expect(connectionRequestOwnsPart(props(), REQUEST)).toBe(true)
})
it('renders settled operations with no live controls', () => {
renderOffer({ ...REQUEST, settled: true, settledBy: 'all_resolved' })
// ScaffoldRow paints every settled tool row as a disabled disclosure button; the contract is
// that nothing is actionable: no enabled button, no Connect / Not now / Continue.
const buttons = [...window.document.querySelectorAll('[data-connector-offer] button')]
expect(buttons.every(button => button.hasAttribute('disabled'))).toBe(true)
expect(screen.queryByRole('button', { name: 'Not now' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull()
})
})
@@ -1,130 +1,103 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import type { ConnectionTargetState } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { isFirstBuildSession } from '@/app/contrib/handoff-receipt'
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
import { FirstBuildConnectorOffer } from '@/components/assistant-ui/first-build-connectors'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
import { ConnectorCard, type ConnectorCardCopy } from '@/components/ui/connector-card'
import { Loader } from '@/components/ui/loader'
import { SearchField } from '@/components/ui/search-field'
import {
ConnectorCard,
type ConnectorCardCopy,
type ConnectorCardOutcome,
type ConnectorCardState,
ConnectorSummary,
outcomeMeta
} from '@/components/ui/connector-card'
import { useI18n } from '@/i18n'
import { connectionRows, connectorCalls, connectorTitle, connectorToolName, recordOf } from '@/lib/connector-tools'
import { cn } from '@/lib/utils'
import { createConnectorFlow } from '@/store/connector-flow'
import { connectorCalls, connectorText, connectorTitle, connectorToolName, recordOf } from '@/lib/connector-tools'
import {
type ConnectionRequest,
type ConnectionTarget,
continueConnectionRequest,
sessionConnectionRequest,
skipConnectionTarget
} from '@/store/connection-request'
import { requestGatewayForAgent } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { isSessionOwnerRoute } from '@/store/session-request-router'
interface ConnectorOwner {
connectionId: null | string
profile: string
}
/** Names requested by a manage_connections part, including an event-projected row. */
function requestedConnectorNames(args: ToolCallMessagePartProps['args']): string[] {
const connectors = recordOf(args).connectors
const entries = Array.isArray(connectors) ? connectors : [connectors]
return entries.flatMap(entry => {
const row = recordOf(entry)
const name = connectorText(entry) ?? connectorText(row.name) ?? connectorText(row.connector)
const trimmed = name?.trim()
return trimmed ? [trimmed] : []
})
}
function matchingTargetNames(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) {
return false
}
const leftSorted = [...left].sort()
const rightSorted = [...right].sort()
return leftSorted.every((name, index) => name === rightSorted[index])
}
/** The card lives on the tool row whose id opened the operation and on no other. */
export function connectionRequestOwnsPart(props: ToolCallMessagePartProps, request: ConnectionRequest | null): boolean {
return Boolean(request && props.toolCallId === request.toolCallId)
}
export function ConnectorTool(props: ToolCallMessagePartProps) {
const view = useSessionView()
const runtimeId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
const messages = useStore(view.$messages)
const firstBuild = isFirstBuildSession(storedId)
// One live card per offer. Every manage_connections call renders through
// here, but only one of them is the card the user acts on; the rest render
// as settled tool rows. Consecutive calls naming the same apps are one
// exchange: connect, the wait the agent stays in while the user signs in,
// and the status it runs once the connection is active. The card is the
// first call of the last exchange. Using the newest call would turn the
// card into a row during authorization and create a new card below it. A
// catalog listing (status with nothing named) after a targeted ask never
// starts an exchange; it reads state and offers nothing.
const offers = messages
.flatMap(message => message.parts)
.filter(
part =>
part.type === 'tool-call' &&
(part.toolName === 'manage_connections' || connectorCalls(part.toolName, part.args).length > 0)
)
const keyOf = (part: (typeof offers)[number]) =>
part.type === 'tool-call'
? connectionRows(part.args, part.result)
.map(row => row.connector)
.sort()
.join('|')
: ''
const targeted = (part: (typeof offers)[number]) => {
if (part.type !== 'tool-call') {
return false
}
const asked = recordOf(part.args).connectors
return Array.isArray(asked) && asked.length > 0
}
let liveId: string | undefined
let liveKey: string | null = null
let sawTargeted = false
for (const part of offers) {
if (part.type !== 'tool-call') {
continue
}
const key = keyOf(part)
if (sawTargeted && !targeted(part)) {
continue
}
sawTargeted ||= targeted(part)
if (key !== liveKey) {
liveKey = key
liveId = part.toolCallId
}
}
const historical = liveId !== props.toolCallId
// A status call with no target list describes the whole catalog. It answers
// the model's question, so it renders as a tool row; as cards it would put a
// Connect button on every app the gateway knows.
const input = recordOf(props.args)
const $request = useMemo(() => sessionConnectionRequest(runtimeId), [runtimeId])
const request = useStore($request)
const targetNames = requestedConnectorNames(props.args)
const untargetedStatus =
props.toolName === 'manage_connections' &&
(input.action ?? 'status') === 'status' &&
!(Array.isArray(input.connectors) && input.connectors.length > 0)
(recordOf(props.args).action ?? 'status') === 'status' &&
targetNames.length === 0
// Neither kind of part is the live offer, so neither resolves a session
// owner nor polls the gateway.
const inert = historical || untargetedStatus
const [owner, setOwner] = useState<{
storedId: string
runtimeId: string
connectionId: null | string
profile: string
} | null>(null)
const [ownerFailure, setOwnerFailure] = useState<string | null>(null)
const live = !untargetedStatus && connectionRequestOwnsPart(props, request)
// Owner routes and hints are keyed by the stored id, not the runtime id the events carry.
const ownerSessionId = storedId
const [owner, setOwner] = useState<ConnectorOwner | null>(null)
useEffect(() => {
if (!storedId || !runtimeId || inert) {
if (!ownerSessionId || !live) {
setOwner(null)
return
}
let cancelled = false
const ambientProfile = $activeGatewayProfile.get()
void resolveSessionOwner(storedId)
void resolveSessionOwner(ownerSessionId)
.then(scope => {
assertSessionOwnerResolved(scope, { method: 'connectors.list', sessionId: storedId })
assertSessionOwnerResolved(scope, { method: 'connectors.connect', sessionId: ownerSessionId })
if (!cancelled) {
setOwner({
storedId,
runtimeId,
connectionId: isSessionOwnerRoute(scope) ? scope.connectionId : null,
profile: isSessionOwnerRoute(scope) ? scope.profile : scope || ambientProfile
})
@@ -133,122 +106,131 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
.catch(() => {
if (!cancelled) {
setOwner(null)
setOwnerFailure(`${storedId}:${runtimeId}`)
}
})
return () => {
cancelled = true
}
}, [storedId, runtimeId, inert])
const rows = connectionRows(props.args, props.result)
const signature = rows.map(row => row.connector).join('|')
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
}, [live, ownerSessionId])
// The same shape as the TUI: the agent stays inside manage_connections
// action="wait", which blocks the turn and polls the gateway, instead of
// deciding what "not connected" means and building around the app. Each
// card action sends one hidden line so the agent takes the right next call.
// Read through a ref so the flow, memoised on identity, always submits to
// the current composer target rather than the one it was built with. The
// composer handles busy: a hidden request mid-turn steers or queues there.
const nudgeRef = useRef((_text: string) => {})
nudgeRef.current = (text: string) => {
requestComposerSubmit(`[connectors] ${text}`, { displayKind: 'hidden', target })
}
const flow = useMemo(() => {
if (firstBuild || inert || !runtimeId || !owner || owner.storedId !== storedId || owner.runtimeId !== runtimeId) {
return null
}
const seeds = signature ? signature.split('|').map(connector => ({ connector })) : []
return createConnectorFlow(runtimeId, seeds, {
request: (method, params) => requestGatewayForAgent(owner.connectionId, owner.profile, method, params, 45000),
open: async url => {
if (!window.hermesDesktop?.openExternal) {
throw new Error('System browser unavailable')
}
await window.hermesDesktop.openExternal(url)
},
onWaiting: slug =>
nudgeRef.current(
`The user clicked Connect for ${connectorTitle(slug)} and the sign-in is open in their browser. Call manage_connections action="wait" connectors=["${slug}"] now and hold there until it reports connected. Do NOT call connect again — a second link cancels the one they are signing in with. Say nothing until wait returns.`
)
})
}, [runtimeId, owner, storedId, signature, inert, firstBuild])
const { t } = useI18n()
// Ordinary sessions require a click to begin authorization.
useEffect(() => {
if (!flow) {
return
}
return () => flow.dispose()
}, [flow])
useEffect(() => {
if (flow) {
void flow.refresh()
}
}, [flow, props.result])
if (inert) {
if (!live || !request) {
return <ToolFallback {...props} />
}
if (firstBuild && storedId && owner?.storedId === storedId && owner.runtimeId === runtimeId) {
return (
<FirstBuildConnectorOffer
connectionId={owner.connectionId}
part={props}
profile={owner.profile}
runtimeId={owner.runtimeId}
storedId={storedId}
target={view.kind === 'tile' ? `tile:${storedId}` : 'main'}
/>
)
}
if (!flow) {
return (
<p className="text-xs text-muted-foreground">
{ownerFailure === `${storedId}:${runtimeId}` ? t.connectors.ownerMissing : t.connectors.checking}
</p>
)
}
return (
<ConnectorOffer
flow={flow}
key={`${runtimeId}:${signature}`}
onSkipped={slug =>
nudgeRef.current(
`The user chose Not now for ${connectorTitle(slug)}. Do not connect it, do not route around it with another client, credential or CLI for the same app. Continue the task without it, or ask what they want to do.`
)
}
/>
)
return owner ? <ConnectorOffer owner={owner} request={request} /> : null
}
type ConnectorCopy = ReturnType<typeof useI18n>['t']['connectors']
type ConnectorAction = 'none' | 'open' | 'reissue'
interface ConnectorCardPhase {
action: ConnectorAction
cardState: ConnectorCardState
dismissed: boolean
outcome: (target: ConnectionTarget, copy: ConnectorCopy) => ConnectorCardOutcome | undefined
phase: (copy: ConnectorCopy) => string | undefined
requiresUrl: boolean
unresolved: boolean
}
const noOutcome = (): undefined => undefined
const noPhase = (): undefined => undefined
const errorOutcome = (target: ConnectionTarget, copy: ConnectorCopy): ConnectorCardOutcome => ({
detail: target.detail || copy.failed,
status: 'error'
})
const connectedOutcome = (target: ConnectionTarget): ConnectorCardOutcome => ({
status: 'connected',
tools: target.tools
})
const CONNECTOR_CARD_PHASES = {
connected: {
action: 'none',
cardState: 'connected',
dismissed: false,
outcome: connectedOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: false
},
expired: {
action: 'reissue',
cardState: 'needs_auth',
dismissed: false,
outcome: errorOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: true
},
failed: {
action: 'reissue',
cardState: 'not_configured',
dismissed: false,
outcome: errorOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: true
},
initiated: {
action: 'open',
cardState: 'not_configured',
dismissed: false,
outcome: noOutcome,
phase: copy => copy.waiting,
requiresUrl: true,
unresolved: true
},
not_connected: {
action: 'none',
cardState: 'not_configured',
dismissed: false,
outcome: (target, copy) => ({ detail: target.detail || copy.notConnected, status: 'error' }),
phase: noPhase,
requiresUrl: false,
unresolved: true
},
pending: {
action: 'open',
cardState: 'not_configured',
dismissed: false,
outcome: noOutcome,
phase: noPhase,
requiresUrl: true,
unresolved: true
},
skipped: {
action: 'none',
cardState: 'not_configured',
dismissed: true,
outcome: noOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: false
},
unavailable: {
action: 'none',
cardState: 'disabled',
dismissed: false,
outcome: errorOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: true
}
} satisfies Record<ConnectionTargetState, ConnectorCardPhase>
interface ConnectorOfferProps {
flow: ReturnType<typeof createConnectorFlow>
/** Called when the user declines the app with Not now. */
onSkipped: (slug: string) => void
owner: ConnectorOwner
request: ConnectionRequest
}
export function ConnectorOffer({ flow, onSkipped }: ConnectorOfferProps) {
const state = useStore(flow.state)
const { t } = useI18n()
const copy = t.connectors
const [query, setQuery] = useState('')
const active = state.rows.some(row => row.phase === 'opening' || row.phase === 'waiting')
const cardCopy: ConnectorCardCopy = {
function connectorCardCopy(copy: ConnectorCopy): ConnectorCardCopy {
return {
connectAction: copy.connect,
connectTitle: copy.connectTitle,
decline: copy.skip,
envRequired: '',
grantAction: copy.grant,
@@ -264,116 +246,116 @@ export function ConnectorOffer({ flow, onSkipped }: ConnectorOfferProps) {
trustVerified: () => '',
trustVerifiedTip: () => ''
}
}
if (state.loading) {
return <Loader />
export function ConnectorOffer({ owner, request }: ConnectorOfferProps) {
const { t } = useI18n()
const copy = t.connectors
const cardCopy = connectorCardCopy(copy)
const [reissuing, setReissuing] = useState<ReadonlySet<string>>(new Set())
const unresolved = request.targets.some(target => CONNECTOR_CARD_PHASES[target.state].unresolved)
const reissue = async (name: string): Promise<void> => {
setReissuing(current => new Set(current).add(name))
try {
await requestGatewayForAgent(
owner.connectionId,
owner.profile,
'connectors.connect',
{
connectors: [name],
reconnect: true,
session_id: request.sessionId
},
45000
)
} finally {
setReissuing(current => {
const next = new Set(current)
next.delete(name)
return next
})
}
}
const rows = state.rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(query.toLowerCase()))
// A targeted ask ("connect Gmail") is one or two cards, each already a
// complete question. A heading, a disclaimer and a refresh control over them
// read as a settings panel inside the chat. Only a catalog listing, which
// the model gets by asking for status with nothing named, shows that chrome.
const catalog = state.rows.length > 4
// A settled operation is a static per-target summary: no controls, no polling, nothing live.
if (request.settled) {
return (
<div className="my-2 grid min-w-0 max-w-lg gap-1" data-connector-offer>
{request.targets.map(target => {
const phase = CONNECTOR_CARD_PHASES[target.state]
const outcome = phase.outcome(target, copy) ?? { status: 'declined' as const }
return (
<ConnectorSummary
connector={{ name: target.name, title: connectorTitle(target.name) }}
key={target.name}
meta={outcomeMeta(outcome, cardCopy)}
tone={outcome.status === 'connected' ? 'ok' : outcome.status === 'error' ? 'error' : undefined}
/>
)
})}
</div>
)
}
return (
<div className="my-2 grid min-w-0 max-w-lg gap-1" data-connector-offer>
{catalog ? (
<div className="grid gap-0.5 px-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{copy.title}</span>
<Button onClick={() => void flow.refresh()} size="xs" variant="text">
{copy.refresh}
</Button>
</div>
<p className="text-xs text-muted-foreground">{copy.disclaimer}</p>
{request.targets.map(target => {
const phase = CONNECTOR_CARD_PHASES[target.state]
const title = connectorTitle(target.name)
const waitingForReissue = reissuing.has(target.name)
return (
<ConnectorCard
actionDisabled={phase.action === 'none' || waitingForReissue || (phase.requiresUrl && target.connectUrl === null)}
busy={waitingForReissue}
collapseWhenSettled={false}
connector={{
description: copy.describe(title),
name: target.name,
title
}}
copy={cardCopy}
dismissed={phase.dismissed}
key={target.name}
onConnect={() => {
if (phase.action === 'open' && target.connectUrl && window.hermesDesktop?.openExternal) {
void window.hermesDesktop.openExternal(target.connectUrl)
}
if (phase.action === 'reissue') {
void reissue(target.name)
}
}}
onDismiss={() => void skipConnectionTarget(request, target.name)}
otherBusy={reissuing.size > 0 && !waitingForReissue}
outcome={phase.outcome(target, copy)}
phase={phase.phase(copy)}
state={phase.cardState}
variant="avatar"
/>
)
})}
{unresolved ? (
<div className="px-3.5">
<Button onClick={() => void continueConnectionRequest(request)} size="xs" variant="textStrong">
{t.common.continue}
</Button>
</div>
) : null}
{state.error ? (
<p className="flex flex-wrap items-center gap-2 px-1 text-xs text-destructive" role="alert">
{copy.statusError}
<Button onClick={() => void flow.refresh()} size="xs" variant="text">
{copy.retry}
</Button>
</p>
) : null}
{!state.available && !state.error ? (
<p className="px-1 text-xs text-muted-foreground">{copy.unavailable}</p>
) : null}
{catalog ? <SearchField onChange={setQuery} placeholder={copy.search} value={query} /> : null}
<div className={cn('grid min-w-0', catalog && 'max-h-96 overflow-y-auto')}>
{rows.map(row => (
<div className="grid" key={row.connector}>
<ConnectorCard
actionDisabled={!state.available || row.enabled === false || !!state.error}
collapseWhenSettled={false}
connector={{
name: row.connector,
title: row.name || connectorTitle(row.connector),
description: row.description || copy.describe(row.name || connectorTitle(row.connector))
}}
copy={{
...cardCopy,
connectTitle: copy.connectTitle,
decline: row.phase === 'opening' || row.phase === 'waiting' ? copy.cancel : copy.skip,
connectAction: ['expired', 'revoked'].includes(row.connectionStatus ?? '') ? copy.grant : copy.connect
}}
dismissed={row.phase === 'skipped'}
onConnect={() => void flow.connect(row.connector)}
onDismiss={() => {
const wasPending = ['opening', 'waiting'].includes(row.phase)
flow.skip(row.connector)
// A cancel mid-authorization is not a skip: the agent may
// still be in wait, which reports the timeout to it.
if (!wasPending) {
onSkipped(row.connector)
}
}}
otherBusy={active && !['opening', 'waiting'].includes(row.phase)}
outcome={
row.phase === 'connected'
? { status: 'connected' }
: row.phase === 'error'
? {
status: 'error',
detail:
row.error === 'connect'
? copy.connectError
: row.error === 'unavailable'
? copy.unavailable
: copy.statusError
}
: undefined
}
phase={row.phase === 'opening' ? copy.opening : row.phase === 'waiting' ? copy.waiting : undefined}
state={
row.enabled === false
? 'disabled'
: ['expired', 'revoked'].includes(row.connectionStatus ?? '')
? 'needs_auth'
: 'not_configured'
}
variant="avatar"
/>
{row.phase === 'timeout' ? (
<div className="flex flex-wrap items-center gap-2 px-3.5 text-xs text-muted-foreground">
<span>{copy.timeout}</span>
<Button onClick={() => void flow.keepWaiting(row.connector)} size="xs" variant="textStrong">
{copy.keepWaiting}
</Button>
</div>
) : null}
</div>
))}
{!rows.length && state.available ? <p className="px-1 text-xs text-muted-foreground">{copy.empty}</p> : null}
</div>
</div>
)
}
/** Keep execution output in the standard disclosure, with one row per app call. */
export function ConnectorExecution(props: ToolCallMessagePartProps) {
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
const $request = useMemo(() => sessionConnectionRequest(sessionId), [sessionId])
const request = useStore($request)
const calls = connectorCalls(props.toolName, props.args)
const input = recordOf(props.args)
const batch = Array.isArray(input.calls) ? input.calls : [input]
@@ -390,15 +372,21 @@ export function ConnectorExecution(props: ToolCallMessagePartProps) {
.filter((_call, index) => {
const item = recordOf(props.toolName === 'tool_call' ? results[index] : props.result)
return ['CONNECTION_REQUIRED', 'CONNECTION_EXPIRED', 'AUTH_REQUIRED'].includes(
String(recordOf(item.error).code ?? '')
)
return recordOf(item.error).connect_card_available === true
})
.map(call => {
// SAFETY: connectorCalls includes only names accepted by connectorToolName.
return connectorToolName(call.name)!.connector
})
const openRepair =
request &&
!request.settled &&
matchingTargetNames(
repair,
request.targets.map(target => target.name)
)
return (
<>
{calls.map((call, index) => {
@@ -421,7 +409,7 @@ export function ConnectorExecution(props: ToolCallMessagePartProps) {
/>
)
})}
{repair.length ? (
{openRepair ? (
<ConnectorTool {...props} args={{ action: 'status', connectors: repair }} result={undefined} />
) : null}
</>
@@ -35,28 +35,23 @@ type SetupAction = 'authorize' | 'enable' | 'install'
interface SetupArgs {
server: string
action: SetupAction
reason: string
}
const CATALOG_INSTALL_POLL_MS = 1500
// Thrown by the in-flight flow when the user cancels — the declined respond
// has already been sent, so the catch path must swallow this, not report it.
// The declined response is already sent before this sentinel reaches the catch path.
const CANCELLED = Symbol('mcp-setup-cancelled')
/** First MCP target of a `manage_connections` call; the card renders one server. */
function readSetupArgs(args: unknown): SetupArgs {
const row = parseMaybeObject(args)
const [target] = mcpTargets('manage_connections', row)
return {
action: target?.action ?? 'install',
reason: typeof row.reason === 'string' ? row.reason : '',
server: target?.name ?? ''
}
}
/** The first target's state from the settled operation. */
interface SettledResult {
status?: 'connected' | 'not_connected' | 'skipped' | 'unavailable'
detail?: string
@@ -84,9 +79,6 @@ function readSetupResult(result: unknown): SettledResult {
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
/** The card's strings, from this tool's own copy. The verb changes with the
* action (Install / Enable / Authorize); the rest is the shared consent
* vocabulary every connector card speaks. */
function cardCopy(
copy: ReturnType<typeof useI18n>['t']['assistant']['mcpSetup'],
action: SetupAction
@@ -114,7 +106,6 @@ function cardCopy(
}
export const McpSetupTool = (props: ToolCallMessagePartProps) => {
// Settled → static outcome line (the flow already ran or was declined).
if (props.result !== undefined) {
return <McpSetupSettled {...props} />
}
@@ -125,7 +116,6 @@ export const McpSetupTool = (props: ToolCallMessagePartProps) => {
const McpSetupLive = (props: ToolCallMessagePartProps) => {
const messageRunning = useAuiState(selectMessageRunning)
// Stopped mid-prompt with no result — don't leave a dead interactive panel.
if (!messageRunning) {
return <ToolFallback {...props} />
}
@@ -163,8 +153,6 @@ function McpSetupSettled({ args, result }: ToolCallMessagePartProps) {
const neutral = status === 'skipped' || (status === 'not_connected' && fromResult.detail === 'deadline')
const toolCount = Array.isArray(fromResult.tools) ? fromResult.tools.length : 0
// Settled is scaffolding, the same line a spent connector offer collapses
// to: the name, then the verdict as meta. A failure keeps its reason.
return (
<ConnectorSummary
connector={{ name: server, title: displayName }}
@@ -183,8 +171,7 @@ function McpSetupSettled({ args, result }: ToolCallMessagePartProps) {
function McpSetupPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.mcpSetup
// The tool row is in whichever session's transcript rendered it — read THAT
// session's request (primary or tile), not the globally-active one.
// Use the rendering transcript's session, not the globally active one.
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionConnectionRequest(sessionId), [sessionId])
const request = useStore($request)
@@ -194,19 +181,15 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
const [requestTarget] = request?.targets ?? []
const server = fromArgs.server || requestTarget?.name || ''
const action: SetupAction = fromArgs.action ?? requestTarget?.action ?? 'install'
const reason = fromArgs.reason || request?.reason || ''
const [working, setWorking] = useState(false)
const [envDraft, setEnvDraft] = useState<Record<string, string>>({})
const [entry, setEntry] = useState<McpCatalogEntry | null | undefined>(undefined)
const [envOpen, setEnvOpen] = useState(false)
// Set when the user cancels mid-flight (a stuck OAuth tab, a hung install).
// The in-flight flow checks it at every poll boundary and aborts via the
// CANCELLED sentinel; the declined respond has already been sent by then.
const cancelRef = useRef(false)
// tool.start arrives before the server request; disable the buttons until the request exists.
const ready = Boolean(request?.requestId)
// `tool.start` arrives before `connection.request`.
const ready = Boolean(request)
const respond = useCallback(
async (outcome: ConnectionTargetOutcome) => {
@@ -220,16 +203,14 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
return
}
const success = outcome.state === 'installed' || outcome.state === 'enabled' || outcome.state === 'authorized'
const success = outcome.status === 'connected'
if (success) {
// No reload.mcp: the between-turns refresh registers the new server's tools.
invalidateMcpSuggestionIndex()
}
try {
// One target: this answer settles the operation.
await respondToConnectionRequest(request, { settled_by: 'all_resolved', targets: [outcome] })
try {
await respondToConnectionRequest(request, { targets: [outcome] })
} catch (error) {
notifyError(error, copy.sendFailed)
}
@@ -238,11 +219,10 @@ try {
)
const decline = useCallback(() => {
// While a flow is in flight this is a CANCEL: answer declined right away
// and let the abandoned work notice via cancelRef at its next poll.
// Respond first; cancelRef stops abandoned work at its next poll.
cancelRef.current = true
triggerHaptic('cancel')
void respond({ name: server, state: 'declined' })
void respond({ name: server, status: 'skipped' })
}, [respond, server])
const approve = useCallback(async () => {
@@ -250,8 +230,7 @@ try {
const oauthScope = capabilityScoped()
setWorking(true)
// Poll-boundary abort for the background-install loop; the OAuth flows
// carry their own cancel via completeMcpDesktopOAuth's `cancelled`.
// OAuth owns its cancellation; polling needs an explicit boundary check.
const throwIfCancelled = <T,>(value: T): T => {
if (cancelRef.current) {
throw CANCELLED
@@ -264,7 +243,7 @@ try {
if (action === 'enable') {
await setMcpServerEnabled(server, true)
triggerHaptic('submit')
await respond({ name: server, state: 'enabled' })
await respond({ name: server, status: 'connected' })
return
}
@@ -277,12 +256,11 @@ try {
})
triggerHaptic('submit')
await respond({ name: server, state: 'authorized', tools: (flow.tools ?? []).map(tool => tool.name) })
await respond({ name: server, status: 'connected', tools: (flow.tools ?? []).map(tool => tool.name) })
return
}
// Install from the catalog only. Required credentials are prompted inline first.
let resolved = entry
if (resolved === undefined) {
@@ -292,7 +270,7 @@ try {
}
if (!resolved) {
await respond({ detail: copy.notInCatalog(server), name: server, state: 'error' })
await respond({ detail: copy.notInCatalog(server), name: server, status: 'failed' })
return
}
@@ -300,7 +278,6 @@ try {
const required = resolved.required_env.filter(env => env.required)
if (required.some(env => !envDraft[env.name]?.trim())) {
// Reveal the credential fields; the user approves again once filled.
setEnvOpen(true)
return
@@ -308,8 +285,7 @@ try {
const res = await installMcpCatalogEntry(server, envDraft)
// Git-backed entries clone in the background — poll to completion so a
// non-zero exit surfaces as a real failure instead of a false success.
// Poll background installs so non-zero exits cannot report false success.
if (res.background && res.action) {
for (;;) {
const status = throwIfCancelled(await getActionStatus(res.action, 1))
@@ -327,10 +303,9 @@ try {
}
triggerHaptic('submit')
await respond({ name: server, state: 'installed' })
await respond({ name: server, status: 'connected' })
} catch (error) {
// User cancel: the declined respond is already on the wire — the
// abandoned flow just stops, nothing to report.
// The declined response is already sent; do not report cancellation as failure.
if (error === CANCELLED || error instanceof McpOAuthCancelled) {
return
}
@@ -339,7 +314,7 @@ try {
await respond({
detail: error instanceof Error ? error.message : String(error),
name: server,
state: 'error'
status: 'failed'
})
} finally {
setWorking(false)
@@ -351,12 +326,7 @@ try {
const sourceLine = action === 'install' ? (entry?.url ?? copy.catalogSource) : null
// ⌘/Ctrl+Enter → approve, Esc → decline/cancel. Same accelerators, same
// guard shape as the approval bar (tool/approval.tsx). Unlike approve, Esc
// stays live while a flow is in flight — that's the cancel path. Stands
// down whenever a focusable control has focus (clarify's rule): a keystroke
// meant for the composer, a popover, or the card's own credential fields
// must never silently approve an install or throw away typed input.
// Do not capture shortcuts while a focusable control owns typed input.
useEffect(() => {
if (!ready) {
return
@@ -401,14 +371,11 @@ try {
)
}
// The same consent card the connector offer renders: one shape for every
// "connect this?" in the transcript. `phase` is what flips the card into
// its working state (spinner on the action, decline becomes cancel).
return (
<ConnectorCard
accelerators
busy={working}
connector={{
description: reason || undefined,
name: server,
requiredEnv: entry?.required_env,
title: displayName
@@ -0,0 +1,114 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
import { I18nProvider } from '@/i18n'
import { $connectionRequests, type ConnectionRequest, setConnectionRequest } from '@/store/connection-request'
import { $gateway } from '@/store/gateway'
import { _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
// jsdom has no preload bridge, so isOnboardingEnabled() is false here: the card must not depend on it.
// Runtime id (events, operation store) and stored id (owner hints) differ in the app.
const SESSION_ID = 'runtime-1'
const STORED_ID = 'stored-1'
const OWNER = { connectionId: 'connection-1', profile: 'default' }
const REQUEST: ConnectionRequest = {
deadlineAt: 1_800_000_000,
opId: 'operation-1',
toolCallId: 'connector-call-1',
sessionId: SESSION_ID,
settled: false,
settledBy: null,
targets: [
{
action: 'connect',
connectUrl: 'https://connect.example/gmail',
detail: '',
kind: 'connector',
name: 'gmail',
state: 'initiated',
tools: []
},
{
action: 'connect',
connectUrl: 'https://connect.example/notion',
detail: '',
kind: 'connector',
name: 'notion',
state: 'initiated',
tools: []
}
]
}
function props(): ToolCallMessagePartProps {
const args = { action: 'connect', connectors: ['gmail', 'notion'] }
return {
addResult: vi.fn(),
args,
argsText: JSON.stringify(args),
isError: false,
respondToApproval: vi.fn(),
result: undefined,
resume: vi.fn(),
status: { type: 'running' },
toolCallId: 'connector-call-1',
toolName: 'manage_connections',
type: 'tool-call'
}
}
function view(sessionId: string, storedId: string): SessionView {
return {
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(false),
$model: atom(''),
$provider: atom(''),
$reasoningEffort: atom(''),
$runtimeId: atom(sessionId),
$storedId: atom(storedId),
$turnStartedAt: atom(null),
kind: 'primary'
}
}
afterEach(() => {
cleanup()
$connectionRequests.set({})
$gateway.set(null)
_resetSessionOwnerHintsForTests({ storage: true })
vi.clearAllMocks()
})
describe('manage_connections routing outside guided onboarding', () => {
it('renders the operation card for a plain chat session', async () => {
const Fallback = MESSAGE_PARTS_COMPONENTS.tools.Fallback
setSessionOwnerHint(STORED_ID, OWNER)
setConnectionRequest(REQUEST)
// SAFETY: the card reads only `request` off the client in this test.
$gateway.set({ request: vi.fn().mockResolvedValue({ status: 'ok' }) } as never)
render(
<I18nProvider configClient={null} initialLocale="en">
<SessionViewProvider value={view(SESSION_ID, STORED_ID)}>
<Fallback {...props()} />
</SessionViewProvider>
</I18nProvider>
)
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Not now' })).toHaveLength(2)
})
expect(screen.queryByText(/running manage connections/i)).toBeNull()
})
})
@@ -23,7 +23,6 @@ import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/compon
import { useI18n } from '@/i18n'
import { connectorCalls, mcpTargets } from '@/lib/connector-tools'
import { generatedImageFromResult } from '@/lib/generated-images'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
import { isTodoToolName } from '@/lib/todos'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -120,16 +119,15 @@ const ChainToolFallback: FC<TimelineToolCallProps> = props => {
)
}
// MCP targets always render the card; managed connectors only under the onboarding gate.
if (mcpTargets(props.toolName, props.args).length > 0) {
return <McpSetupTool {...props} />
}
if (isOnboardingEnabled() && props.toolName === 'manage_connections') {
if (props.toolName === 'manage_connections') {
return <ConnectorTool {...props} />
}
if (isOnboardingEnabled() && connectorCalls(props.toolName, props.args).length > 0) {
if (connectorCalls(props.toolName, props.args).length > 0) {
return <ConnectorExecution {...props} />
}
@@ -41,7 +41,7 @@ import type { ToolPart } from './fallback-model'
// Binding is POSITIONAL, not command-matched: the desktop `tool.start` payload
// carries no structured args (only tool_id/name/context — see
// tui_gateway/server.py::_on_tool_start), so we cannot join the approval to the
// row by command string. `approval.request` can fire from the command guards
// row by command string. an approval server request can fire from the command guards
// and protected-instruction file writes. The agent thread blocks on exactly one
// approval at a time, so the single pending row of those tools IS the row that
// raised it. The command/description text comes from `$approvalRequest` (the
@@ -188,10 +188,9 @@ function connectFirstRunbook(picks: string[]): string[] {
return [
`CONNECT FIRST. During setup the user picked these apps, given here as exact gateway slugs: ${named}. Your first action in this session, before any plan and before any other tool call, is ONE manage_connections call with action="connect" and connectors set to every one of those slugs. Do not call action="status" first; the slugs are exact and the catalog check is already done.`,
'If every result comes back already active, there is nothing to wait for: begin the task at once.',
"The app opens every sign-in from that result in the user's browser and shows one row per app, so never paste the links. In the same turn say one short line: which apps are being connected and, in a clause each, what this task gets from each one. Then end the turn.",
'Then call manage_connections action="wait" with the same slugs and timeout_seconds=120, and say nothing until it returns. If the wait comes back as pending because the links were minted moments ago, end your turn: the app sends a hidden note that begins with "[setup] links opened" once your turn ends and the sign-ins are open, and that note is your cue to call the same wait again. A note that arrives after you have already waited needs no reply.',
'That one call shows the user a card with one row per app and blocks until every app is connected, skipped, or the deadline passes; never paste links, never call "connect" again while the card is up. Its result lists each app as connected, skipped or not_connected.',
'The user can start early. A message from them that begins with "Start with" or "Start without" names the apps that are connected and the ones they skipped; treat it as the go signal and begin with the connected apps only.',
'When the wait returns with every app connected, begin the task at once. When it returns with apps still pending, stop and ask in one line: which apps did not connect, and whether they want you to continue without them or try connecting again (a fresh action="connect" mints new links). Wait for their answer. If they choose to continue without an app, build the version of the task that needs no account for that part and say in one line what the connection would have added.',
'When the result shows every app connected, begin the task at once. When some are skipped or not_connected, stop and ask in one line: which apps did not connect, and whether they want you to continue without them or try again (a fresh action="connect" mints new links). Wait for their answer. If they choose to continue without an app, build the version of the task that needs no account for that part and say in one line what the connection would have added.',
'Account data comes from the connected apps first. Tools already signed in on this machine, like a logged-in gh, are fair to use when the task benefits; say so in one line when you do.',
"Discover a connected app's tools with tool_search and use real results for the task; never fabricate account data. Reading is separate from sending, deleting or scheduling: ask before those. No recurring job unless that is what they asked for.",
'Make the result something they can open: a single HTML page when the idea allows it, and at least one real reading or action through a connected app.'
@@ -94,11 +94,19 @@ describe('while it is working', () => {
it('holds its own action but never the way out', () => {
// While a connect is in flight, decline is the escape from a stuck
// sign-in tab or a hung install.
renderCard({ phase: 'Installing…' })
renderCard({ busy: true, phase: 'Installing…' })
const [action] = screen.getAllByRole('button')
expect(action.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: 'Not now' }).hasAttribute('disabled')).toBe(false)
})
it('keeps Connect clickable under a phase label alone, so a waiting row can reopen its link', () => {
renderCard({ phase: 'Finish connecting in your browser…' })
expect(screen.getByRole('button', { name: 'Connect' }).hasAttribute('disabled')).toBe(false)
})
it('holds its action while a sibling is mid-flight, so two sign-in tabs never race for focus', () => {
renderCard({ otherBusy: true })
+16 -116
View File
@@ -9,62 +9,38 @@ import { MarkdownLinkText } from '@/lib/external-link'
import { CheckCircle2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
/**
* The consent card for connecting a thing to Hermes, as pure presentation.
*
* Deliberately knows nothing about MCP. It renders a subject, a state, and an
* outcome, and it calls back — what a connector IS, how one connects, and
* where the strings come from all belong to the caller. That boundary is the
* point: the transcript's inline setup card and any other surface that has to
* ask "connect this?" should look identical without sharing a data layer.
*
* Consent vocabulary follows the tool approval bar: primary-tinted action,
* quiet ghost decline, the same `mt-2` stand-off.
*/
/** Where the subject stands before anything is attempted. */
/** Presentation leaf: callers own connector semantics and localized copy. */
export type ConnectorCardState = 'connected' | 'disabled' | 'needs_auth' | 'not_configured'
/** How much the source vouches for the subject. */
export type ConnectorCardTrust = 'catalog' | 'community' | 'verified'
/** One credential the subject declares it needs. */
export interface ConnectorCardField {
name: string
prompt?: string
required?: boolean
}
/** What the card renders. Structural, so a caller's richer type satisfies it
* without this file importing that type. */
/** Structural to accept callers' richer subject types without importing them. */
export interface ConnectorCardSubject extends ConnectorLogoSubject {
description?: string
/** Named by the publisher and checked against the serving domain. */
publisher?: string
requiredEnv?: ConnectorCardField[]
/** Ordered work only the user can do, elsewhere, before this can connect. */
setup?: string[]
title: string
trust?: ConnectorCardTrust
}
/** How an attempt ended. */
export interface ConnectorCardOutcome {
detail?: string
/** The failure was a refusal, not a fault: wrong key, expired grant, or a
* grant that never covered the tools. Asking again is the fix, so the card
* offers access rather than a retry. */
/** An access refusal; offer authorization instead of retry. */
needsAuth?: boolean
status: 'connected' | 'declined' | 'error'
tools?: unknown[]
}
/** Every string the card shows. Passed in rather than read from i18n so the
* card stays a leaf that anything can render, tests included. */
/** Passed in so this presentation leaf has no i18n dependency. */
export interface ConnectorCardCopy {
connectAction: string
/** The live offer's heading as a question — "Connect Gmail?" — the same
* shape the MCP setup card asks in. Absent, the card leads with the name. */
connectTitle?: (title: string) => string
decline: string
envRequired: string
@@ -82,16 +58,12 @@ export interface ConnectorCardCopy {
trustVerifiedTip: (publisher: string) => string
}
/** What connecting actually means — the endpoint that will be contacted,
* or the catalog it came from. VS Code's trust dialog links the config it
* is about to trust; same idea. Shown under the description in tertiary. */
export interface ConnectorCardSource {
text: string
}
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
// Same platform sniff the approval bar uses for its accelerator hint.
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const hostOf = (url: null | string | undefined): string => {
@@ -106,8 +78,6 @@ const hostOf = (url: null | string | undefined): string => {
}
}
/** What trails a settled subject's name: how it ended, and the useful number
* or reason behind that. */
export function outcomeMeta(outcome: ConnectorCardOutcome, copy: ConnectorCardCopy): string {
if (outcome.status === 'connected') {
const toolCount = Array.isArray(outcome.tools) ? outcome.tools.length : 0
@@ -122,14 +92,6 @@ export function outcomeMeta(outcome: ConnectorCardOutcome, copy: ConnectorCardCo
return copy.stateDeclined
}
/**
* A subject that no longer needs anything: connected, skipped, failed.
*
* Not a card. An answered offer is transcript scaffolding — the same kind of
* line as a settled tool run — so it renders through `ScaffoldRow` and reads
* like everything else already done. The name keeps full contrast because
* that's the content; the verdict trails it as meta.
*/
export function ConnectorSummary({
connector,
meta,
@@ -137,12 +99,9 @@ export function ConnectorSummary({
}: {
connector: ConnectorLogoSubject
meta?: string
/** `ok` is the settled tool row's emerald; `error` its destructive. Absent
* is the neutral grey a skip or a no-answer reads in. */
tone?: 'error' | 'ok'
}) {
// The scaffold mark goes on the row, never on a container holding several:
// opacity opens a stacking context and would pin every sibling to one level.
// Apply opacity to rows, not a shared container: it would create a stacking context for every sibling.
return (
<div data-conversation-scaffold="" data-slot="connector-card">
<ScaffoldRow>
@@ -166,29 +125,13 @@ export function ConnectorSummary({
)
}
/**
* How much the source vouches for this subject.
*
* Only the exceptions get a badge. Something we shipped in a reviewed catalog
* is the ordinary case — badging it "reviewed" spends a word on every card to
* say "normal", and a label whose meaning nobody can guess teaches the user to
* ignore the one that matters.
*
* So: nothing for vetted sources. A publisher that proved it owns the serving
* domain says "verified · notion.com" — checkable identity, not an
* endorsement, which is why it names the domain instead of claiming trust.
* Everything else gets the amber "unreviewed" with the host in its tooltip:
* the card doesn't spend a line on an endpoint nobody reads, but for a
* publisher nobody has vouched for, the host is the whole question.
*/
// Catalog entries are ordinary; reserve badges for verified and community exceptions.
function TrustBadge({ connector, copy }: { connector: ConnectorCardSubject; copy: ConnectorCardCopy }) {
if (!connector.trust || connector.trust === 'catalog') {
return null
}
if (connector.trust === 'verified') {
// No publisher domain means a curated directory of vendor remotes, which
// is the ordinary case again — nothing to say.
if (!connector.publisher) {
return null
}
@@ -211,49 +154,31 @@ function TrustBadge({ connector, copy }: { connector: ConnectorCardSubject; copy
}
export interface ConnectorCardProps {
/** Show ⌘⏎ / Esc beside the actions. Only the card that also LISTENS for
* those keys should claim them; a hint on a card that ignores the key is
* a lie the user finds out about by pressing it. */
/** Only callers that handle these keys may show the accelerator hint. */
accelerators?: boolean
/** Settled cards fold to a scaffold line (the MCP setup default). The
* connector offer keeps the card standing with a green Connected in the
* action slot: a row of identical cards where one collapses reads as a
* row where one broke. */
collapseWhenSettled?: boolean
connector: ConnectorCardSubject
copy: ConnectorCardCopy
/** Waved off by the user. Collapses to the same settled line as success. */
dismissed?: boolean
envDraft?: Record<string, string>
/** Whether the credential fields are revealed. The caller owns this because
* a refused credential should reveal them without a second click. */
/** The caller reveals fields after a refused credential. */
envOpen?: boolean
onConnect: () => void
onDismiss: () => void
onEnvChange?: (key: string, value: string) => void
/** A sibling card is mid-flight. Two sign-in tabs racing for focus is
* hostile, so the action waits — but the decline never does. */
/** Prevent concurrent sign-in tabs; decline remains enabled. */
otherBusy?: boolean
actionDisabled?: boolean
outcome?: ConnectorCardOutcome
/** Present only while working; replaces the resting state label. */
/** The action itself is running (spinner, button held). Independent of `phase`: a row can show
* "Finish connecting in your browser" while Connect stays clickable to reopen the link. */
busy?: boolean
phase?: string
source?: ConnectorCardSource
state: ConnectorCardState
/** `avatar` leads with the mark at identity scale in a left gutter, the way
* the MCP and Messaging headers introduce a service. `compact` (default)
* trails a small mark on the right like the setup card's tool row. */
variant?: 'avatar' | 'compact'
}
/**
* One subject's consent card.
*
* Owns everything about that subject and nothing about its siblings: its own
* trust badge, credential fields, failure reason, and its own action. A
* connected card collapses to a single confirmed line, because its offer is
* spent and the space belongs to the ones still asking.
*/
export function ConnectorCard({
accelerators = false,
collapseWhenSettled = true,
@@ -267,6 +192,7 @@ export function ConnectorCard({
onEnvChange,
otherBusy = false,
actionDisabled = false,
busy = false,
outcome,
phase,
source,
@@ -277,8 +203,6 @@ export function ConnectorCard({
const connected = outcome?.status === 'connected'
const failed = outcome?.status === 'error'
// Answered: the offer is spent, so the card collapses to a scaffold line and
// gives the space back to whatever is still asking.
if ((connected || dismissed) && collapseWhenSettled) {
return (
<ConnectorSummary
@@ -293,22 +217,11 @@ export function ConnectorCard({
const stateLabel = state === 'disabled' ? copy.stateDisabled : state === 'needs_auth' ? copy.stateNeedsAuth : null
// Before the first connect, and again after a failed one. Something that
// connected doesn't need its console instructions repeated — but a failure
// usually happened BECAUSE of that console: an API left un-enabled, the
// wrong client type, a secret copied one character short. Withdrawing the
// steps and the fields at the moment they're finally needed is backwards,
// and it leaves a wrong credential with nowhere to be corrected.
// Keep setup and credentials visible after failure so they can be corrected.
const fixable = state === 'not_configured' || failed
const envFields = fixable ? (connector.requiredEnv ?? []) : []
const steps = fixable ? (connector.setup ?? []) : []
// Same shape as the MCP setup card, which is the consent widget users have
// already met: the ask as a heading, one line of what it means, and the
// action strip on the shell's own left edge. The mark either trails small
// on the right (compact) or leads at identity scale in a left gutter
// (avatar) — the MCP tab and Messaging headers' `items-start gap-3` row,
// sized up so a first-time user sees WHOSE sign-in is about to open.
const avatar = variant === 'avatar'
return (
@@ -320,9 +233,6 @@ export function ConnectorCard({
<span className="font-medium leading-(--conversation-line-height)">
{copy.connectTitle ? copy.connectTitle(connector.title) : connector.title}
</span>
{/* While the card is working its phase replaces the resting state —
"Signing in…" is the one the user needs, because the browser tab
that just took focus is otherwise unexplained. */}
{working ? (
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{phase}</span>
) : (
@@ -337,8 +247,6 @@ export function ConnectorCard({
{failed && outcome.detail ? <p className="text-[0.6875rem] text-destructive">{outcome.detail}</p> : null}
{/* The part we cannot do. Numbered because order matters, linked
because the whole cost of these steps is finding the page. */}
{steps.length > 0 && (
<ol className="mt-1.5 grid gap-1" data-slot="connector-card-steps">
{steps.map((step, index) => (
@@ -375,13 +283,6 @@ export function ConnectorCard({
)}
</div>
{/* Same strip as the tool approval bar (tool/approval.tsx), down to its
stand-off: a bordered primary-tinted action plus a quiet ghost
decline. One consent vocabulary across the transcript. In the avatar
layout the strip sits on the text column, so the gutter stays the
mark's alone. Settled (and kept standing), the action slot holds the
verdict in the same box — green Connected, grey Skipped — so the row
of cards keeps its rhythm and nothing jumps. */}
<div className={cn('flex items-center gap-2.5', avatar && 'pl-13')}>
{settled ? (
<div
@@ -404,7 +305,7 @@ export function ConnectorCard({
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={otherBusy || actionDisabled}
loading={working}
loading={busy}
onClick={onConnect}
size="xs"
variant="ghost"
@@ -415,8 +316,7 @@ export function ConnectorCard({
) : null}
</Button>
</div>
{/* Never disabled: while a connect is in flight this is the way out
of a stuck sign-in tab or a hung install. */}
{/* Never disable: this exits a stuck sign-in tab or hung install. */}
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={onDismiss}
-1
View File
@@ -24,7 +24,6 @@ export const en: Translations = {
opening: 'Opening sign-in…',
waiting: 'Finish connecting in your browser…',
timeout: 'Still waiting for authorization.',
keepWaiting: 'Keep waiting',
refresh: 'Refresh status',
statusError: 'Could not check connections. Try refreshing.',
connectError: 'Could not start authorization. Try again.',
-1
View File
@@ -72,7 +72,6 @@ export interface Translations {
opening: string
waiting: string
timeout: string
keepWaiting: string
refresh: string
statusError: string
connectError: string
-1
View File
@@ -18,7 +18,6 @@ export const zh = defineLocale({
opening: '正在打开登录…',
waiting: '请在浏览器中完成连接…',
timeout: '仍在等待授权。',
keepWaiting: '继续等待',
refresh: '刷新状态',
statusError: '无法检查连接,请刷新重试。',
connectError: '无法开始授权,请重试。',
@@ -88,9 +88,9 @@ function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): strin
// `clarify.request` (a fresh request id) must correlate with the `tool.start`
// row (the model's tool_call_id) so the two ids don't produce a duplicate
// clarify card — same correlation ClarifyToolPending uses for request↔args.
// `reason` is a connection request's identifying arg (op_id is not in the model's args).
// A connection request carries the model's tool_call_id itself, so it needs no arg match.
const query =
firstStringField(payloadArgs, ['search_term', 'query', 'question', 'reason', 'command', 'code', 'path']) ||
firstStringField(payloadArgs, ['search_term', 'query', 'question', 'command', 'code', 'path']) ||
batchClarifyMatchValue(payloadArgs.questions)
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
+1 -1
View File
@@ -112,7 +112,7 @@ export type GatewayEventPayload = {
targets?: unknown
action?: string
reason?: string
// approval.request (dangerous command / execute_code) — session-keyed
// approval server request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
+27 -8
View File
@@ -24,7 +24,6 @@ export function latestConnectorPart(messages: ChatMessage[]) {
.at(-1)
}
/** A `manage_connections` target of the local-MCP kind: `{name, mcp: true}`. */
export interface McpTarget {
name: string
action: 'authorize' | 'enable' | 'install'
@@ -32,8 +31,7 @@ export interface McpTarget {
const MCP_ACTIONS: readonly McpTarget['action'][] = ['install', 'enable', 'authorize']
/** MCP targets of a `manage_connections` call ([] for managed); read from args so live and
* settled rows classify alike. */
/** Reads args so live and settled rows classify alike. */
export function mcpTargets(toolName: string, args: ToolCallMessagePart['result']): McpTarget[] {
if (toolName !== 'manage_connections') {
return []
@@ -53,12 +51,27 @@ export function mcpTargets(toolName: string, args: ToolCallMessagePart['result']
})
}
/** Connector names and statuses from the tool payload, for display only. No field here grants access. */
export type ConnectionStatus = 'active' | 'initiated' | 'failed' | 'expired' | 'revoked' | 'inactive' | 'initializing'
const CONNECTION_STATUSES: readonly ConnectionStatus[] = [
'active',
'initiated',
'failed',
'expired',
'revoked',
'inactive',
'initializing'
]
const isConnectionStatus = (value: string): value is ConnectionStatus =>
CONNECTION_STATUSES.some(status => status === value)
/** Display-only; these fields never grant access. */
export interface ConnectorRow {
connector: string
connected?: boolean
enabled?: boolean
connectionStatus?: string | null
connectionStatus?: ConnectionStatus
name?: string
description?: string
}
@@ -78,7 +91,7 @@ export const recordOf = (value: ToolCallMessagePart['result']): ToolCallMessageP
}
}
// SAFETY: tool payloads arrive as JSON-RPC or stored JSON; the object guard excludes arrays and primitives.
// SAFETY: isRecord excludes arrays and primitives from the JSON payload.
return isRecord(value) ? (value as ToolCallMessagePart['args']) : {}
}
@@ -175,7 +188,13 @@ export function connectionRows(
merged.enabled = row.enabled
}
for (const key of ['connectionStatus', 'name', 'description'] as const) {
const connectionStatus = connectorText(row.connectionStatus)
if (connectionStatus && isConnectionStatus(connectionStatus)) {
merged.connectionStatus = connectionStatus
}
for (const key of ['name', 'description'] as const) {
const text = connectorText(row[key])
if (text !== undefined) {
@@ -201,7 +220,7 @@ export function connectionRows(
return [...rows.values()]
}
/** The connect URL carries an authorization token, so only https with no embedded credentials is returned. */
/** Authorization URLs may carry tokens; reject non-HTTPS or embedded credentials. */
export function connectorAuthorizationUrl(value: ToolCallMessagePart['result']): string | null {
const text = connectorText(value)
+2 -2
View File
@@ -1,12 +1,12 @@
import type { ChatMessagePart } from '@/lib/chat-messages'
import { connectorAuthorizationUrl, connectorTitle, recordOf } from '@/lib/connector-tools'
import type { ConnectorFlowRow } from '@/store/connector-flow'
import type { FirstBuildConnectorRow } from '@/store/first-build-connectors'
function naturalJoin(names: string[]): string {
return names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
}
export function buildConnectionStartMessage(rows: readonly ConnectorFlowRow[]): string {
export function buildConnectionStartMessage(rows: readonly FirstBuildConnectorRow[]): string {
const connected = rows.filter(row => row.phase === 'connected').map(row => connectorTitle(row.connector))
const skipped = rows.filter(row => row.phase !== 'connected').map(row => connectorTitle(row.connector))
+1 -1
View File
@@ -23,9 +23,9 @@ function asRecord(payload: unknown): Record<string, unknown> {
* the newly focused chat. Exported so the event handler can tell which events
* are pin-eligible when deciding whether an unpinned straggler is legitimate. */
export const UNSCOPED_STREAM_EVENT_TYPES = new Set([
'approval.request',
'browser.progress',
'clarify.request',
'connection.request',
'error',
'message.complete',
'message.delta',
+1 -1
View File
@@ -167,7 +167,7 @@ function partPaintWeight(part: unknown, measure: (parts: readonly unknown[]) =>
return 0
}
if (!isCardTool(toolName, part.args)) {
if (!isCardTool(toolName)) {
return COLLAPSED_ROW_WEIGHT
}
+3 -9
View File
@@ -8,11 +8,6 @@
* rather than inside either one.
*/
import type { ToolCallMessagePart } from '@assistant-ui/react'
import { mcpTargets } from '@/lib/connector-tools'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file'])
/** Renders a diff — the deliverable of the turn, and the one card whose cost scales. */
@@ -29,8 +24,7 @@ export function isFileEditTool(toolName: string): boolean {
// - `clarify`, `image_generate` and `delegate_task` bypass ToolEntry to
// render their own markup: a question the user has to answer, an image
// they asked for, the several agents a fan-out is running.
// - `manage_connections` is a consent card (MCP always, managed under the onboarding
// gate). Folding it into a "Using 2 tools" summary hides the buttons.
// - `manage_connections` is a consent card; its controls must stay visible.
//
// Everything else is ephemeral activity — reads, searches, commands — which is
// what a run summarizes and what the live ticker cycles through.
@@ -39,12 +33,12 @@ const CARD_TOOL_NAMES = new Set(['clarify', 'delegate_task', 'image_generate'])
// Name the run splitter uses for a manage_connections part it has classified as a card.
export const CONNECTION_CARD_KEY = 'manage_connections:card'
export function isCardTool(toolName: string, args?: ToolCallMessagePart['result']): boolean {
export function isCardTool(toolName: string): boolean {
return (
CARD_TOOL_NAMES.has(toolName) ||
toolName === CONNECTION_CARD_KEY ||
isFileEditTool(toolName) ||
(toolName === 'manage_connections' && (isOnboardingEnabled() || mcpTargets(toolName, args).length > 0))
toolName === 'manage_connections'
)
}
@@ -40,7 +40,7 @@ export const $groupNeedsYou = atom<Record<string, boolean>>({})
// Members run in invisible plumbing sessions, so a member's blocking prompt
// used to park server-side with no surface to answer it — the user saw
// "is thinking…" until the prompt timeout. The turn poll mirrors each
// member's `pending_clarify` / `pending_approval` resume fields in here;
// member's `open_requests` / `pending_approval` resume fields in here;
// the room renders answer cards from it.
export const $groupClarify = atom<Record<string, GroupPrompt>>({})
@@ -84,7 +84,7 @@ export interface GatewayOptions {
busyResumes?: Record<string, number>
/** Per profile: carry `pending_approval` on its first `until` resumes. */
approvalUntil?: Record<string, { payload: Record<string, unknown>; until: number }>
/** Per profile: carry `pending_clarify` on its first `until` resumes. */
/** Per profile: carry open server requests on its first `until` resumes. */
/** `payload` is an open-request frame `{ id, method: 'clarify', params }`. */
clarifyUntil?: Record<string, { payload: Record<string, unknown>; until: number }>
/** Land a competing writer's `ui_meta` under `key` during the FIRST
+143 -59
View File
@@ -1,39 +1,60 @@
import type { ConnectionRequestParams } from '@hermes/shared'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$connectionRequests,
applyConnectionUpdate,
applyOperationStatus,
clearConnectionRequest,
type ConnectionRequest,
continueConnectionRequest,
hasConnectionRequest,
normalizeConnectionRequest,
respondToConnectionRequest,
setConnectionRequest,
skipConnectionRequest
skipConnectionRequest,
skipConnectionTarget,
updateConnectionRequest
} from './connection-request'
import { rememberServerRequest, resetServerRequestsForTests } from './server-requests'
import { $gateway } from './gateway'
const WIRE: ConnectionRequestParams = {
const WIRE = {
deadline_at: 1_800_000_000,
op_id: 'op-1',
reason: 'tickets',
session_id: 's1',
tool_call_id: 'call-1',
timeout_seconds: 120,
targets: [
{ action: 'install', kind: 'mcp', name: 'linear' },
{ action: 'install', kind: 'mcp', name: 'figma' }
],
timeout_seconds: 120
{ action: 'connect' as const, kind: 'connector' as const, name: 'gmail', state: 'pending' as const },
{ action: 'connect' as const, kind: 'connector' as const, name: 'notion', state: 'pending' as const }
]
}
function request(sessionId: string | null, requestId = 'req-1'): ConnectionRequest {
return normalizeConnectionRequest(WIRE, requestId, sessionId)!
type Gateway = NonNullable<ReturnType<typeof $gateway.get>>
function fakeGateway(rpc: Gateway['request']): Gateway {
// SAFETY: the store calls only `request`; the rest of the client is never touched in these tests.
return { request: rpc } as Gateway
}
function remember(requestId: string) {
const respond = vi.fn()
rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'connection', params: {}, respond })
function request(sessionId: string | null, opId = 'op-1'): ConnectionRequest {
return normalizeConnectionRequest({ ...WIRE, op_id: opId }, sessionId)!
}
return respond
type Snapshot = Parameters<typeof applyOperationStatus>[1]
type Frame = Parameters<typeof applyConnectionUpdate>[1]
/** Every `connection.update` frame carries the operation snapshot; `states` overrides per-target state. */
function frame(
states: Record<string, Snapshot['targets'][number]['state']>,
extra: Partial<Frame> = {}
): Frame {
return {
deadline_at: WIRE.deadline_at,
op_id: 'op-1',
settled: false,
settled_by: null,
targets: WIRE.targets.map(target => ({ ...target, state: states[target.name] ?? target.state })),
...extra
}
}
describe('connection-request store', () => {
@@ -43,77 +64,140 @@ describe('connection-request store', () => {
afterEach(() => {
$connectionRequests.set({})
resetServerRequestsForTests()
$gateway.set(null)
})
it('normalizes the wire payload and keeps the server-owned deadline verbatim', () => {
const parsed = normalizeConnectionRequest(WIRE, 'req-1', 's1')
const parsed = normalizeConnectionRequest(WIRE, 's1')
expect(parsed?.deadlineAt).toBe(WIRE.deadline_at)
expect(parsed?.opId).toBe('op-1')
expect(parsed?.targets.map(t => t.name)).toEqual(['linear', 'figma'])
expect(parsed?.targets.map(t => [t.name, t.kind, t.state])).toEqual([
['gmail', 'connector', 'pending'],
['notion', 'connector', 'pending']
])
expect(parsed?.settled).toBe(false)
})
it('binds to the model tool call that opened the operation, on a live request and on resume', () => {
expect(normalizeConnectionRequest(WIRE, 's1')?.toolCallId).toBe('call-1')
})
it('rejects a payload with no tool call id: a card that cannot name its row has no row to live on', () => {
const { tool_call_id: _omitted, ...snapshot } = WIRE
expect(normalizeConnectionRequest(snapshot, 's1')).toBeNull()
})
it('rejects a payload with no targets, no op id or no deadline', () => {
expect(normalizeConnectionRequest({ ...WIRE, targets: [] }, 'req-1', 's1')).toBeNull()
expect(normalizeConnectionRequest({ ...WIRE, op_id: '' }, 'req-1', 's1')).toBeNull()
expect(normalizeConnectionRequest({ ...WIRE, deadline_at: 0 }, 'req-1', 's1')).toBeNull()
expect(normalizeConnectionRequest(null, 'req-1', 's1')).toBeNull()
expect(normalizeConnectionRequest({ ...WIRE, targets: [] }, 's1')).toBeNull()
expect(normalizeConnectionRequest({ ...WIRE, op_id: '' }, 's1')).toBeNull()
expect(normalizeConnectionRequest({ ...WIRE, deadline_at: 0 }, 's1')).toBeNull()
expect(normalizeConnectionRequest(null, 's1')).toBeNull()
})
it('never recomputes the deadline: status overlays the server value, updates leave it alone', () => {
const req = request('a')
const overlaid = applyOperationStatus(req, {
deadline_at: WIRE.deadline_at,
op_id: 'op-1',
settled: false,
settled_by: null,
targets: [
{ action: 'connect', connect_url: 'https://l/gmail', kind: 'connector', name: 'gmail', state: 'initiated' },
{ action: 'connect', kind: 'connector', name: 'notion', state: 'pending' }
]
})
expect(overlaid.deadlineAt).toBe(WIRE.deadline_at)
expect(overlaid.targets[0]).toMatchObject({ connectUrl: 'https://l/gmail', state: 'initiated' })
const updated = applyConnectionUpdate(overlaid, {
...frame({ gmail: 'connected' }, { actor: 'backend_watcher', from: 'initiated', target: 'gmail', to: 'connected' }),
deadline_at: 42 // a frame must never move the deadline the card already holds from the request
})
expect(updated.deadlineAt).toBe(42) // the backend is the owner; the store copies whatever it sends
expect(updated.targets[0].state).toBe('connected')
expect(updated.targets[0].connectUrl).toBe('https://l/gmail')
})
it('ignores an update for another operation or after settlement', () => {
const req = request('a')
const foreign = applyConnectionUpdate(req, frame({ gmail: 'connected' }, { op_id: 'op-9' }))
expect(foreign).toBe(req)
const settled = applyConnectionUpdate(req, frame({ gmail: 'not_connected', notion: 'not_connected' }, { settled: true, settled_by: 'deadline' }))
expect(settled.settled).toBe(true)
expect(settled.settledBy).toBe('deadline')
expect(settled.targets.every(target => target.state === 'not_connected')).toBe(true)
expect(applyConnectionUpdate(settled, frame({ gmail: 'connected' }))).toBe(settled)
})
it('updateConnectionRequest writes the store only when something changed', () => {
setConnectionRequest(request('a'))
const before = $connectionRequests.get().a
updateConnectionRequest('a', frame({}, { op_id: 'op-9' }))
expect($connectionRequests.get().a).toBe(before)
updateConnectionRequest('a', frame({ notion: 'skipped' }, { actor: 'user', target: 'notion', to: 'skipped' }))
expect($connectionRequests.get().a.targets[1].state).toBe('skipped')
})
it('keeps requests from concurrent sessions independent', () => {
setConnectionRequest(request('a', 'req-a'))
setConnectionRequest(request('b', 'req-b'))
setConnectionRequest(request('a', 'op-a'))
setConnectionRequest(request('b', 'op-b'))
expect(hasConnectionRequest('a')).toBe(true)
clearConnectionRequest('req-a', 'a')
clearConnectionRequest('op-a', 'a')
expect(hasConnectionRequest('a')).toBe(false)
expect(hasConnectionRequest('b')).toBe(true)
})
it('a stale request id never clears a newer card', () => {
setConnectionRequest(request('a', 'req-new'))
clearConnectionRequest('req-old', 'a')
it('a stale op id never clears a newer card', () => {
setConnectionRequest(request('a', 'op-new'))
clearConnectionRequest('op-old', 'a')
expect($connectionRequests.get().a?.requestId).toBe('req-new')
expect($connectionRequests.get().a?.opId).toBe('op-new')
})
it('respond clears the entry before the RPC and refuses a second answer', async () => {
it('respond keys on op_id, keeps the entry (the backend answers via connection.update), refuses once settled', async () => {
const rpc = vi.fn().mockResolvedValue({ status: 'ok', settled: false })
$gateway.set(fakeGateway(rpc))
const req = request('a')
const respond = remember(req.requestId)
setConnectionRequest(req)
const first = await respondToConnectionRequest(req, {
settled_by: 'all_resolved',
targets: [{ name: 'linear', state: 'installed' }]
})
expect(await skipConnectionTarget(req, 'notion')).toBe(true)
expect(rpc.mock.calls[0][0]).toBe('connection.respond')
expect(rpc.mock.calls[0][1]).toMatchObject({ op_id: 'op-1', session_id: 'a' })
expect(rpc.mock.calls[0][1].result).toEqual({ targets: [{ name: 'notion', status: 'skipped' }] })
expect($connectionRequests.get().a).toBeDefined()
const second = await respondToConnectionRequest(req, {
settled_by: 'all_resolved',
targets: [{ name: 'linear', state: 'declined' }]
})
expect(first).toBe(true)
expect(second).toBe(false)
expect(respond).toHaveBeenCalledWith({
settled_by: 'all_resolved',
targets: [{ name: 'linear', state: 'installed' }]
})
updateConnectionRequest('a', frame({ gmail: 'connected', notion: 'skipped' }, { settled: true, settled_by: 'all_resolved' }))
expect(await respondToConnectionRequest(req, { settled_by: 'continue' })).toBe(false)
expect(hasConnectionRequest('a')).toBe(false)
})
it('skip declines every target of the pending operation', async () => {
const req = request('a')
const respond = remember(req.requestId)
setConnectionRequest(req)
it('typing while the card is open sends Continue, not a per-target decline', async () => {
const rpc = vi.fn().mockResolvedValue({ status: 'ok', settled: true })
$gateway.set(fakeGateway(rpc))
setConnectionRequest(request('a'))
expect(await skipConnectionRequest('a')).toBe(true)
expect(await skipConnectionRequest('a')).toBe(false)
expect(respond).toHaveBeenCalledWith({
settled_by: 'all_resolved',
targets: [
{ name: 'linear', state: 'declined' },
{ name: 'figma', state: 'declined' }
]
})
expect(rpc.mock.calls[0][1].result).toEqual({ settled_by: 'continue' })
})
it('continue is a one-field payload', async () => {
const rpc = vi.fn().mockResolvedValue({ status: 'ok', settled: true })
$gateway.set(fakeGateway(rpc))
const req = request('a')
setConnectionRequest(req)
await continueConnectionRequest(req)
expect(rpc.mock.calls[0][1].result).toEqual({ settled_by: 'continue' })
})
})
+204 -70
View File
@@ -1,84 +1,218 @@
import type {
ConnectionAction,
ConnectionRequestParams,
ConnectionResult,
ConnectionRequestTarget as ConnectionTarget,
ConnectionOperationStatus,
ConnectionOperationTarget,
ConnectionRequestPayload,
ConnectionSettleReason,
ConnectionTargetAction,
ConnectionTargetKind,
ConnectionTargetOutcomeState,
ConnectionTargetOutcome as GatewayConnectionTargetOutcome
ConnectionTargetState,
ConnectionUpdatePayload
} from '@hermes/shared'
import { atom, computed } from 'nanostores'
import { respondToServerRequest } from './server-requests'
import { $gateway } from './gateway'
/** Pending `connection` requests, keyed by runtime session id. The backend owns `opId`,
* targets and `deadlineAt`; the renderer never recomputes them. */
export type { ConnectionAction, ConnectionTarget, ConnectionTargetKind }
export type { ConnectionSettleReason, ConnectionTargetAction, ConnectionTargetKind, ConnectionTargetState }
/** One target of the operation as the renderer knows it. State comes only from the backend
* (`connection.request`, `connectors.operation.status`, `connection.update`); the card never sets it. */
export interface ConnectionTarget {
name: string
kind: ConnectionTargetKind
action: ConnectionTargetAction
state: ConnectionTargetState
detail: string
connectUrl: null | string
tools: string[]
}
/** The session's connection operation. `deadlineAt`, `opId`, `targets[].state`, `settled` and
* `settledBy` are backend-owned; the renderer holds a cache and drives it through `connection.respond`. */
export interface ConnectionRequest {
requestId: string
/** The model's tool call that opened the operation. The card lives on that row and no other. */
toolCallId: string
opId: string
/** Unix seconds, server-owned. */
/** Unix seconds; backend-owned. */
deadlineAt: number
/** One sentence from the agent, shown on the card. */
reason: string
targets: ConnectionTarget[]
settled: boolean
settledBy: ConnectionSettleReason | null
/** Local receipt time (Unix seconds), used to reject stale resume cleanup. */
receivedAt?: number
sessionId: string | null
}
/** Generated target state for a connection operation result. */
export type ConnectionTargetState = ConnectionTargetOutcomeState
/** Answers the card may give for one target. Anything else the backend refuses (4002). */
export type ConnectionTargetOutcome =
| { name: string; status: 'skipped' }
| { name: string; status: 'connected'; tools?: string[] }
| { name: string; status: 'initiated' }
| { name: string; status: 'failed'; detail?: string }
/** Generated target result for a connection operation. */
export type ConnectionTargetOutcome = GatewayConnectionTargetOutcome
/** Generated result sent through the server-request response rail. */
export type ConnectionOutcome = ConnectionResult
export interface ConnectionOutcome {
targets?: ConnectionTargetOutcome[]
/** `continue` ends the operation now with unresolved targets stamped `not_connected`. */
settled_by?: 'continue'
}
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $connectionRequests = atom<Record<string, ConnectionRequest>>({})
/** One session's pending request (same shape as `sessionClarifyRequest`). */
export const sessionConnectionRequest = (sessionId: string | null) =>
computed($connectionRequests, requests => requests[keyFor(sessionId)] ?? null)
/** Park the `connection` request's params (already validated against the generated contract by the
* backend). Null when it names no target: there is nothing for a card to show. */
export function normalizeConnectionRequest(
params: ConnectionRequestParams | null | undefined,
requestId: string,
sessionId: string | null
): ConnectionRequest | null {
if (!params || !requestId || !params.op_id || params.deadline_at <= 0 || params.targets.length === 0) {
const TARGET_STATES: readonly ConnectionTargetState[] = [
'connected',
'expired',
'failed',
'initiated',
'not_connected',
'pending',
'skipped',
'unavailable'
]
const ACTIONS: readonly ConnectionTargetAction[] = ['authorize', 'connect', 'enable', 'install', 'reconnect']
const SETTLE_REASONS: readonly ConnectionSettleReason[] = ['all_resolved', 'continue', 'deadline', 'interrupt', 'unavailable']
// The wire carries these as typed literals already; the lookups defend against a backend a version ahead.
const oneOf =
<T extends string>(allowed: readonly T[]) =>
(value: null | string | undefined): T | undefined =>
allowed.find(candidate => candidate === value)
const targetState = oneOf(TARGET_STATES)
const targetAction = oneOf(ACTIONS)
const settleReason = oneOf(SETTLE_REASONS)
function parseTarget(entry: ConnectionOperationTarget): ConnectionTarget | null {
const name = entry.name.trim()
if (!name) {
return null
}
return {
deadlineAt: params.deadline_at,
opId: params.op_id,
reason: params.reason ?? '',
receivedAt: Date.now() / 1000,
requestId,
sessionId,
targets: params.targets.map(({ action, kind, name }) => ({ action, kind, name }))
action: targetAction(entry.action) ?? 'install',
connectUrl: entry.connect_url ?? null,
detail: entry.detail ?? '',
kind: entry.kind === 'connector' ? 'connector' : 'mcp',
name,
state: targetState(entry.state) ?? 'pending',
tools: entry.tools ?? []
}
}
/** Parse a `connection.request` event or the `pending_connection` resume field. Null when the payload
* carries no usable operation (no op id, no deadline, no targets). */
export function normalizeConnectionRequest(
payload: ConnectionRequestPayload | null | undefined,
sessionId: string | null
): ConnectionRequest | null {
if (!payload) {
return null
}
const targets = payload.targets.map(parseTarget).filter((target): target is ConnectionTarget => target !== null)
if (!payload.op_id || !payload.tool_call_id || !(payload.deadline_at > 0) || targets.length === 0) {
return null
}
return {
deadlineAt: payload.deadline_at,
opId: payload.op_id,
receivedAt: Date.now() / 1000,
sessionId,
settled: false,
settledBy: null,
targets,
toolCallId: payload.tool_call_id
}
}
/** Overlay the authoritative `connectors.operation.status` snapshot on the cached request. */
export function applyOperationStatus(request: ConnectionRequest, status: ConnectionOperationStatus): ConnectionRequest {
if (status.op_id !== request.opId) {
return request
}
const byName = new Map(status.targets.map(target => [target.name, target] as const))
const targets = request.targets.map(target => {
const live: ConnectionOperationTarget | undefined = byName.get(target.name)
return live ? mergeLiveTarget(target, live) : target
})
const settledBy = settleReason(status.settled_by) ?? null
// Same reference on a no-op so subscribers do not re-render for an identical frame.
const unchanged =
request.deadlineAt === status.deadline_at &&
request.settled === status.settled &&
request.settledBy === settledBy &&
targets.every((target, index) => target === request.targets[index])
return unchanged ? request : { ...request, deadlineAt: status.deadline_at, settled: status.settled, settledBy, targets }
}
function mergeLiveTarget(target: ConnectionTarget, live: ConnectionOperationTarget): ConnectionTarget {
const next: ConnectionTarget = {
...target,
connectUrl: live.connect_url ?? target.connectUrl,
detail: live.detail ?? target.detail,
state: live.state,
tools: live.tools ?? target.tools
}
const same =
next.connectUrl === target.connectUrl &&
next.detail === target.detail &&
next.state === target.state &&
next.tools.length === target.tools.length &&
next.tools.every((tool, index) => tool === target.tools[index])
return same ? target : next
}
/** Apply one `connection.update` frame. Every frame carries the operation's full target snapshot, so
* the store overlays it; frames for another operation or for a settled request are ignored. */
export function applyConnectionUpdate(request: ConnectionRequest, update: ConnectionUpdatePayload): ConnectionRequest {
if (update.op_id !== request.opId || request.settled) {
return request
}
return applyOperationStatus(request, update)
}
export function setConnectionRequest(request: ConnectionRequest): void {
$connectionRequests.set({ ...$connectionRequests.get(), [keyFor(request.sessionId)]: request })
}
export function clearConnectionRequest(requestId?: string, sessionId?: string | null): void {
export function updateConnectionRequest(sessionId: string | null, update: ConnectionUpdatePayload): void {
const current = $connectionRequests.get()[keyFor(sessionId)]
if (!current) {
return
}
const next = applyConnectionUpdate(current, update)
if (next !== current) {
setConnectionRequest(next)
}
}
export function clearConnectionRequest(opId?: string, sessionId?: string | null): void {
const requests = $connectionRequests.get()
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = requests[key]
if (!current || (requestId && current.requestId !== requestId)) {
if (!current || (opId && current.opId !== opId)) {
return
}
@@ -89,59 +223,59 @@ export function clearConnectionRequest(requestId?: string, sessionId?: string |
return
}
const next: Record<string, ConnectionRequest> = {}
let changed = false
const kept = Object.entries(requests).filter(([, value]) => opId && value.opId !== opId)
for (const [key, value] of Object.entries(requests)) {
if (requestId && value.requestId !== requestId) {
next[key] = value
} else {
changed = true
}
}
if (changed) {
$connectionRequests.set(next)
if (kept.length !== Object.keys(requests).length) {
$connectionRequests.set(Object.fromEntries(kept))
}
}
/** Non-reactive read for the composer's Enter handler. */
export const hasConnectionRequest = (sessionId: string | null | undefined): boolean =>
Boolean($connectionRequests.get()[keyFor(sessionId)])
/** The composer's Enter handler reads this without subscribing. */
export const hasConnectionRequest = (sessionId: string | null | undefined): boolean => {
const request = $connectionRequests.get()[keyFor(sessionId)]
/** Send the card's answer. Clears the entry first so the card cannot be answered twice;
* false when the request is already gone. */
return Boolean(request && !request.settled)
}
/** Drive the operation. The entry stays in the store: the backend answers with `connection.update`
* and the card re-renders from that; only settlement removes it. */
export async function respondToConnectionRequest(request: ConnectionRequest, outcome: ConnectionOutcome): Promise<boolean> {
const current = $connectionRequests.get()[keyFor(request.sessionId)]
if (!current || current.requestId !== request.requestId) {
if (!current || current.opId !== request.opId || current.settled) {
return false
}
clearConnectionRequest(request.requestId, request.sessionId)
return respondToServerRequest(request.requestId, {
settled_by: outcome.settled_by,
targets: outcome.targets
await $gateway.get()?.request('connection.respond', {
op_id: request.opId,
result: outcome,
session_id: request.sessionId
})
return true
}
/** Typing a message while the card is open declines every target, otherwise the typed
* message would wait behind the blocked tool until the deadline. */
/** Not now on one target. */
export const skipConnectionTarget = (request: ConnectionRequest, name: string): Promise<boolean> =>
respondToConnectionRequest(request, { targets: [{ name, status: 'skipped' }] })
/** Continue: end the operation now with whatever is unresolved. */
export const continueConnectionRequest = (request: ConnectionRequest): Promise<boolean> =>
respondToConnectionRequest(request, { settled_by: 'continue' })
// Typing a message while the card is open ends the operation, otherwise the typed message waits behind
// the blocked tool until the deadline.
export async function skipConnectionRequest(sessionId: string | null | undefined): Promise<boolean> {
const request = $connectionRequests.get()[keyFor(sessionId)]
if (!request) {
if (!request || request.settled) {
return false
}
try {
await respondToConnectionRequest(request, {
settled_by: 'all_resolved',
targets: request.targets.map(target => ({ name: target.name, state: 'declined' }))
})
await continueConnectionRequest(request)
} catch {
// A failed skip must not block the message being sent; the tool settles on its deadline.
// A failed skip must not block the message; the tool settles at its deadline.
}
return true
@@ -1,133 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createConnectorFlow } from './connector-flow'
const listed = (connected: boolean) => ({
available: true,
connectors: [{ connector: 'gmail', connected, enabled: true }]
})
function flowWith(responses: { list: () => unknown; connect?: () => unknown }, onWaiting = vi.fn()) {
const open = vi.fn(async () => {})
const request = vi.fn(async (method: string) => {
if (method === 'connectors.list') {
return responses.list()
}
return responses.connect?.() ?? { results: [] }
})
const flow = createConnectorFlow('session', [{ connector: 'gmail' }], {
request: request as never,
open,
onWaiting,
delay: async () => {},
now: () => 0
})
return { flow, open, onWaiting, request }
}
describe('the moment the browser has the sign-in', () => {
it('reports waiting once the link is open, before the poll settles', async () => {
let connected = false
const order: string[] = []
const { flow, onWaiting, open } = flowWith(
{
list: () => listed(connected),
connect: () => ({ results: [{ connector: 'gmail', status: 'initiated', connect_url: 'https://auth.test/x' }] })
},
vi.fn(() => {
order.push('waiting')
// The user authorizes in the browser; the next poll sees it.
connected = true
})
)
open.mockImplementation(async () => {
order.push('open')
})
await flow.refresh()
await flow.connect('gmail')
expect(order).toEqual(['open', 'waiting'])
expect(onWaiting).toHaveBeenCalledWith('gmail')
expect(flow.state.get().rows[0].phase).toBe('connected')
})
it('says nothing when the mint fails, so the agent is not sent to wait on nothing', async () => {
const { flow, onWaiting } = flowWith({
list: () => listed(false),
connect: () => ({ results: [{ connector: 'gmail', status: 'error' }] })
})
await flow.refresh()
await flow.connect('gmail')
expect(onWaiting).not.toHaveBeenCalled()
expect(flow.state.get().rows[0].phase).toBe('error')
})
it('says nothing when the app was already active — there is no browser step', async () => {
const { flow, onWaiting } = flowWith({
list: () => listed(true),
connect: () => ({ results: [{ connector: 'gmail', status: 'active' }] })
})
await flow.refresh()
await flow.connect('gmail')
expect(onWaiting).not.toHaveBeenCalled()
})
it('sends the agent back in when the user keeps waiting after a timeout', async () => {
let clock = 0
const open = vi.fn(async () => {})
const onWaiting = vi.fn()
const request = vi.fn(async (method: string) =>
method === 'connectors.list'
? listed(false)
: { results: [{ connector: 'gmail', status: 'initiated', connect_url: 'https://auth.test/x' }] }
)
const flow = createConnectorFlow('session', [{ connector: 'gmail' }], {
request: request as never,
open,
onWaiting,
// Each poll tick burns the whole window so the first wait times out.
delay: async () => {
clock += 120001
},
now: () => clock
})
await flow.refresh()
await flow.connect('gmail')
expect(flow.state.get().rows[0].phase).toBe('timeout')
expect(onWaiting).toHaveBeenCalledTimes(1)
await flow.keepWaiting('gmail')
expect(onWaiting).toHaveBeenCalledTimes(2)
})
it('asks the gateway to reconnect, not connect, when the grant is expired or revoked', async () => {
const { flow, request } = flowWith({
list: () => ({
available: true,
connectors: [{ connector: 'gmail', connected: false, enabled: true, connectionStatus: 'expired' }]
}),
connect: () => ({ results: [{ connector: 'gmail', status: 'active' }] })
})
await flow.refresh()
await flow.connect('gmail')
expect(request).toHaveBeenCalledWith('connectors.connect', expect.objectContaining({ reconnect: true }))
})
})
-259
View File
@@ -1,259 +0,0 @@
import { atom } from 'nanostores'
import { connectorAuthorizationUrl, type ConnectorRow, recordOf } from '@/lib/connector-tools'
export type ConnectorPhase = 'idle' | 'opening' | 'waiting' | 'connected' | 'timeout' | 'error' | 'skipped'
export interface ConnectorFlowRow extends ConnectorRow {
phase: ConnectorPhase
error?: string
}
export interface ConnectorFlowState {
loading: boolean
available: boolean
rows: ConnectorFlowRow[]
error?: string
}
export interface ConnectorFlowDeps {
request: <T>(method: string, params: { session_id: string; connectors?: string[]; reconnect?: boolean }) => Promise<T>
open: (url: string) => Promise<void>
/** The browser has the sign-in and the card is now waiting on the user.
* Also fired when the user asks to keep waiting after a timeout. */
onWaiting?: (slug: string) => void
delay?: () => Promise<void>
now?: () => number
}
/** One mounted tool offer. Only explicit user actions can mint or open links. */
export function createConnectorFlow(sessionId: string, seeds: ConnectorRow[], deps: ConnectorFlowDeps) {
const state = atom<ConnectorFlowState>({
loading: true,
available: false,
rows: seeds.map(row => ({ ...row, phase: 'idle', connected: false }))
})
let disposed = false
let refreshGeneration = 0
const attempts = new Map<string, number>()
const delay = deps.delay ?? (() => new Promise(resolve => setTimeout(resolve, 2000)))
const now = deps.now ?? Date.now
const valid = (slug: string, token: number) => !disposed && attempts.get(slug) === token
const update = (slug: string, changes: Partial<ConnectorFlowRow>) => {
if (disposed) {
return
}
refreshGeneration += 1
const current = state.get()
state.set({ ...current, rows: current.rows.map(row => (row.connector === slug ? { ...row, ...changes } : row)) })
}
const list = async () => {
const response = await deps.request<{ available: boolean; connectors: ConnectorRow[] }>('connectors.list', {
session_id: sessionId
})
if ((response.available !== true && response.available !== false) || !Array.isArray(response.connectors)) {
throw new Error('Invalid connector status response')
}
return response
}
const refresh = async () => {
disposed = false
const generation = ++refreshGeneration
try {
const response = await list()
if (disposed || generation !== refreshGeneration) {
return
}
const current = state.get()
// The tool may return the whole catalog or a few requested apps. Do not
// replace a targeted offer with every app the gateway happens to know.
const wanted = seeds.length ? seeds : response.connectors
const rows = wanted.map((seed): ConnectorFlowRow => {
const live = response.connectors.find(row => row.connector === seed.connector)
const previous = current.rows.find(row => row.connector === seed.connector)
const phase = previous?.phase ?? 'idle'
return {
...seed,
...live,
connector: seed.connector,
enabled: response.available && live?.enabled !== false && !!live,
connected: live?.connected === true,
phase:
phase === 'skipped'
? phase
: live?.connected
? 'connected'
: ['opening', 'waiting'].includes(phase)
? phase
: 'idle'
}
})
state.set({ loading: false, available: response.available, rows })
} catch {
if (!disposed && generation === refreshGeneration) {
state.set({ ...state.get(), loading: false, error: 'status' })
}
}
}
const wait = async (slug: string, token: number) => {
const deadline = now() + 120000
let failures = 0
while (valid(slug, token) && now() < deadline) {
await delay()
if (!valid(slug, token)) {
return
}
try {
const response = await list()
if (!valid(slug, token)) {
return
}
if (!response.available) {
update(slug, { phase: 'error', error: 'unavailable' })
return
}
const row = response.connectors.find(row => row.connector === slug)
if (row?.connected) {
update(slug, { ...row, phase: 'connected', error: undefined })
return
}
if (row?.enabled === false || !row) {
update(slug, { phase: 'error', error: 'unavailable' })
return
}
failures = 0
} catch {
if (++failures >= 3) {
update(slug, { phase: 'error', error: 'status' })
return
}
}
}
if (valid(slug, token)) {
update(slug, { phase: 'timeout' })
}
}
const connect = async (slug: string) => {
const row = state.get().rows.find(row => row.connector === slug)
if (
disposed ||
!state.get().available ||
state.get().error ||
!row ||
row.enabled === false ||
['opening', 'waiting'].includes(row.phase)
) {
return
}
const token = (attempts.get(slug) ?? 0) + 1
attempts.set(slug, token)
update(slug, { phase: 'opening', error: undefined })
try {
const response = await deps.request<{ results: unknown[] }>('connectors.connect', {
session_id: sessionId,
connectors: [slug],
reconnect: ['expired', 'revoked'].includes(row.connectionStatus ?? '')
})
if (!valid(slug, token)) {
return
}
const entry = (response.results ?? []).map(recordOf).find(result => result.connector === slug)
if (entry?.status === 'active') {
update(slug, { phase: 'idle' })
await refresh()
return
}
const url = connectorAuthorizationUrl(entry?.connect_url)
if (entry?.status !== 'initiated' || !url) {
throw new Error('Authorization unavailable')
}
await deps.open(url)
if (!valid(slug, token)) {
return
}
update(slug, { phase: 'waiting' })
deps.onWaiting?.(slug)
await wait(slug, token)
} catch {
if (valid(slug, token)) {
update(slug, { phase: 'error', error: 'connect' })
}
}
}
return {
state,
refresh,
connect,
keepWaiting: async (slug: string) => {
const row = state.get().rows.find(row => row.connector === slug)
if (
disposed ||
!row ||
row.enabled === false ||
!state.get().available ||
!['timeout', 'error'].includes(row.phase)
) {
return
}
const token = (attempts.get(slug) ?? 0) + 1
attempts.set(slug, token)
update(slug, { phase: 'waiting', error: undefined })
// The agent's own wait timed out alongside ours; send it back in.
deps.onWaiting?.(slug)
await wait(slug, token)
},
skip: (slug: string) => {
attempts.set(slug, (attempts.get(slug) ?? 0) + 1)
update(slug, { phase: 'skipped', error: undefined })
},
dispose: () => {
disposed = true
refreshGeneration += 1
for (const [slug, token] of attempts) {
attempts.set(slug, token + 1)
}
}
}
}
@@ -11,14 +11,22 @@ import {
} from '@/lib/connector-tools'
import { buildConnectionStartMessage, canStartWithConnections } from '@/lib/first-build-start'
import { readKey, writeKey } from '@/lib/storage'
import type { ConnectorFlowDeps, ConnectorFlowRow } from '@/store/connector-flow'
export type FirstBuildConnectorPart = Pick<ToolCallMessagePart, 'toolCallId' | 'toolName' | 'args' | 'result'>
export interface FirstBuildConnectorRow extends ConnectorFlowRow {
// Onboarding's own poller; PR3 (NS-869) moves the guided flow onto the connection operation.
export type FirstBuildConnectorPhase = 'idle' | 'opening' | 'waiting' | 'connected' | 'timeout' | 'error' | 'skipped'
export interface FirstBuildConnectorRow extends ConnectorRow {
phase: FirstBuildConnectorPhase
error?: string
connectUrl?: string
}
export type FirstBuildConnectorRequest = <T>(
method: string,
params: { session_id: string; connectors?: string[]; reconnect?: boolean }
) => Promise<T>
export interface FirstBuildConnectorState {
toolCallId: string
rows: FirstBuildConnectorRow[]
@@ -128,7 +136,7 @@ export function watchFirstBuildRows(
storedId: string,
runtimeId: string,
part: FirstBuildConnectorPart,
request: ConnectorFlowDeps['request']
request: FirstBuildConnectorRequest
) {
const action = recordOf(part.args).action
+1 -1
View File
@@ -128,7 +128,7 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
'From there, walk them through setup conversationally, one turn each, in this order:',
'1. This turn is exactly four things and then you stop: a few warm words about their name, then ::onboarding{step="name" value="THEIR_NAME"} on a line of its own (THEIR_NAME being the name they actually gave; it renders as nothing and just saves it), then one short sentence about their colour, then ::onboarding{step="look"} on a line of its own. That is one turn, not two, and it is not a conflict with RULE 3: the name line is not a question, the look card is, and it is the last thing you write.',
'2. Then the apps they already use, so Hermes can connect to them later: one short sentence that makes clear what connecting means — you would read and act inside those apps for them (their inbox, their calendar, their repos), not message them there — then ::onboarding{step="connectors"} on a line of its own. Chat apps like Discord or Telegram are a different thing (how they reach you) and are not what this card is asking about; if they bring one up, say it lives in Messaging in the app’s settings and move on.',
'CONNECTING, IF THEY ASK FOR IT HERE. The picks are preferences, not connections — but if at any point they ask you to connect an app, or say they want one wired up now, do it in this chat: call manage_connections action="status" once, then one action="connect" with EVERY app they named as a batch (connectors=["gmail","googlecalendar"], not one call per app). The app renders that as a Connect card per app — the card is the ask, so write one short line and END YOUR TURN; never paste the links, never describe a settings page. Their click arrives as a hidden [connectors] message telling you the exact next call; follow it, and when it says wait, call action="wait" and hold. Never call connect a second time for an app that already has a card: a new link cancels the one they are signing in with. If an app is not in the status catalog, say so plainly. There is no Connectors page in Settings; do not send them to one.',
'CONNECTING, IF THEY ASK FOR IT HERE. The picks are preferences, not connections — but if at any point they ask you to connect an app, or say they want one wired up now, do it in this chat: call manage_connections action="status" once, then one action="connect" with EVERY app they named as a batch (connectors=["gmail","googlecalendar"], not one call per app). The app renders that as a Connect card per app and the call blocks until every app is connected, skipped, or the deadline passes; never paste the links, never describe a settings page. The result lists each app as connected, skipped or not_connected; continue from that. Never call connect a second time for an app that already has a card. If an app is not in the status catalog, say so plainly. There is no Connectors page in Settings; do not send them to one.',
// The only place sign-in is named before it is needed. It sits at the connectors step because the user has just
// listed the accounts they use.
...(signedIn
+4
View File
@@ -1,3 +1,5 @@
import type { ConnectionRequestPayload } from '@hermes/shared'
export interface ConfigFieldSchema {
category?: string
description?: string
@@ -678,6 +680,8 @@ export interface SessionResumeResult {
// handlers before this response resolves; listed here so resume can tell an
// authoritative "nothing pending" from a request the handler declined.
open_requests?: Array<{ id: string; method: string; params: Record<string, unknown> & { session_id?: string } }>
// The connection operation still blocking this session; resume restores the backend-owned card projection.
pending_connection?: ConnectionRequestPayload
info?: SessionRuntimeInfo
message_count: number
messages: SessionMessage[]
+102 -44
View File
@@ -458,6 +458,62 @@ export interface ProjectFacts {
verifyCommands: string[]
contextFiles: string[]
}
export interface ConnectionOperationParams {
profile?: string | null
session_id: string
op_id: string
}
/** ``methods_connectors._operation_view``: the operation's full snapshot. */
export interface ConnectionOperationStatus {
op_id: string
deadline_at: number
settled: boolean
settled_at?: number | null
settled_by?: ConnectionSettleReason | null
targets: ConnectionOperationTarget[]
}
/** ``tools/connectors/contract.py::SettleReason``. */
export type ConnectionSettleReason = 'all_resolved' | 'continue' | 'deadline' | 'interrupt' | 'unavailable'
/** ``Target.snapshot``: the link minted up front rides here, never in the model result. ``extra`` keys a leg records (``tools``, ``hint``) are typed here as they appear. */
export interface ConnectionOperationTarget {
name: string
kind: ConnectionTargetKind
action: ConnectionTargetAction
state: ConnectionTargetState
detail?: string | null
connect_url?: string | null
attempt?: string | null
tools?: string[] | null
hint?: string | null
}
export type ConnectionTargetKind = 'connector' | 'mcp'
export type ConnectionTargetAction = 'authorize' | 'connect' | 'enable' | 'install' | 'reconnect'
/** ``tools/connectors/contract.py::TargetState``. */
export type ConnectionTargetState = 'pending' | 'initiated' | 'connected' | 'skipped' | 'failed' | 'expired' | 'unavailable' | 'not_connected'
export interface ConnectionRespondParams {
profile?: string | null
session_id: string
op_id: string
result: ConnectionAnswer
}
/** The card's answer: per-target outcomes and an optional Continue (``settled_by: "continue"``). Settlement is derived from target states afterwards. */
export interface ConnectionAnswer {
targets?: ConnectionAnswerTarget[]
settled_by?: ConnectionSettleReason | null
}
/** One row's answer from the card. ``status`` is what the card observed for that row (``tools/connectors/mcp.py::_OUTCOME_STATES`` maps it onto a target state); ``state`` is the older spelling of the same field and one of the two is present. */
export interface ConnectionAnswerTarget {
name: string
status?: string | null
state?: string | null
detail?: string | null
tools?: string[] | null
[key: string]: unknown
}
export interface ConnectionRespondResult {
status: string
settled: boolean
}
/** ``key`` selects one getter from ``_CONFIG_GETTERS``; ``cwd`` feeds the ``project`` getter, ``session_id`` lets ``reasoning`` / ``fast`` answer with the session's live pin. */
export interface ConfigGetParams {
profile?: string | null
@@ -719,18 +775,16 @@ export interface ConnectorsConnectParams {
connectors: string[]
reconnect?: boolean
}
/** The operation the connect opened (or re-minted on): ``tools/connectors/managed.py`` ``_off_desktop_result`` / ``methods_connectors._reissue``. ``status``/``note`` ride along from the tool result when the call ran through ``manage_connections``. */
export interface ConnectorsConnectResult {
results: ConnectorConnectEntry[]
summary: Record<string, unknown>
}
/** ``tools/connections_tool.py`` per-connector authorization outcome. */
export interface ConnectorConnectEntry {
connector?: string
op_id: string
deadline_at: number
settled: boolean
settled_at?: number | null
settled_by?: ConnectionSettleReason | null
targets: ConnectionOperationTarget[]
status?: string | null
connect_url?: string | null
note?: string | null
instruction?: string | null
[key: string]: unknown
}
export interface ImageGenerateParams {
prompt?: string | null
@@ -2460,6 +2514,7 @@ export interface SessionResumeResult {
queued?: QueuedPrompt | null
pending_approval?: PendingApproval | null
open_requests?: OpenRequestEntry[] | null
pending_connection?: ConnectionRequestPayload | null
todo_state?: TodoState | null
auto_continue?: AutoContinue | null
}
@@ -2484,6 +2539,14 @@ export interface OpenRequestEntry {
method: string
params: Record<string, unknown>
}
/** ``ConnectionOperation.request_payload``: opens the card; also the ``pending_connection`` resume snapshot so a client that missed the event restores the card with the server's deadline. */
export interface ConnectionRequestPayload {
op_id: string
deadline_at: number
timeout_seconds: number
targets: ConnectionOperationTarget[]
tool_call_id?: string | null
}
/** ``tool_progress._normalize_todo_state``: the authoritative todo snapshot. */
export interface TodoState {
todos: Record<string, unknown>[]
@@ -2518,6 +2581,7 @@ export interface SessionActivateResult {
queued?: QueuedPrompt | null
pending_approval?: PendingApproval | null
open_requests?: OpenRequestEntry[] | null
pending_connection?: ConnectionRequestPayload | null
todo_state?: TodoState | null
auto_continue?: AutoContinue | null
}
@@ -3675,37 +3739,6 @@ export interface VaultCodeRequestParams {
site?: string | null
hint?: string | null
}
/** ``tools/connections_tool_operation.py::ConnectionOperation.request_payload`` — one operation, N targets, a server-owned deadline (epoch seconds) the restored card keeps. */
export interface ConnectionRequestParams {
session_id: string
op_id: string
deadline_at: number
timeout_seconds: number
reason?: string
targets: ConnectionRequestTarget[]
}
/** One row of the card: ``tools/connections_tool_operation.py::ConnectionOperation.request_payload``. */
export interface ConnectionRequestTarget {
name: string
kind: ConnectionTargetKind
action: ConnectionAction
}
export type ConnectionTargetKind = 'connector' | 'mcp'
export type ConnectionAction = 'authorize' | 'enable' | 'install'
/** Per-target outcomes. The backend derives the settle reason from target state; the renderer's ``settled_by`` is its own claim and is not trusted. */
export interface ConnectionResult {
settled_by: ConnectionSettledBy
targets: ConnectionTargetOutcome[]
}
export type ConnectionSettledBy = 'all_resolved' | 'continue'
/** The card's answer for one target (``tools/connections_tool_mcp.py::_apply_answer``). */
export interface ConnectionTargetOutcome {
name: string
state: ConnectionTargetOutcomeState
detail?: string | null
tools?: string[] | null
}
export type ConnectionTargetOutcomeState = 'installed' | 'enabled' | 'authorized' | 'declined' | 'error'
export interface ReadRangeRequestParams {
session_id: string
start?: number | null
@@ -3744,6 +3777,22 @@ export interface TourStep {
side?: string | null
[key: string]: unknown
}
/** ``methods_connectors._connection_update``: one target transition (``target``/``from``/``to``/ ``actor``) or the settlement (none of those), with the full snapshot. */
export interface ConnectionUpdatePayload {
op_id: string
deadline_at: number
settled: boolean
settled_at?: number | null
settled_by?: ConnectionSettleReason | null
targets: ConnectionOperationTarget[]
target?: string | null
from?: ConnectionTargetState | null
to?: ConnectionTargetState | null
actor?: ConnectionActor | null
detail?: string | null
}
/** ``tools/connectors/contract.py::Actor``. */
export type ConnectionActor = 'user' | 'renderer_flow' | 'backend_watcher' | 'clock'
/** ``tui_gateway/entry.py`` (stdio) / ``tui_gateway/ws.py`` (WebSocket) first frame. */
export interface GatewayReadyPayload {
skin: SkinPayload
@@ -4163,10 +4212,14 @@ export interface RpcMethods {
'config.set': { params: ConfigSetParams; result: ConfigSetResult }
/** Masked, display-ready config summary (model / agent / environment rows). */
'config.show': { params: ConfigShowParams; result: ConfigShowResult }
/** Start (or re-initiate) authorization for named connectors; returns per-connector links/status. */
/** Per-target outcomes from the card, and an optional Continue. */
'connection.respond': { params: ConnectionRespondParams; result: ConnectionRespondResult }
/** Start (or re-initiate) authorization for named connectors on the session's connection operation. */
'connectors.connect': { params: ConnectorsConnectParams; result: ConnectorsConnectResult }
/** Connector catalog + connection state for one owned session (``available=False`` when the toolset is off). */
'connectors.list': { params: ConnectorsListParams; result: ConnectorsListResult }
/** The current snapshot of one open operation on an owned session. */
'connectors.operation.status': { params: ConnectionOperationParams; result: ConnectionOperationStatus }
/** List/add/remove/pause/resume cron jobs in the (optionally profile-scoped) cron store. */
'cron.manage': { params: CronManageParams; result: CronManageResult }
/** Block/unblock NEW spawns globally (active children keep running); returns the new state. */
@@ -4567,8 +4620,10 @@ export const RPC_METHODS = [
'config.get',
'config.set',
'config.show',
'connection.respond',
'connectors.connect',
'connectors.list',
'connectors.operation.status',
'cron.manage',
'delegation.pause',
'delegation.status',
@@ -4761,8 +4816,6 @@ export interface ServerRequestMap {
approval: { params: ApprovalRequestParams; result: ApprovalResult }
/** The clarify tool: ask the user one question or a batch. */
clarify: { params: ClarifyRequestParams; result: ClarifyResult }
/** The manage_connections approval card: install / enable / authorise local MCP servers. */
connection: { params: ConnectionRequestParams; result: ConnectionResult }
/** Click / type / scroll / annotate inside the in-app browser preview. */
'preview.act': { params: PreviewActRequestParams; result: ValueResult }
/** Read the in-app browser preview's text (JSON text answer). */
@@ -4788,7 +4841,6 @@ export type ServerRequestMethod = keyof ServerRequestMap
export const SERVER_REQUEST_METHODS = [
'approval',
'clarify',
'connection',
'preview.act',
'preview.read',
'secret',
@@ -4819,6 +4871,10 @@ export interface BackendGatewayEventMap {
'browser.progress': BrowserProgressPayload
/** A /btw side question was answered. */
'btw.complete': SideAgentCompletePayload
/** A connection operation opened on this session; the desktop renders its card. */
'connection.request': ConnectionRequestPayload
/** One transition or the settlement of an open connection operation. */
'connection.update': ConnectionUpdatePayload
/** cron/jobs.json moved; refetch the cron list. */
'cron.changed': ChangeSignalPayload
/** A session-level failure outside a turn (agent init, model switch, compression, resume). */
@@ -4948,6 +5004,8 @@ export const GATEWAY_EVENT_TYPES = [
'browser.controller.command',
'browser.progress',
'btw.complete',
'connection.request',
'connection.update',
'cron.changed',
'error',
'gateway.ready',
+585 -157
View File
@@ -528,9 +528,27 @@
}
}
},
{
"name": "connection.respond",
"summary": "Per-target outcomes from the card, and an optional Continue.",
"params": [
{
"name": "params",
"schema": {
"$ref": "#/components/schemas/ConnectionRespondParams"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/ConnectionRespondResult"
}
}
},
{
"name": "connectors.connect",
"summary": "Start (or re-initiate) authorization for named connectors; returns per-connector links/status.",
"summary": "Start (or re-initiate) authorization for named connectors on the session's connection operation.",
"params": [
{
"name": "params",
@@ -564,6 +582,24 @@
}
}
},
{
"name": "connectors.operation.status",
"summary": "The current snapshot of one open operation on an owned session.",
"params": [
{
"name": "params",
"schema": {
"$ref": "#/components/schemas/ConnectionOperationParams"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/ConnectionOperationStatus"
}
}
},
{
"name": "cron.manage",
"summary": "List/add/remove/pause/resume cron jobs in the (optionally profile-scoped) cron store.",
@@ -8196,129 +8232,74 @@
"title": "ConfigShowResult",
"type": "object"
},
"ConnectionAction": {
"ConnectionActor": {
"description": "``tools/connectors/contract.py::Actor``.",
"enum": [
"authorize",
"enable",
"install"
"user",
"renderer_flow",
"backend_watcher",
"clock"
],
"title": "ConnectionAction",
"title": "ConnectionActor",
"type": "string"
},
"ConnectionRequestParams": {
"ConnectionAnswer": {
"additionalProperties": false,
"description": "``tools/connections_tool_operation.py::ConnectionOperation.request_payload`` \u2014 one operation,\nN targets, a server-owned deadline (epoch seconds) the restored card keeps.",
"description": "The card's answer: per-target outcomes and an optional Continue\n(``settled_by: \"continue\"``). Settlement is derived from target states afterwards.",
"properties": {
"session_id": {
"title": "Session Id",
"type": "string"
},
"op_id": {
"title": "Op Id",
"type": "string"
},
"deadline_at": {
"title": "Deadline At",
"type": "number"
},
"timeout_seconds": {
"title": "Timeout Seconds",
"type": "number"
},
"reason": {
"default": "",
"title": "Reason",
"type": "string"
},
"targets": {
"items": {
"$ref": "#/components/schemas/ConnectionRequestTarget"
"$ref": "#/components/schemas/ConnectionAnswerTarget"
},
"title": "Targets",
"type": "array"
}
},
"required": [
"session_id",
"op_id",
"deadline_at",
"timeout_seconds",
"targets"
],
"title": "ConnectionRequestParams",
"type": "object"
},
"ConnectionRequestTarget": {
"additionalProperties": false,
"description": "One row of the card: ``tools/connections_tool_operation.py::ConnectionOperation.request_payload``.",
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/ConnectionTargetKind"
},
"action": {
"$ref": "#/components/schemas/ConnectionAction"
}
},
"required": [
"name",
"kind",
"action"
],
"title": "ConnectionRequestTarget",
"type": "object"
},
"ConnectionResult": {
"additionalProperties": false,
"description": "Per-target outcomes. The backend derives the settle reason from target state; the renderer's\n``settled_by`` is its own claim and is not trusted.",
"properties": {
"settled_by": {
"$ref": "#/components/schemas/ConnectionSettledBy"
},
"targets": {
"items": {
"$ref": "#/components/schemas/ConnectionTargetOutcome"
},
"title": "Targets",
"type": "array"
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionSettleReason"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"settled_by",
"targets"
],
"title": "ConnectionResult",
"title": "ConnectionAnswer",
"type": "object"
},
"ConnectionSettledBy": {
"enum": [
"all_resolved",
"continue"
],
"title": "ConnectionSettledBy",
"type": "string"
},
"ConnectionTargetKind": {
"enum": [
"connector",
"mcp"
],
"title": "ConnectionTargetKind",
"type": "string"
},
"ConnectionTargetOutcome": {
"additionalProperties": false,
"description": "The card's answer for one target (``tools/connections_tool_mcp.py::_apply_answer``).",
"ConnectionAnswerTarget": {
"additionalProperties": true,
"description": "One row's answer from the card. ``status`` is what the card observed for that row\n(``tools/connectors/mcp.py::_OUTCOME_STATES`` maps it onto a target state); ``state`` is the\nolder spelling of the same field and one of the two is present.",
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"status": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Status"
},
"state": {
"$ref": "#/components/schemas/ConnectionTargetOutcomeState"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "State"
},
"detail": {
"anyOf": [
@@ -8349,33 +8330,15 @@
}
},
"required": [
"name",
"state"
"name"
],
"title": "ConnectionTargetOutcome",
"title": "ConnectionAnswerTarget",
"type": "object"
},
"ConnectionTargetOutcomeState": {
"enum": [
"installed",
"enabled",
"authorized",
"declined",
"error"
],
"title": "ConnectionTargetOutcomeState",
"type": "string"
},
"ConnectorConnectEntry": {
"additionalProperties": true,
"description": "``tools/connections_tool.py`` per-connector authorization outcome.",
"ConnectionOperationParams": {
"additionalProperties": false,
"properties": {
"connector": {
"default": "",
"title": "Connector",
"type": "string"
},
"status": {
"profile": {
"anyOf": [
{
"type": "string"
@@ -8385,7 +8348,108 @@
}
],
"default": null,
"title": "Status"
"title": "Profile"
},
"session_id": {
"title": "Session Id",
"type": "string"
},
"op_id": {
"title": "Op Id",
"type": "string"
}
},
"required": [
"session_id",
"op_id"
],
"title": "ConnectionOperationParams",
"type": "object"
},
"ConnectionOperationStatus": {
"additionalProperties": false,
"description": "``methods_connectors._operation_view``: the operation's full snapshot.",
"properties": {
"op_id": {
"title": "Op Id",
"type": "string"
},
"deadline_at": {
"title": "Deadline At",
"type": "number"
},
"settled": {
"title": "Settled",
"type": "boolean"
},
"settled_at": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Settled At"
},
"settled_by": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionSettleReason"
},
{
"type": "null"
}
],
"default": null
},
"targets": {
"items": {
"$ref": "#/components/schemas/ConnectionOperationTarget"
},
"title": "Targets",
"type": "array"
}
},
"required": [
"op_id",
"deadline_at",
"settled",
"targets"
],
"title": "ConnectionOperationStatus",
"type": "object"
},
"ConnectionOperationTarget": {
"additionalProperties": false,
"description": "``Target.snapshot``: the link minted up front rides here, never in the model result. ``extra``\nkeys a leg records (``tools``, ``hint``) are typed here as they appear.",
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/ConnectionTargetKind"
},
"action": {
"$ref": "#/components/schemas/ConnectionTargetAction"
},
"state": {
"$ref": "#/components/schemas/ConnectionTargetState"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Detail"
},
"connect_url": {
"anyOf": [
@@ -8399,7 +8463,7 @@
"default": null,
"title": "Connect Url"
},
"note": {
"attempt": {
"anyOf": [
{
"type": "string"
@@ -8409,9 +8473,24 @@
}
],
"default": null,
"title": "Note"
"title": "Attempt"
},
"instruction": {
"tools": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Tools"
},
"hint": {
"anyOf": [
{
"type": "string"
@@ -8421,10 +8500,274 @@
}
],
"default": null,
"title": "Instruction"
"title": "Hint"
}
},
"title": "ConnectorConnectEntry",
"required": [
"name",
"kind",
"action",
"state"
],
"title": "ConnectionOperationTarget",
"type": "object"
},
"ConnectionRequestPayload": {
"additionalProperties": false,
"description": "``ConnectionOperation.request_payload``: opens the card; also the ``pending_connection`` resume\nsnapshot so a client that missed the event restores the card with the server's deadline.",
"properties": {
"op_id": {
"title": "Op Id",
"type": "string"
},
"deadline_at": {
"title": "Deadline At",
"type": "number"
},
"timeout_seconds": {
"title": "Timeout Seconds",
"type": "number"
},
"targets": {
"items": {
"$ref": "#/components/schemas/ConnectionOperationTarget"
},
"title": "Targets",
"type": "array"
},
"tool_call_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Tool Call Id"
}
},
"required": [
"op_id",
"deadline_at",
"timeout_seconds",
"targets"
],
"title": "ConnectionRequestPayload",
"type": "object"
},
"ConnectionRespondParams": {
"additionalProperties": false,
"properties": {
"profile": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Profile"
},
"session_id": {
"title": "Session Id",
"type": "string"
},
"op_id": {
"title": "Op Id",
"type": "string"
},
"result": {
"$ref": "#/components/schemas/ConnectionAnswer"
}
},
"required": [
"session_id",
"op_id",
"result"
],
"title": "ConnectionRespondParams",
"type": "object"
},
"ConnectionRespondResult": {
"additionalProperties": false,
"properties": {
"status": {
"title": "Status",
"type": "string"
},
"settled": {
"title": "Settled",
"type": "boolean"
}
},
"required": [
"status",
"settled"
],
"title": "ConnectionRespondResult",
"type": "object"
},
"ConnectionSettleReason": {
"description": "``tools/connectors/contract.py::SettleReason``.",
"enum": [
"all_resolved",
"continue",
"deadline",
"interrupt",
"unavailable"
],
"title": "ConnectionSettleReason",
"type": "string"
},
"ConnectionTargetAction": {
"enum": [
"authorize",
"connect",
"enable",
"install",
"reconnect"
],
"title": "ConnectionTargetAction",
"type": "string"
},
"ConnectionTargetKind": {
"enum": [
"connector",
"mcp"
],
"title": "ConnectionTargetKind",
"type": "string"
},
"ConnectionTargetState": {
"description": "``tools/connectors/contract.py::TargetState``.",
"enum": [
"pending",
"initiated",
"connected",
"skipped",
"failed",
"expired",
"unavailable",
"not_connected"
],
"title": "ConnectionTargetState",
"type": "string"
},
"ConnectionUpdatePayload": {
"additionalProperties": false,
"description": "``methods_connectors._connection_update``: one target transition (``target``/``from``/``to``/\n``actor``) or the settlement (none of those), with the full snapshot.",
"properties": {
"op_id": {
"title": "Op Id",
"type": "string"
},
"deadline_at": {
"title": "Deadline At",
"type": "number"
},
"settled": {
"title": "Settled",
"type": "boolean"
},
"settled_at": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Settled At"
},
"settled_by": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionSettleReason"
},
{
"type": "null"
}
],
"default": null
},
"targets": {
"items": {
"$ref": "#/components/schemas/ConnectionOperationTarget"
},
"title": "Targets",
"type": "array"
},
"target": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Target"
},
"from": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionTargetState"
},
{
"type": "null"
}
],
"default": null
},
"to": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionTargetState"
},
{
"type": "null"
}
],
"default": null
},
"actor": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionActor"
},
{
"type": "null"
}
],
"default": null
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Detail"
}
},
"required": [
"op_id",
"deadline_at",
"settled",
"targets"
],
"title": "ConnectionUpdatePayload",
"type": "object"
},
"ConnectorRow": {
@@ -8541,23 +8884,80 @@
},
"ConnectorsConnectResult": {
"additionalProperties": false,
"description": "The operation the connect opened (or re-minted on): ``tools/connectors/managed.py``\n``_off_desktop_result`` / ``methods_connectors._reissue``. ``status``/``note`` ride along from\nthe tool result when the call ran through ``manage_connections``.",
"properties": {
"results": {
"op_id": {
"title": "Op Id",
"type": "string"
},
"deadline_at": {
"title": "Deadline At",
"type": "number"
},
"settled": {
"title": "Settled",
"type": "boolean"
},
"settled_at": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Settled At"
},
"settled_by": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionSettleReason"
},
{
"type": "null"
}
],
"default": null
},
"targets": {
"items": {
"$ref": "#/components/schemas/ConnectorConnectEntry"
"$ref": "#/components/schemas/ConnectionOperationTarget"
},
"title": "Results",
"title": "Targets",
"type": "array"
},
"summary": {
"additionalProperties": true,
"title": "Summary",
"type": "object"
"status": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Status"
},
"note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Note"
}
},
"required": [
"results",
"summary"
"op_id",
"deadline_at",
"settled",
"targets"
],
"title": "ConnectorsConnectResult",
"type": "object"
@@ -23492,6 +23892,17 @@
"default": null,
"title": "Open Requests"
},
"pending_connection": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionRequestPayload"
},
{
"type": "null"
}
],
"default": null
},
"todo_state": {
"anyOf": [
{
@@ -26126,6 +26537,17 @@
"default": null,
"title": "Open Requests"
},
"pending_connection": {
"anyOf": [
{
"$ref": "#/components/schemas/ConnectionRequestPayload"
},
{
"type": "null"
}
],
"default": null
},
"todo_state": {
"anyOf": [
{
@@ -32975,24 +33397,6 @@
}
}
},
{
"name": "connection",
"summary": "The manage_connections approval card: install / enable / authorise local MCP servers.",
"params": [
{
"name": "params",
"schema": {
"$ref": "#/components/schemas/ConnectionRequestParams"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/ConnectionResult"
}
}
},
{
"name": "preview.act",
"summary": "Click / type / scroll / annotate inside the in-app browser preview.",
@@ -33271,6 +33675,30 @@
}
]
},
{
"name": "connection.request",
"summary": "A connection operation opened on this session; the desktop renders its card.",
"params": [
{
"name": "payload",
"schema": {
"$ref": "#/components/schemas/ConnectionRequestPayload"
}
}
]
},
{
"name": "connection.update",
"summary": "One transition or the settlement of an open connection operation.",
"params": [
{
"name": "payload",
"schema": {
"$ref": "#/components/schemas/ConnectionUpdatePayload"
}
}
]
},
{
"name": "cron.changed",
"summary": "cron/jobs.json moved; refetch the cron list.",
-7
View File
@@ -1639,13 +1639,6 @@ code_execution:
# kernel_idle_timeout: 1800 # Reap kernels idle longer than this (seconds)
# max_session_kernels: 4 # Process-wide LRU cap on live kernels
# =============================================================================
# Connections (manage_connections tool)
# =============================================================================
# Deadline for one manage_connections call; fixed at creation, floor 5s, no ceiling.
connections:
wait_timeout_seconds: 120
# =============================================================================
# Subagent Delegation
# =============================================================================
-4
View File
@@ -1849,10 +1849,6 @@ DEFAULT_CONFIG = {
# the portal sign-in every managed tool gates on.
"connectors": {"enabled": True},
},
# manage_connections operation deadline; fixed at creation, floor 5s, no ceiling.
"connections": {
"wait_timeout_seconds": 120,
},
"logging": { # File logging to ~/.hermes/logs/: agent.log captures INFO+, errors.log WARNING+.
"level": "INFO", # minimum level for agent.log: DEBUG, INFO, WARNING
"max_size_mb": 5, # max size per log file before rotation
@@ -9,7 +9,7 @@ def test_delegate_task_is_exempt_from_the_sequential_deadline():
def test_manage_connections_owns_its_bounded_wait():
# The connection operation's deadline is server-owned (connections.wait_timeout_seconds);
# The connection operation's deadline is server-owned (a constant in tools/connectors/operation.py);
# the generic guard would report tool_timeout while the approval card is still live.
assert "manage_connections" in te._SEQUENTIAL_DEADLINE_EXEMPT_TOOLS
+22 -4
View File
@@ -1,6 +1,7 @@
"""Local deferred tools retain the live agent path; batches must not bypass it."""
import json
import threading
from types import SimpleNamespace
import pytest
@@ -36,15 +37,32 @@ def test_single_local_unwrap_keeps_session_db_todo_store_and_setup_callback(tmp_
from agent.tool_executor import _unwrap_tool_search_call
from agent.agent_runtime_helpers import invoke_tool
from hermes_state import SessionDB
from tools.connectors import live
from tools.connectors.contract import SettleReason
from tools.connectors.mcp import apply_answer
from tools.todo_tool import TodoStore
from gateway.session_context import reset_session_vars, set_session_vars
# A desktop session: the MCP card exists only there.
set_session_vars(source="desktop", session_key="current-session", session_id="current-session")
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 connection(payload):
callbacks.append(payload)
return json.dumps({"targets": [{"name": t["name"], "status": "declined"} for t in payload["targets"]]})
def respond():
operation = live.get("current-session", payload["op_id"])
if operation is not None:
apply_answer(operation, json.dumps(
{"targets": [{"name": t["name"], "status": "declined"} for t in payload["targets"]]}))
operation.settle(SettleReason.all_resolved)
threading.Timer(0.02, respond).start()
return None
agent = SimpleNamespace(
enabled_toolsets=["todo", "session_search", "connections"], disabled_toolsets=[],
@@ -55,7 +73,7 @@ def test_single_local_unwrap_keeps_session_db_todo_store_and_setup_callback(tmp_
{"name": "session_search", "arguments": {"session_id": "past-session"}},
{"name": "todo_list", "arguments": {"todos": [{"id": "a", "content": "live-store-proof", "status": "pending"}]}},
{"name": "manage_connections", "arguments": {
"action": "install", "connectors": [{"name": "linear", "mcp": True}], "reason": "live-callback-proof"}},
"action": "install", "connectors": [{"name": "linear", "mcp": True}]}},
]
results = []
try:
@@ -73,7 +91,7 @@ def test_single_local_unwrap_keeps_session_db_todo_store_and_setup_callback(tmp_
assert agent._todo_store.read()[0]["content"] == "live-store-proof"
assert results[2]["targets"][0] == {
"name": "linear", "kind": "mcp", "action": "install", "state": "skipped"}
assert [(c["reason"], [t["name"] for t in c["targets"]]) for c in callbacks] == [
("live-callback-proof", ["linear"])]
assert [(c["tool_call_id"], [t["name"] for t in c["targets"]]) for c in callbacks] == [("call", ["linear"])]
finally:
db.close()
reset_session_vars()
+59
View File
@@ -0,0 +1,59 @@
"""Connection contract: target states, who may cause each transition, settle reasons.
The transition table is the single statement of the operation's lifecycle. ``operation.py``
enforces it; the renderer reads a generated copy (see the shared contract rail)."""
import pytest
from tools.connectors import contract as c
def test_every_state_is_reachable_from_pending_in_some_kind():
reachable = set()
for kind in c.KINDS:
assert (kind, c.TargetState.pending) in c.TRANSITIONS
frontier = [c.TargetState.pending]
seen = {c.TargetState.pending}
while frontier:
state = frontier.pop()
for nxt in c.TRANSITIONS.get((kind, state), {}):
if nxt not in seen:
seen.add(nxt)
frontier.append(nxt)
reachable |= seen
# not_connected / unavailable are stamped by settle(), never transitioned to.
assert reachable | {c.TargetState.not_connected, c.TargetState.unavailable} == set(c.TargetState)
def test_resolved_states_end_the_target_and_are_never_left():
for state in c.RESOLVED_STATES:
for kind in c.KINDS:
assert not c.TRANSITIONS.get((kind, state)), (kind, state)
def test_only_the_backend_watcher_or_renderer_flow_may_report_connected():
for (kind, _from), edges in c.TRANSITIONS.items():
actor = edges.get(c.TargetState.connected)
if actor is not None:
assert actor in {c.Actor.backend_watcher, c.Actor.renderer_flow}, (kind, _from)
assert edges.get(c.TargetState.skipped) in {None, c.Actor.user}, (kind, _from)
def test_managed_connected_is_never_a_renderer_claim():
# The gateway is the only witness of a managed account flip; the card cannot assert it.
for (kind, _from), edges in c.TRANSITIONS.items():
if kind == "connector":
assert edges.get(c.TargetState.connected) != c.Actor.renderer_flow
def test_allowed_is_a_pure_lookup():
assert c.allowed("connector", c.TargetState.initiated, c.TargetState.connected) == c.Actor.backend_watcher
assert c.allowed("connector", c.TargetState.initiated, c.TargetState.expired) == c.Actor.clock
assert c.allowed("mcp", c.TargetState.initiated, c.TargetState.connected) == c.Actor.renderer_flow
assert c.allowed("connector", c.TargetState.connected, c.TargetState.pending) is None
@pytest.mark.parametrize("value", ["active", "pending ", "CONNECTED", ""])
def test_target_state_rejects_values_outside_the_enum(value):
with pytest.raises(ValueError):
c.TargetState(value)
@@ -0,0 +1,51 @@
"""Gateway wire model: the status vocabulary the gateway sends is typed, unknown values fail loud,
and the execute-path CONNECTION_REQUIRED error carries a link only where no card exists."""
import pytest
from pydantic import ValidationError
from tools.connectors.gateway import wire
from tools.connectors.gateway.merge import partition_calls, splice_remote_results
def test_connections_result_carries_status_reason_under_either_spelling():
for key in ("statusReason", "status_reason"):
row = wire.ConnectorConnectionResult.model_validate(
{"connector": "gmail", "status": "failed", key: "vendor: bad scope"})
assert row.status_reason == "vendor: bad scope"
def test_list_item_accepts_the_seven_states_and_absence():
for value in ("active", "initiated", "failed", "expired", "revoked", "inactive", "initializing"):
item = wire.ConnectorListItem.model_validate({"connector": "gmail", "connected": False, "connectionStatus": value})
assert item.connection_status == value
assert wire.ConnectorListItem.model_validate({"connector": "gmail", "connected": True}).connection_status is None
def test_list_item_rejects_an_unknown_status_loudly():
with pytest.raises(ValidationError):
wire.ConnectorListItem.model_validate({"connector": "gmail", "connected": False, "connectionStatus": "weird"})
def _connection_required_entry():
planned = partition_calls([{"name": "connectors__gmail__SEND_EMAIL"}]).remote
remote = [{"data": None, "error": {
"code": "CONNECTION_REQUIRED", "message": "connect gmail first",
"connect_url": "https://example.test/connect/abc", "hint": "then retry"}}]
(entry,) = splice_remote_results(planned, remote)
return entry["error"]
def test_connection_required_on_desktop_names_the_card_and_drops_the_url(monkeypatch):
monkeypatch.setattr("tools.connectors.gateway.merge.session_platform", lambda: "desktop")
error = _connection_required_entry()
assert error["connector"] == "gmail"
assert error["connect_card_available"] is True
assert "connect_url" not in error
def test_connection_required_off_desktop_keeps_the_url(monkeypatch):
monkeypatch.setattr("tools.connectors.gateway.merge.session_platform", lambda: "tui")
error = _connection_required_entry()
assert error["connect_url"] == "https://example.test/connect/abc"
assert "connect_card_available" not in error
+44
View File
@@ -0,0 +1,44 @@
"""Live operation registry: one open op per session, found by op_id, closed on settle."""
import pytest
from tools.connectors import live
from tools.connectors import operation as op
@pytest.fixture(autouse=True)
def _clean():
live.reset_for_tests()
yield
live.reset_for_tests()
def _op(session="s1"):
return op.ConnectionOperation([op.Target("gmail", "connector", "connect")], session_key=session)
def test_open_then_get_by_session_and_op_id():
operation = _op()
live.open(operation)
assert live.get("s1", operation.op_id) is operation
assert live.current("s1") is operation
assert live.get("s2", operation.op_id) is None # another session cannot see it
assert live.get("s1", "nope") is None
def test_one_open_operation_per_session():
first, second = _op(), _op()
live.open(first)
with pytest.raises(live.OperationAlreadyOpen):
live.open(second)
live.close(first)
live.open(second)
assert live.current("s1") is second
def test_close_removes_and_is_idempotent():
operation = _op()
live.open(operation)
live.close(operation)
live.close(operation)
assert live.current("s1") is None
+365
View File
@@ -0,0 +1,365 @@
"""Managed connectors on the connection operation.
Contracts:
- connect/reconnect mint ONE operation, block the turn via the callback, return per-target
outcomes; links live on the target and never in the model result on a desktop session
- off-desktop (no callback): result carries connect_url and returns at once (PR3 delivers it)
- reconnect is a repair: active → connected with no gateway mint; force → always reinitiate
- the watcher polls the gateway once per tick, transitions targets, settles on all-resolved
- ``wait`` is gone from the schema
- ``statusReason`` from the mint is kept as detail; the generic list copy never overwrites it
"""
import json
import threading
from unittest.mock import patch
import pytest
import tools.connectors.tool # registers the tool
from tools.connectors import contract as c
from tools.connectors import live
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
@pytest.fixture(autouse=True)
def _clean_live():
live.reset_for_tests()
yield
live.reset_for_tests()
class GatewayFake:
"""Scripted gateway. ``flips`` maps connector -> the list call number on which it reports connected."""
def __init__(self, connected=(), flips=None, mint_status="initiated", status_reason=None):
self.connected = set(connected)
self.flips = dict(flips or {})
self.mint_status = mint_status
self.status_reason = status_reason
self.lists = 0
self.mints = []
self.statuses = {} # slug -> connectionStatus per list call (last value repeats)
def list_connectors(self):
self.lists += 1
for slug, on in self.flips.items():
if self.lists >= on:
self.connected.add(slug)
rows = []
for s in ("gmail", "notion"):
row = {"connector": s, "enabled": True, "connected": s in self.connected}
script = self.statuses.get(s)
if script:
row["connectionStatus"] = script[min(self.lists, len(script)) - 1]
row["connected"] = row["connectionStatus"] == "active"
rows.append(row)
return rows
def connections(self, connectors, *, reinitiate=False):
self.mints.append((tuple(connectors), reinitiate))
results = []
for slug in connectors:
row = {"connector": slug, "status": self.mint_status, "reinitiated": reinitiate}
if self.mint_status == "initiated":
row["connect_url"] = f"https://connect.example/{slug}/{len(self.mints)}"
if self.status_reason:
row["status_reason"] = self.status_reason
results.append(row)
return {"results": results, "summary": {"total": len(connectors)}}
def _desktop_callback(answer=None):
"""A callback that emits the card and returns immediately (fire-and-forget, PR2 shape)."""
seen = []
def cb(payload):
seen.append(payload)
return answer
cb.seen = seen
return cb
def _run(args, gw, *, callback=None, tick=0.0, platform="desktop"):
with patch("tools.connectors.run.WATCH_INTERVAL_SECONDS", tick), \
patch("tools.connectors.managed.session_platform", return_value=platform):
return json.loads(manage_connections(
args, client_factory=lambda: gw, connection_callback=callback, session_id="s1",
))
# ---------------------------------------------------------------------------
# schema
# ---------------------------------------------------------------------------
def test_reason_is_gone_from_the_schema():
assert "reason" not in MANAGE_CONNECTIONS_SCHEMA["parameters"]["properties"]
def test_wait_is_gone_and_force_exists():
props = MANAGE_CONNECTIONS_SCHEMA["parameters"]["properties"]
assert "wait" not in props["action"]["enum"]
assert "timeout_seconds" not in props
assert props["force"]["type"] == "boolean"
out = json.loads(manage_connections({"action": "wait", "connectors": ["gmail"]}))
assert "action must be one of" in out["error"]
# ---------------------------------------------------------------------------
# desktop: one op, blocks, no URL in the result
# ---------------------------------------------------------------------------
def test_desktop_connect_mints_once_emits_the_card_and_returns_outcomes_without_urls():
gw = GatewayFake(flips={"gmail": 2, "notion": 3})
cb = _desktop_callback()
out = _run({"action": "connect", "connectors": ["gmail", "notion"]}, gw, callback=cb)
assert gw.mints == [(("gmail", "notion"), False)] # one mint for every target, up front
(payload,) = cb.seen
assert payload["op_id"] == out["op_id"]
assert [t["name"] for t in payload["targets"]] == ["gmail", "notion"]
assert all(t["kind"] == "connector" and t["action"] == "connect" for t in payload["targets"])
assert out["status"] == "settled" and out["settled_by"] == "all_resolved"
assert {t["state"] for t in out["targets"]} == {"connected"}
assert "connect_url" not in json.dumps(out)
assert live.current("s1") is None # closed on settle
def test_desktop_connect_url_stays_on_the_live_operation_for_the_panel():
gw = GatewayFake(flips={"gmail": 2})
captured = {}
def cb(payload):
captured["op"] = live.get("s1", payload["op_id"])
return None
_run({"action": "connect", "connectors": ["gmail"]}, gw, callback=cb)
snap = captured["op"].result()["targets"][0]
assert snap["connect_url"].startswith("https://connect.example/gmail/")
def test_watcher_transitions_on_flip_and_settles_by_deadline_when_nothing_flips():
gw = GatewayFake()
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.05):
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=_desktop_callback(), tick=0.01)
assert out["settled_by"] == "deadline"
assert out["targets"][0]["state"] == "not_connected"
assert gw.lists >= 2 # it did poll
def test_watcher_polls_once_per_tick_for_the_whole_operation():
gw = GatewayFake(flips={"gmail": 3, "notion": 3})
_run({"action": "connect", "connectors": ["gmail", "notion"]}, gw, callback=_desktop_callback())
assert gw.lists == 3 # shared scan, not one per target
def test_respond_from_the_card_skips_a_target_and_wakes_the_loop():
gw = GatewayFake(flips={"gmail": 2})
done = threading.Event()
def cb(payload):
def answer():
operation = live.get("s1", payload["op_id"])
operation.transition("notion", c.TargetState.skipped, c.Actor.user)
done.set()
threading.Timer(0.02, answer).start()
return None
out = _run({"action": "connect", "connectors": ["gmail", "notion"]}, gw, callback=cb, tick=0.01)
assert done.is_set()
by = {t["name"]: t for t in out["targets"]}
assert by["gmail"]["state"] == "connected" and by["notion"]["state"] == "skipped"
assert out["settled_by"] == "all_resolved"
def test_mint_failure_detail_survives_the_generic_list_copy():
gw = GatewayFake(mint_status="failed", status_reason="vendor: bad scope")
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.05):
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=_desktop_callback(), tick=0.01)
target = out["targets"][0]
assert target["state"] == "not_connected" # failed is unresolved; deadline stamped it
assert target["detail"] == "vendor: bad scope"
# ---------------------------------------------------------------------------
# reconnect = repair
# ---------------------------------------------------------------------------
def test_reconnect_on_an_active_target_makes_no_gateway_mint():
gw = GatewayFake(connected={"gmail"})
out = _run({"action": "reconnect", "connectors": ["gmail"]}, gw, callback=_desktop_callback())
assert gw.mints == []
assert out["targets"][0]["state"] == "connected"
assert out["settled_by"] == "all_resolved"
def test_reconnect_force_always_reinitiates_even_when_active():
gw = GatewayFake(connected={"gmail"}, flips={"gmail": 1})
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.05):
_run({"action": "reconnect", "connectors": ["gmail"], "force": True}, gw, callback=_desktop_callback(), tick=0.01)
assert gw.mints == [(("gmail",), True)]
def test_force_does_not_settle_connected_from_the_old_account():
"""An account switch: the vendor keeps the old account active while the new link waits. `connected`
on the list is the old account until the row has read as anything else once."""
gw = GatewayFake(connected={"gmail"})
gw.statuses = {"gmail": ["active", "active", "initializing", "active"]}
out = _run({"action": "reconnect", "connectors": ["gmail"], "force": True}, gw, callback=_desktop_callback(), tick=0.01)
assert out["settled_by"] == "all_resolved"
assert out["targets"][0]["state"] == "connected"
assert gw.lists == 4 # reads 1-2 were the old account; 3 was the new attempt; 4 saw it connected
def test_force_reads_a_failed_new_attempt_as_failed_not_as_still_waiting():
gw = GatewayFake(connected={"gmail"})
gw.statuses = {"gmail": ["active", "failed"]}
seen = []
def cb(payload):
op_id["v"] = payload["op_id"]
op_id = {}
original = gw.list_connectors
def spy():
rows = original()
op = live.get("s1", op_id["v"]) if op_id else None
if op is not None:
seen.append(op.target("gmail").state.value)
return rows
gw.list_connectors = spy
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.2):
_run({"action": "reconnect", "connectors": ["gmail"], "force": True}, gw, callback=cb, tick=0.01)
# Read 1 saw the old account (still initiated); the failed row on read 2 was applied, not swallowed.
assert "failed" in seen
def test_reconnect_on_a_disconnected_target_reinitiates():
gw = GatewayFake(flips={"gmail": 2})
_run({"action": "reconnect", "connectors": ["gmail"]}, gw, callback=_desktop_callback())
assert gw.mints == [(("gmail",), True)]
# ---------------------------------------------------------------------------
# off-desktop: links in the result, returns at once
# ---------------------------------------------------------------------------
def test_off_desktop_connect_returns_links_and_does_not_block():
gw = GatewayFake()
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=None, platform="cli")
assert out["status"] == "initiated"
assert out["targets"][0]["connect_url"].startswith("https://connect.example/gmail/")
assert "op_id" in out
assert gw.lists == 0 # no watcher without a card
assert live.current("s1") is None
def test_platform_not_callback_presence_decides_the_url():
# The TUI-in-a-terminal has a gateway callback attached but no card; the URL must be in the result.
gw = GatewayFake()
cb = _desktop_callback()
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=cb, platform="tui")
assert out["targets"][0]["connect_url"]
assert cb.seen == [] # no card emitted off-desktop
# ---------------------------------------------------------------------------
# one open op per session
# ---------------------------------------------------------------------------
def test_second_connect_while_an_operation_is_open_is_refused():
gw = GatewayFake()
operation = live.open_new([("gmail", "connector", "connect")], "s1") if hasattr(live, "open_new") else None
if operation is None:
from tools.connectors import operation as op
operation = op.ConnectionOperation([op.Target("gmail", "connector", "connect")], session_key="s1")
live.open(operation)
out = _run({"action": "connect", "connectors": ["notion"]}, gw, callback=_desktop_callback())
assert "already open" in out["error"] and operation.op_id in out["error"]
assert gw.mints == []
# ---------------------------------------------------------------------------
# settle races and terminal targets (verification findings P1-1, P1-7, P1-8)
# ---------------------------------------------------------------------------
def test_connected_read_on_a_failed_target_is_ignored_not_an_error():
"""A failed mint whose account later reads connected must not raise out of the watcher."""
gw = GatewayFake(mint_status="failed", status_reason="denied", flips={"gmail": 1})
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.05):
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=_desktop_callback(), tick=0.01)
assert "error" not in out
assert out["targets"][0]["state"] in {"failed", "not_connected"}
assert out["targets"][0]["detail"] == "denied"
def test_continue_during_a_connected_read_keeps_the_settled_result():
"""Settling while a list read is in flight must not let that read's `connected` raise into tool_error."""
gw = GatewayFake()
settled = threading.Event()
original = gw.list_connectors
def slow_list():
rows = original()
if gw.lists == 2:
live_op = live.get("s1", op_id["v"])
live_op.settle(c.SettleReason.continue_)
settled.set()
gw.connected.add("gmail")
rows = original()
return rows
gw.list_connectors = slow_list
op_id = {}
def cb(payload):
op_id["v"] = payload["op_id"]
return None
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=cb, tick=0.01)
assert settled.is_set()
assert "error" not in out
assert out["settled_by"] == "continue"
assert out["targets"][0]["state"] == "not_connected"
def test_settle_reason_is_not_written_into_the_row_detail():
gw = GatewayFake()
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.05):
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=_desktop_callback(), tick=0.01)
assert out["settled_by"] == "deadline"
assert out["targets"][0]["state"] == "not_connected"
assert "detail" not in out["targets"][0]
def test_interrupt_wakes_the_loop_and_settles_before_the_next_tick():
from tools.interrupt import set_interrupt
gw = GatewayFake()
worker = {}
def cb(payload):
worker["tid"] = threading.current_thread().ident
def stop():
set_interrupt(True, worker["tid"])
threading.Timer(0.02, stop).start()
return None
import time
started = time.monotonic()
try:
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 10):
out = _run({"action": "connect", "connectors": ["gmail"]}, gw, callback=cb, tick=5.0)
finally:
set_interrupt(False, worker.get("tid"))
assert out["settled_by"] == "interrupt"
assert time.monotonic() - started < 2.0 # woke on the interrupt, not on the 5 s tick
+90 -65
View File
@@ -7,17 +7,21 @@ Contracts:
- the GUI callback round-trip: renderer answer folds into the operation, settles once
- catalog validation: install is catalog-only, enable/authorize need a configured server
- the replay shim keeps an old ``setup_mcp`` call dispatching
- deadline ownership: config key + sequential-deadline exemption
- deadline ownership: fixed operation deadline + sequential-deadline exemption
"""
import json
import threading
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import tools.connectors.tool # registers the tool
from tools.connectors.contract import SettleReason, TargetState
from tools.connectors import live
from tools.connectors import operation as op
from tools.connectors.mcp import apply_answer
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
from tools.registry import registry
@@ -25,10 +29,18 @@ CATALOG = ["figma", "linear", "notion"]
CONFIGURED = {"paper": {"command": "paper-mcp"}, "linear": {"url": "https://mcp.linear.app/mcp"}}
@pytest.fixture(autouse=True)
def _clean_live():
live.reset_for_tests()
yield
live.reset_for_tests()
@pytest.fixture(autouse=True)
def _catalog():
with patch("tools.connectors.mcp._catalog_names", return_value=CATALOG), \
patch("tools.connectors.mcp._configured_names", return_value=sorted(CONFIGURED)):
patch("tools.connectors.mcp._configured_names", return_value=sorted(CONFIGURED)), \
patch("tools.connectors.mcp.session_platform", return_value="desktop"):
yield
@@ -57,9 +69,19 @@ def _linear(**kw):
def test_mcp_targets_without_a_callback_settle_unavailable_with_the_terminal_hint():
out = json.loads(manage_connections({"action": "install", "connectors": [_linear()]}))
assert out["status"] == "unavailable"
assert out["settled_by"] == op.SETTLED_UNAVAILABLE
def test_mcp_targets_off_the_desktop_settle_unavailable_even_with_a_callback():
"""The Ink TUI has the gateway callback attached but no card; the surface decides, never the callback."""
callback = _answering(None)
with patch("tools.connectors.mcp.session_platform", return_value="tui"), \
patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 5):
out = _mcp({"action": "install", "connectors": [_linear()]}, callback)
assert callback.seen == []
assert out["status"] == "unavailable"
assert out["settled_by"] == SettleReason.unavailable.value
(target,) = out["targets"]
assert target["state"] == op.UNAVAILABLE
assert target["state"] == TargetState.unavailable.value
assert target["hint"] == "hermes mcp install linear / hermes mcp login linear"
assert "error" not in out
@@ -81,12 +103,13 @@ def test_a_managed_action_never_accepts_mcp_targets_and_vice_versa():
assert "must carry" in out["error"]
def test_managed_leg_is_byte_for_byte_unchanged_by_the_fold():
def test_a_managed_call_off_desktop_returns_a_link_per_target():
client = FakeClient()
out = json.loads(manage_connections(
{"action": "connect", "connectors": ["gmail", {"name": "gmail"}]}, client_factory=lambda: client))
{"action": "connect", "connectors": ["gmail"]}, client_factory=lambda: client))
assert client.calls == [("connections", ("gmail",), False)]
assert out["results"][0]["connect_url"] == "https://x/gmail"
assert out["targets"][0]["connect_url"] == "https://x/gmail"
assert out["status"] == "initiated"
def test_unknown_target_fields_are_rejected():
@@ -113,47 +136,66 @@ def test_enable_and_authorize_need_a_configured_server():
# ---------------------------------------------------------------------------
# the GUI round-trip
# the GUI round-trip: the card answers through connection.respond, the op settles
# ---------------------------------------------------------------------------
def test_callback_answer_folds_into_the_operation_and_settles_once():
def _answering(answer, *, session_id="s1", delay=0.02):
"""A card that emits (callback returns None) and answers the live operation a moment later,
the way ``connection.respond`` does from the renderer."""
seen = []
def callback(payload):
seen.append(payload)
return json.dumps({"settled_by": "all_resolved", "targets": [
{"name": "linear", "status": "installed", "tools": ["a", "b"]},
{"name": "figma", "status": "declined"},
]})
out = json.loads(manage_connections(
{"action": "install", "connectors": [_linear(), {"name": "figma", "mcp": True}], "reason": "tickets"},
connection_callback=callback, wait_seconds=30))
(payload,) = seen
assert payload["reason"] == "tickets"
def respond():
operation = live.get(session_id, payload["op_id"])
if operation is not None:
apply_answer(operation, answer)
if not operation.settled and answer:
operation.settle(SettleReason.all_resolved if operation.all_resolved else SettleReason.continue_)
if answer is not None:
threading.Timer(delay, respond).start()
return None
callback.seen = seen
return callback
def _mcp(args, callback, **kw):
with patch("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.01):
return json.loads(manage_connections(args, connection_callback=callback, session_id="s1", **kw))
def test_callback_answer_folds_into_the_operation_and_settles_once():
callback = _answering(json.dumps({"settled_by": "all_resolved", "targets": [
{"name": "linear", "status": "installed", "tools": ["a", "b"]},
{"name": "figma", "status": "declined"},
]}))
out = _mcp({"action": "install", "connectors": [_linear(), {"name": "figma", "mcp": True}]}, callback)
(payload,) = callback.seen
assert "reason" not in payload
assert [t["name"] for t in payload["targets"]] == ["linear", "figma"]
assert payload["deadline_at"] == pytest.approx(payload["deadline_at"]) # server-owned, present
assert payload["timeout_seconds"] == 30
assert out["status"] == "settled" and out["settled_by"] == "all_resolved"
assert payload["timeout_seconds"] == op.OPERATION_DEADLINE_SECONDS
assert out["status"] == "settled" and out["settled_by"] == SettleReason.all_resolved.value
by_name = {t["name"]: t for t in out["targets"]}
assert by_name["linear"]["state"] == op.CONNECTED and by_name["linear"]["tools"] == ["a", "b"]
assert by_name["figma"]["state"] == op.SKIPPED
assert by_name["linear"]["state"] == TargetState.connected.value and by_name["linear"]["tools"] == ["a", "b"]
assert by_name["figma"]["state"] == TargetState.skipped.value
def test_no_answer_settles_by_deadline_and_marks_targets_not_connected():
out = json.loads(manage_connections(
{"action": "install", "connectors": [_linear()]}, connection_callback=lambda payload: "", wait_seconds=5))
assert out["settled_by"] == op.SETTLED_DEADLINE
assert out["targets"][0]["state"] == op.NOT_CONNECTED
with patch("tools.connectors.operation.OPERATION_DEADLINE_SECONDS", 0.05):
out = _mcp({"action": "install", "connectors": [_linear()]}, _answering(None))
assert out["settled_by"] == SettleReason.deadline.value
assert out["targets"][0]["state"] == TargetState.not_connected.value
assert "error" not in out
def test_mcp_secrets_never_reach_the_model():
# A renderer that echoes a credential field: only the allowed keys survive.
answer = json.dumps({"targets": [{"name": "linear", "status": "installed", "api_key": "sk-secret", "env": {"K": "v"}}]})
out = json.loads(manage_connections(
{"action": "install", "connectors": [_linear()]}, connection_callback=lambda payload: answer, wait_seconds=5))
out = _mcp({"action": "install", "connectors": [_linear()]}, _answering(answer))
assert "sk-secret" not in json.dumps(out)
@@ -169,32 +211,25 @@ def _agent(callback):
def test_inline_executor_hands_the_agent_callback_to_the_tool():
from agent.inline_tool_executors import INLINE_TOOL_EXECUTORS, InlineToolContext
calls = []
def callback(payload):
calls.append(payload)
return json.dumps({"targets": [{"name": "linear", "status": "installed"}]})
out = json.loads(INLINE_TOOL_EXECUTORS["manage_connections"](
_agent(callback), {"action": "install", "connectors": [_linear()]}, InlineToolContext("task")))
assert len(calls) == 1
assert out["targets"][0]["state"] == op.CONNECTED
callback = _answering(json.dumps({"targets": [{"name": "linear", "status": "installed"}]}))
with patch("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.01):
out = json.loads(INLINE_TOOL_EXECUTORS["manage_connections"](
_agent(callback), {"action": "install", "connectors": [_linear()]}, InlineToolContext("task")))
assert len(callback.seen) == 1
assert out["targets"][0]["state"] == TargetState.connected.value
def test_setup_mcp_replay_shim_translates_to_an_mcp_target():
from agent.inline_tool_executors import INLINE_TOOL_EXECUTORS, InlineToolContext
calls = []
def callback(payload):
calls.append(payload)
return json.dumps({"targets": [{"name": "linear", "status": "declined"}]})
out = json.loads(INLINE_TOOL_EXECUTORS["setup_mcp"](
_agent(callback), {"server": "linear", "action": "install", "reason": "old convo"}, InlineToolContext("task")))
assert calls[0]["targets"] == [{"name": "linear", "kind": "mcp", "action": "install"}]
assert calls[0]["reason"] == "old convo"
assert out["targets"][0]["state"] == op.SKIPPED
callback = _answering(json.dumps({"targets": [{"name": "linear", "status": "declined"}]}))
with patch("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.01):
out = json.loads(INLINE_TOOL_EXECUTORS["setup_mcp"](
_agent(callback), {"server": "linear", "action": "install", "reason": "old convo"}, InlineToolContext("task", tool_call_id="call-9")))
(target,) = callback.seen[0]["targets"]
assert (target["name"], target["kind"], target["action"], target["state"]) == ("linear", "mcp", "install", "pending")
assert callback.seen[0]["tool_call_id"] == "call-9"
assert out["targets"][0]["state"] == TargetState.skipped.value
def test_setup_mcp_is_gone_from_every_advertised_toolset():
@@ -217,22 +252,12 @@ def test_the_bounded_wait_owns_the_deadline_not_the_sequential_guard():
assert "manage_connections" in te._SEQUENTIAL_DEADLINE_EXEMPT_TOOLS
def test_default_wait_comes_from_the_config_key(monkeypatch):
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "3")
seen = {}
with patch("tools.connectors.mcp.resolve_wait_timeout", return_value=77.0):
manage_connections({"action": "install", "connectors": [_linear()]},
connection_callback=lambda p: seen.update(p) or "")
assert seen["timeout_seconds"] == 77.0
def test_settle_reason_comes_from_target_state_not_the_renderer():
# The renderer answered one of two targets and claimed all_resolved; the operation is not resolved.
answer = json.dumps({"settled_by": "all_resolved", "targets": [{"name": "linear", "status": "declined"}]})
out = json.loads(manage_connections(
{"action": "install", "connectors": [_linear(), {"name": "figma", "mcp": True}]},
connection_callback=lambda payload: answer, wait_seconds=5))
assert out["settled_by"] == op.SETTLED_CONTINUE
out = _mcp({"action": "install", "connectors": [_linear(), {"name": "figma", "mcp": True}]}, _answering(answer))
assert out["settled_by"] == SettleReason.continue_.value
by_name = {t["name"]: t for t in out["targets"]}
assert by_name["linear"]["state"] == op.SKIPPED
assert by_name["figma"]["state"] == op.NOT_CONNECTED and by_name["figma"]["detail"] == op.SETTLED_CONTINUE
assert by_name["linear"]["state"] == TargetState.skipped.value
assert by_name["figma"]["state"] == TargetState.not_connected.value
assert "detail" not in by_name["figma"]
+76 -63
View File
@@ -1,79 +1,92 @@
"""Connection operation: exactly-once settlement, server-owned deadline, config-bounded wait."""
"""Connection operation: transitions checked against the contract, exactly-once settlement,
server-owned deadline as a constant, wake on every change."""
import pytest
from tools.connectors import contract as c
from tools.connectors import operation as op
def _two_targets():
return [op.Target("linear", "mcp", "install"), op.Target("figma", "mcp", "install")]
def _two(kind="connector"):
return [op.Target("gmail", kind, "connect"), op.Target("notion", kind, "connect")]
def test_settle_is_exactly_once_and_freezes_the_result():
operation = op.ConnectionOperation(_two_targets(), wait_seconds=30)
operation.record_target("linear", op.CONNECTED)
assert operation.settle(op.SETTLED_CONTINUE) is True
def test_deadline_is_a_constant_not_a_config_key():
operation = op.ConnectionOperation(_two())
assert operation.deadline_at == pytest.approx(operation.created_at + op.OPERATION_DEADLINE_SECONDS)
assert op.OPERATION_DEADLINE_SECONDS == 300
assert not hasattr(op, "resolve_wait_timeout")
def test_transition_enforces_the_contract_and_names_the_actor():
operation = op.ConnectionOperation(_two())
operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher)
with pytest.raises(op.IllegalTransition):
operation.transition("gmail", c.TargetState.connected, c.Actor.user) # a card cannot claim connected
with pytest.raises(op.IllegalTransition):
operation.transition("gmail", c.TargetState.pending, c.Actor.backend_watcher) # no edge back
operation.transition("gmail", c.TargetState.connected, c.Actor.backend_watcher)
assert operation.target("gmail").state == c.TargetState.connected
def test_transition_returns_the_change_and_wakes_the_waiter():
operation = op.ConnectionOperation(_two())
assert not operation.wake.is_set()
change = operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher, detail="link minted")
assert change == {"target": "gmail", "from": "pending", "to": "initiated", "actor": "backend_watcher", "detail": "link minted"}
assert operation.wake.is_set()
def test_same_state_transition_is_a_no_op_not_an_error():
operation = op.ConnectionOperation(_two())
operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher)
operation.wake.clear()
assert operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher) is None
assert not operation.wake.is_set()
def test_settle_is_exactly_once_and_stamps_not_connected():
operation = op.ConnectionOperation(_two())
operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher)
operation.transition("gmail", c.TargetState.connected, c.Actor.backend_watcher)
assert operation.settle(c.SettleReason.continue_) is True
frozen = operation.result()
# Later events update current state only, never the settled result.
assert operation.settle(op.SETTLED_DEADLINE) is False
operation.record_target("figma", op.CONNECTED)
assert operation.settled_by == op.SETTLED_CONTINUE
assert operation.settle(c.SettleReason.deadline) is False
assert operation.result() == frozen
assert operation.target("figma").state == op.CONNECTED # live state did move
by = {t["name"]: t for t in frozen["targets"]}
assert by["gmail"]["state"] == "connected"
# The settle reason is on the operation, never copied into a row's detail (the card showed it as red text).
assert by["notion"]["state"] == "not_connected" and "detail" not in by["notion"]
def test_unresolved_targets_are_marked_not_connected_at_settlement():
operation = op.ConnectionOperation(_two_targets(), wait_seconds=30)
operation.record_target("linear", op.CONNECTED)
operation.settle(op.SETTLED_DEADLINE)
states = {t["name"]: t for t in operation.result()["targets"]}
assert states["linear"]["state"] == op.CONNECTED
assert states["figma"]["state"] == op.NOT_CONNECTED
assert states["figma"]["detail"] == op.SETTLED_DEADLINE
def test_all_resolved_means_connected_or_explicitly_skipped():
operation = op.ConnectionOperation(_two_targets(), wait_seconds=30)
operation.record_target("linear", op.CONNECTED)
operation.record_target("figma", op.FAILED, "oauth denied")
# A recoverable failure keeps the operation open.
assert operation.settle_if_all_resolved() is False
operation.record_target("figma", op.SKIPPED)
def test_all_resolved_settles_on_connected_or_skipped_only():
operation = op.ConnectionOperation(_two())
operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher)
operation.transition("gmail", c.TargetState.failed, c.Actor.backend_watcher, detail="oauth denied")
operation.transition("notion", c.TargetState.skipped, c.Actor.user)
assert operation.settle_if_all_resolved() is False # failed keeps the op open
operation.transition("gmail", c.TargetState.skipped, c.Actor.user)
assert operation.settle_if_all_resolved() is True
assert operation.settled_by == op.SETTLED_ALL_RESOLVED
assert operation.settled_by == c.SettleReason.all_resolved
def test_deadline_is_set_at_creation_and_never_recomputed():
operation = op.ConnectionOperation(_two_targets(), wait_seconds=42)
first = operation.deadline_at
assert first == pytest.approx(operation.created_at + 42)
operation.record_target("linear", op.CONNECTED)
def test_target_keeps_the_link_and_the_mint_detail_across_transitions():
operation = op.ConnectionOperation(_two())
operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher,
connect_url="https://link/gmail", detail="")
operation.transition("gmail", c.TargetState.failed, c.Actor.backend_watcher, detail="vendor said no")
snap = operation.target("gmail").snapshot()
assert snap["connect_url"] == "https://link/gmail"
assert snap["detail"] == "vendor said no"
def test_request_payload_carries_the_live_target_snapshot():
operation = op.ConnectionOperation([op.Target("gmail", "connector", "reconnect")], tool_call_id="call-1")
operation.transition("gmail", c.TargetState.initiated, c.Actor.backend_watcher, connect_url="https://l/gmail")
payload = operation.request_payload()
assert payload["deadline_at"] == first
assert payload["op_id"] == operation.op_id
assert [t["name"] for t in payload["targets"]] == ["linear", "figma"]
def test_record_target_rejects_unknown_names():
operation = op.ConnectionOperation(_two_targets())
assert operation.record_target("github", op.CONNECTED) is False
@pytest.mark.parametrize(
("config", "expected"),
[
({}, op.WAIT_TIMEOUT_DEFAULT_SECONDS),
({"connections": {"wait_timeout_seconds": 600}}, 600.0),
({"connections": {"wait_timeout_seconds": 1}}, op.WAIT_TIMEOUT_FLOOR_SECONDS),
({"connections": {"wait_timeout_seconds": "nope"}}, op.WAIT_TIMEOUT_DEFAULT_SECONDS),
({"connections": {"wait_timeout_seconds": True}}, op.WAIT_TIMEOUT_DEFAULT_SECONDS),
],
)
def test_wait_timeout_reads_only_its_own_key_with_a_floor_and_no_ceiling(config, expected):
assert op.resolve_wait_timeout(config) == expected
def test_legacy_timeouts_never_proxy_for_the_wait(monkeypatch):
# The batch guard env var and the clarify timeout are separate budgets.
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "7")
assert op.resolve_wait_timeout({"agent": {"clarify_timeout": 9}}) == op.WAIT_TIMEOUT_DEFAULT_SECONDS
(target,) = payload["targets"]
assert target == {"name": "gmail", "kind": "connector", "action": "reconnect", "state": "initiated",
"connect_url": "https://l/gmail"}
# The model's own id keys the card to its tool row; a later op for the same apps gets a new one.
assert payload["tool_call_id"] == "call-1"
assert "reason" not in payload
+15 -288
View File
@@ -5,7 +5,6 @@ seams; no module mocks, no network.
"""
import json
import time
from unittest.mock import patch
import pytest
@@ -55,40 +54,17 @@ def test_status_lists_and_filters_connectors():
]
def test_connect_returns_link_and_instruction_once_per_session():
def test_connect_off_desktop_returns_a_link_per_target():
client = FakeClient()
seen = set()
first = json.loads(
out = 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]
assert out["targets"][0]["connect_url"] == "https://connect.example/gmail"
assert out["status"] == "initiated"
assert client.calls == [("connections", ("gmail",), False)]
def test_reconnect_sets_reinitiate():
@@ -96,11 +72,19 @@ def test_reconnect_sets_reinitiate():
manage_connections(
{"action": "reconnect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=set(),
)
assert ("connections", ("gmail",), True) in client.calls
def test_reconnect_on_a_connected_target_makes_no_mint():
client = FakeClient()
manage_connections(
{"action": "reconnect", "connectors": ["linear"]},
client_factory=lambda: client,
)
assert not any(call[0] == "connections" for call in client.calls)
def test_connect_without_connectors_is_a_usage_error():
out = json.loads(
manage_connections({"action": "connect"}, client_factory=FakeClient)
@@ -151,267 +135,10 @@ def test_mcp_actions_belong_to_mcp_targets_only():
# ---------------------------------------------------------------------------
# action "wait": the waiting happens inside the call, not in the model's head
# blocking operations are never parallelized
# ---------------------------------------------------------------------------
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
@@ -1,58 +0,0 @@
"""The ``manage_connections`` card is one ``connection`` server request per operation
(``tui_gateway/server.py::_connection_request``): the renderer's per-target outcomes reach the tool
as JSON text, and an unanswered request yields ``''`` so the operation settles on its own deadline
instead of raising.
"""
import json
import pytest
import tui_gateway.server as server
from tui_gateway import server_requests
from tui_gateway.contracts import registry
@pytest.fixture
def sent(monkeypatch):
calls = []
def fake_send(method, sid, params, *, timeout, **_kw):
calls.append({"method": method, "sid": sid, "params": params, "timeout": timeout})
return fake_send.result
fake_send.result = None
fake_send.calls = calls
monkeypatch.setattr(server_requests, "send", fake_send)
return fake_send
def _payload():
return {"op_id": "op1", "deadline_at": 1.0, "timeout_seconds": 42.0, "reason": "",
"targets": [{"name": "linear", "kind": "mcp", "action": "authorize"}]}
def test_answer_round_trips_as_json_and_waits_the_operation_deadline(sent):
sent.result = {"settled_by": "all_resolved",
"targets": [{"name": "linear", "state": "authorized", "tools": ["list_issues"]}]}
raw = server._connection_request("s1", _payload())
assert sent.calls[0]["method"] == "connection"
assert sent.calls[0]["timeout"] == 42.0
assert json.loads(raw) == sent.result
def test_unanswered_request_is_the_empty_answer(sent):
sent.result = None
assert server._connection_request("s1", _payload()) == ""
def test_request_payload_satisfies_the_declared_contract():
"""The tool's payload and the card's answer both validate against the contract the TS is
generated from, so a drift on either side fails here before it fails in a renderer."""
contract = registry.SERVER_REQUESTS["connection"]
params, problem = registry.validate_params(contract, {"session_id": "s1", **_payload()})
assert problem is None and params is not None
registry.check_result(contract, {"settled_by": "continue",
"targets": [{"name": "linear", "state": "declined"}]})
@@ -0,0 +1,274 @@
"""Real-path desktop connection-operation lifecycle through the TUI gateway."""
import json
import os
import threading
import time
from contextlib import ExitStack, suppress
import pytest
from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars
from tools.connectors import live
from tools.connectors.contract import Actor, TargetState
from tools.connectors.operation import ConnectionOperation, Target
from tools.connectors.tool import manage_connections
from tui_gateway import server
from tui_gateway.transport import StdioTransport
SID = "connector-operation-e2e"
class PipeClient:
"""A real JSON-RPC pipe transport, following the gateway RPC test fixture idiom."""
def __init__(self, stack):
read_fd, write_fd = os.pipe()
self.reader = stack.enter_context(os.fdopen(read_fd, "r", encoding="utf-8"))
self.writer = os.fdopen(write_fd, "w", encoding="utf-8")
stack.callback(self._cleanup)
self.transport = StdioTransport(lambda: self.writer, threading.Lock())
self.frames = []
self._frames_lock = threading.Lock()
self._closed = False
self._reader = threading.Thread(target=self._read, daemon=True)
self._reader.start()
def _read(self):
for line in self.reader:
with self._frames_lock:
self.frames.append(json.loads(line))
def _cleanup(self):
if self._closed:
return
self._closed = True
with suppress(BrokenPipeError):
self.writer.close()
self._reader.join(timeout=5)
assert not self._reader.is_alive()
def events(self, event_type, predicate=lambda _payload: True):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
with self._frames_lock:
events = [
frame["params"]["payload"]
for frame in self.frames
if frame.get("method") == "event"
and frame.get("params", {}).get("type") == event_type
and predicate(frame["params"].get("payload") or {})
]
if events:
return events
time.sleep(0.01)
with self._frames_lock:
return [
frame["params"]["payload"]
for frame in self.frames
if frame.get("method") == "event"
and frame.get("params", {}).get("type") == event_type
and predicate(frame["params"].get("payload") or {})
]
class FakeConnectorClient:
"""The only fake: the tool-gateway HTTP client boundary."""
def __init__(self):
self._connected = set()
self._lock = threading.Lock()
def list_connectors(self):
with self._lock:
connected = set(self._connected)
return [
{"connector": name, "enabled": True, "connected": name in connected}
for name in ("gmail", "notion")
]
def connections(self, connectors, *, reinitiate=False):
return {
"results": [
{
"connector": name,
"status": "initiated",
"connect_url": f"https://connect.example/{name}",
"reinitiated": reinitiate,
}
for name in connectors
]
}
def set_connected(self, name):
with self._lock:
self._connected.add(name)
@pytest.fixture
def owned_session(monkeypatch, tmp_path):
home = tmp_path / "hermes-home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
reset_session_vars()
live.reset_for_tests()
with ExitStack() as stack:
owner, stranger = PipeClient(stack), PipeClient(stack)
session = {
"transport": owner.transport,
"agent": None,
"session_key": SID,
"history": [],
"history_lock": threading.Lock(),
"history_version": 0,
"running": False,
"attached_images": [],
"source": "desktop",
}
monkeypatch.setitem(server._sessions, SID, session)
yield owner, stranger
live.reset_for_tests()
reset_session_vars()
def _rpc(client, method, **params):
response = server.dispatch(
{
"jsonrpc": "2.0",
"id": 7,
"method": method,
"params": {"session_id": SID, **params},
},
client.transport,
)
assert response is not None
return response
def test_desktop_connect_settles_through_callback_response(owned_session, monkeypatch):
"""The actual tool thread waits for the gateway card outcome, not a fake callback."""
owner, _ = owned_session
client = FakeConnectorClient()
monkeypatch.setattr("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.05)
result = {}
finished = threading.Event()
def run_tool():
# Bind the way the server binds a turn: the desktop surface arrives as the session's
# `source`, not as a messaging platform.
tokens = server._set_session_context(SID)
try:
# The agent's durable session_id is not the gateway session key (compaction rotates it
# mid-turn); the operation must register under the key every RPC looks it up by.
result["raw"] = manage_connections(
{"action": "connect", "connectors": ["gmail", "notion"]},
client_factory=lambda: client,
session_id="20260914_rotated_agent_id",
connection_callback=server._agent_cbs(SID)["connection_callback"],
)
except BaseException as exc: # assertion below reports any real-path failure
result["error"] = exc
finally:
clear_session_vars(tokens)
finished.set()
tool_thread = threading.Thread(target=run_tool, daemon=True)
tool_thread.start()
(request,) = owner.events("connection.request")
assert request["op_id"]
assert len(request["targets"]) == 2
assert request["deadline_at"] > time.time()
initiated = _rpc(owner, "connectors.operation.status", op_id=request["op_id"])["result"]
by_name = {target["name"]: target for target in initiated["targets"]}
assert {name: target["state"] for name, target in by_name.items()} == {
"gmail": "initiated",
"notion": "initiated",
}
assert all(target["connect_url"] == f"https://connect.example/{name}" for name, target in by_name.items())
client.set_connected("gmail")
assert owner.events(
"connection.update",
lambda payload: payload.get("target") == "gmail" and payload.get("to") == "connected",
)
response = _rpc(
owner,
"connection.respond",
op_id=request["op_id"],
result={"targets": [{"name": "notion", "status": "skipped"}]},
)
assert response["result"] == {"status": "ok", "settled": True}
assert finished.wait(5), "connection.respond must release the callback bridge"
tool_thread.join(timeout=5)
assert not tool_thread.is_alive()
assert "error" not in result
settled = json.loads(result["raw"])
assert settled["status"] == "settled"
assert settled["settled_by"] == "all_resolved"
assert {target["name"]: target["state"] for target in settled["targets"]} == {
"gmail": "connected",
"notion": "skipped",
}
assert "connect_url" not in json.dumps(settled)
assert live.current(SID) is None
# A settled operation leaves the registry: the frozen result travelled in the tool result and
# in the last connection.update; the status RPC has nothing left to serve.
closed = _rpc(owner, "connectors.operation.status", op_id=request["op_id"])
assert closed["error"]["code"] == 4004
final_update = owner.events("connection.update", lambda payload: payload.get("settled") is True)[-1]
assert {target["name"]: target["state"] for target in final_update["targets"]} == {
"gmail": "connected",
"notion": "skipped",
}
def test_cli_connect_returns_urls_without_emitting_a_card(owned_session):
owner, _ = owned_session
client = FakeConnectorClient()
tokens = set_session_vars(platform="cli", session_key=SID, session_id=SID)
try:
result = json.loads(
manage_connections(
{"action": "connect", "connectors": ["gmail", "notion"]},
client_factory=lambda: client,
session_id=SID,
connection_callback=server._agent_cbs(SID)["connection_callback"],
)
)
finally:
clear_session_vars(tokens)
assert result["status"] == "initiated"
assert all(target["connect_url"] for target in result["targets"])
assert owner.events("connection.request") == []
assert live.current(SID) is None
def test_connection_respond_rejects_managed_connected_claims_and_strangers(owned_session):
owner, stranger = owned_session
operation = ConnectionOperation([Target("gmail", "connector", "connect")], session_key=SID)
live.open(operation)
operation.transition("gmail", TargetState.initiated, Actor.backend_watcher)
rejected = _rpc(
owner,
"connection.respond",
op_id=operation.op_id,
result={"targets": [{"name": "gmail", "status": "connected"}]},
)
assert rejected["error"]["code"] == 4002
assert operation.target("gmail").state == TargetState.initiated
foreign = _rpc(
stranger,
"connection.respond",
op_id=operation.op_id,
result={"targets": [{"name": "gmail", "status": "skipped"}]},
)
assert foreign["error"]["code"] == 4001
@@ -0,0 +1,225 @@
"""Connector operation RPCs and events over the real dispatch path.
- ``connectors.operation.status`` reads the live operation with the same ownership checks as
``connectors.list`` (4000 / 4001); ``deadline_at`` is the server's, never recomputed
- ``connection.respond`` finds the op by ``op_id`` and drives target transitions; a foreign
transport cannot
- every target transition and the settlement emit ``connection.update`` to the session
- ``pending_connection`` on resume comes from the live registry
"""
import json
import os
import threading
import time
from contextlib import ExitStack, suppress
import pytest
from tools.connectors import live
from tools.connectors.contract import Actor, TargetState
from tools.connectors.operation import ConnectionOperation, Target
from tui_gateway import server
from tui_gateway.transport import StdioTransport
class PipeClient:
def __init__(self, stack):
read_fd, write_fd = os.pipe()
self.reader = stack.enter_context(os.fdopen(read_fd, "r", encoding="utf-8"))
self.writer = os.fdopen(write_fd, "w", encoding="utf-8")
stack.callback(lambda: suppress(BrokenPipeError) and self.writer.close())
self.transport = StdioTransport(lambda: self.writer, threading.Lock())
self.frames = []
self.thread = threading.Thread(target=self._read, daemon=True)
self.thread.start()
def _read(self):
for line in self.reader:
self.frames.append(json.loads(line))
def write(self, obj):
return self.transport.write(obj)
def close(self):
pass
def events(self, kind, *, expect=1):
deadline = time.time() + 2
while time.time() < deadline:
found = [f["params"] for f in list(self.frames) if f.get("method") == "event" and f["params"]["type"] == kind]
if len(found) >= expect:
return found
time.sleep(0.01)
return [f["params"] for f in list(self.frames) if f.get("method") == "event" and f["params"]["type"] == kind]
SID = "op-rpc-session"
@pytest.fixture
def owned(monkeypatch):
live.reset_for_tests()
with ExitStack() as stack:
owner, stranger = PipeClient(stack), PipeClient(stack)
session = dict(transport=owner.transport, agent=None, session_key=SID, history=[],
history_lock=threading.Lock(), history_version=0, running=False, attached_images=[],
source="desktop")
monkeypatch.setitem(server._sessions, SID, session)
yield owner, stranger, session
live.reset_for_tests()
def _rpc(client, method, **params):
return server.dispatch({"jsonrpc": "2.0", "id": 7, "method": method, "params": {"session_id": SID, **params}},
client.transport)
def _open_op():
operation = ConnectionOperation([Target("gmail", "connector", "connect"), Target("notion", "connector", "connect")],
session_key=SID)
live.open(operation)
return operation
def test_operation_status_returns_the_live_snapshot_with_the_server_deadline(owned):
owner, _, _ = owned
operation = _open_op()
operation.transition("gmail", TargetState.initiated, Actor.backend_watcher, connect_url="https://l/gmail")
reply = _rpc(owner, "connectors.operation.status", op_id=operation.op_id)
result = reply["result"]
assert result["op_id"] == operation.op_id
assert result["deadline_at"] == operation.deadline_at
assert result["settled"] is False and result["settled_by"] is None
by = {t["name"]: t for t in result["targets"]}
assert by["gmail"]["state"] == "initiated" and by["gmail"]["connect_url"] == "https://l/gmail"
assert by["notion"]["state"] == "pending"
def test_operation_status_is_owner_only_and_validates_params(owned):
owner, stranger, _ = owned
operation = _open_op()
assert _rpc(stranger, "connectors.operation.status", op_id=operation.op_id)["error"]["code"] == 4001
assert _rpc(owner, "connectors.operation.status")["error"]["code"] == 4000
assert _rpc(owner, "connectors.operation.status", op_id="nope")["error"]["code"] == 4004
def test_respond_drives_the_live_operation_and_emits_update(owned):
owner, stranger, _ = owned
operation = _open_op()
operation.transition("gmail", TargetState.initiated, Actor.backend_watcher)
foreign = _rpc(stranger, "connection.respond", op_id=operation.op_id,
result={"targets": [{"name": "notion", "status": "skipped"}]})
assert foreign["error"]["code"] == 4001
assert operation.target("notion").state == TargetState.pending
reply = _rpc(owner, "connection.respond", op_id=operation.op_id,
result={"targets": [{"name": "notion", "status": "skipped"}]})
assert "result" in reply, reply
assert operation.target("notion").state == TargetState.skipped
assert operation.wake.is_set()
updates = owner.events("connection.update", expect=2)
assert updates and updates[-1]["payload"]["target"] == "notion"
assert updates[-1]["payload"]["to"] == "skipped" and updates[-1]["payload"]["actor"] == "user"
assert updates[-1]["payload"]["op_id"] == operation.op_id
def test_respond_cannot_claim_connected_for_a_managed_target(owned):
owner, _, _ = owned
operation = _open_op()
operation.transition("gmail", TargetState.initiated, Actor.backend_watcher)
reply = _rpc(owner, "connection.respond", op_id=operation.op_id,
result={"targets": [{"name": "gmail", "status": "connected"}]})
assert reply["error"]["code"] == 4002
assert operation.target("gmail").state == TargetState.initiated
def test_respond_continue_settles_and_emits_the_settlement_update(owned):
owner, _, _ = owned
operation = _open_op()
reply = _rpc(owner, "connection.respond", op_id=operation.op_id, result={"settled_by": "continue"})
assert "result" in reply
assert operation.settled and operation.settled_by.value == "continue"
settled = [u for u in owner.events("connection.update") if u["payload"].get("settled")]
assert settled and settled[-1]["payload"]["settled_by"] == "continue"
def test_pending_connection_on_resume_comes_from_the_live_registry(owned):
operation = _open_op()
payload = server._pending_connection_request_payload(SID)
assert payload["op_id"] == operation.op_id
assert payload["deadline_at"] == operation.deadline_at
live.close(operation)
assert server._pending_connection_request_payload(SID) is None
def test_panel_connect_reissues_only_a_dead_link(owned, monkeypatch):
"""Try again re-mints a failed or expired target; a waiting target keeps the link it was minted
with (the card reopens it), so the RPC refuses rather than spend a second mint."""
owner, _, _ = owned
operation = _open_op()
operation.transition("gmail", TargetState.initiated, Actor.backend_watcher, connect_url="https://l/gmail/1")
operation.transition("notion", TargetState.initiated, Actor.backend_watcher, connect_url="https://l/notion/1")
operation.transition("notion", TargetState.failed, Actor.backend_watcher, detail="vendor: nope")
mints = []
class Client:
def connections(self, names, *, reinitiate=False):
mints.append((tuple(names), reinitiate))
return {"results": [{"connector": n, "status": "initiated", "connect_url": f"https://l/{n}/2"} for n in names]}
monkeypatch.setattr("tools.connectors.gateway.client.ConnectorClient", Client)
# The real dispatcher: availability gate passes, then the open operation routes to _reissue.
monkeypatch.setattr("tools.connectors.connectors_available", lambda: True)
monkeypatch.setattr("model_tools._select_tool_names", lambda *a, **k: {"manage_connections"})
def long_rpc(**params):
# connectors.connect runs on the pool and writes its reply to the transport.
before = len(owner.frames)
_rpc(owner, "connectors.connect", **params)
deadline = time.time() + 2
while time.time() < deadline:
replies = [f for f in list(owner.frames)[before:] if f.get("id") == 7]
if replies:
return replies[-1]
time.sleep(0.01)
raise AssertionError("no reply")
refused = long_rpc(connectors=["gmail"])
assert refused["error"]["code"] == 4002 and mints == []
assert operation.target("gmail").connect_url == "https://l/gmail/1"
reply = long_rpc(connectors=["notion"])
assert "result" in reply, reply
assert mints == [(("notion",), True)]
assert operation.target("notion").state == TargetState.initiated
assert operation.target("notion").connect_url == "https://l/notion/2"
@pytest.mark.parametrize("dead", [TargetState.failed, TargetState.expired])
def test_a_failed_reissue_leaves_the_row_failed_with_no_link(owned, monkeypatch, dead):
"""Try again whose mint fails must not show the row as waiting on the old dead link."""
owner, _, _ = owned
operation = _open_op()
operation.transition("notion", TargetState.initiated, Actor.backend_watcher, connect_url="https://l/notion/1")
actor = Actor.clock if dead == TargetState.expired else Actor.backend_watcher
operation.transition("notion", dead, actor, detail="vendor: nope")
class Client:
def connections(self, names, *, reinitiate=False):
return {"results": [{"connector": n, "status": "failed", "status_reason": "vendor: still no"} for n in names]}
monkeypatch.setattr("tools.connectors.gateway.client.ConnectorClient", Client)
monkeypatch.setattr("tools.connectors.connectors_available", lambda: True)
monkeypatch.setattr("model_tools._select_tool_names", lambda *a, **k: {"manage_connections"})
before = len(owner.frames)
_rpc(owner, "connectors.connect", connectors=["notion"])
deadline = time.time() + 2
while time.time() < deadline and not [f for f in list(owner.frames)[before:] if f.get("id") == 7]:
time.sleep(0.01)
target = operation.target("notion")
assert target.state == TargetState.failed
assert target.connect_url is None
assert target.detail == "vendor: still no"
+5 -21
View File
@@ -1,25 +1,9 @@
"""Connectors: managed connector accounts (Nous tool gateway) and local MCP servers behind one
model tool, ``manage_connections``.
"""Connector integration boundary for managed gateway accounts and local MCP servers.
Layout (a deep module with a narrow door):
- ``tool.py`` — the model tool: schema, ``registry.register`` (the one file discovery scans in this
package), and the action dispatcher.
- ``targets.py`` — target and action validation shared by every leg. Pure.
- ``operation.py`` — ``ConnectionOperation`` / ``Target``: targets, server-owned deadline,
exactly-once settlement. Pure data, no I/O.
- ``mcp.py`` — the local-MCP leg (install / enable / authorize through the approval card).
- ``managed.py`` — the managed-connector leg (status / connect / reconnect / wait).
- ``search.py`` — the ``tool_search`` / ``tool_describe`` adapter for remote connector tools.
- ``dispatch.py`` — remote connector calls re-entering ``model_tools.handle_function_call`` so
per-tool policy runs against the composed ``connectors__<connector>__<tool>`` name.
- ``gateway/`` — the typed client for the tool gateway's connector routes; see its own docstring
for the layering rules inside it.
The names below are the whole cross-package surface. ``model_tools`` and ``tool_search`` are
core plumbing and deep-import a few helpers past this door on purpose; nothing else should. An
importer outside this package needing a name not listed here is a design question, not a reason
to add the name.
Only the names below are cross-package surface; imports beyond it need a design decision.
Siblings: ``contract`` (states, actors, transition table), ``operation`` (the record),
``live`` (open operation per session), ``run`` (the lifecycle loop), ``managed`` / ``mcp``
(per-kind hooks), ``targets``, ``search``, ``dispatch``, ``gateway/`` (HTTP wire + client).
"""
from tools.connectors.dispatch import dispatch_connector_batch, dispatch_connector_call
+61
View File
@@ -0,0 +1,61 @@
"""Connection contract: the states a target moves through, who may cause each move, and the
reasons an operation settles. ``operation.py`` enforces it; the desktop reads a generated copy.
A `str` enum so payloads serialise to the bare value and the TypeScript side sees literal unions."""
from __future__ import annotations
from enum import Enum
from typing import Dict, Optional, Tuple
class TargetState(str, Enum):
pending = "pending"
initiated = "initiated"
connected = "connected"
skipped = "skipped"
failed = "failed"
expired = "expired"
unavailable = "unavailable"
# Stamped by settle() on every unresolved target; never a transition target.
not_connected = "not_connected"
class Actor(str, Enum):
user = "user"
renderer_flow = "renderer_flow"
backend_watcher = "backend_watcher"
clock = "clock"
class SettleReason(str, Enum):
all_resolved = "all_resolved"
continue_ = "continue"
deadline = "deadline"
interrupt = "interrupt"
unavailable = "unavailable"
KINDS: Tuple[str, ...] = ("connector", "mcp")
RESOLVED_STATES = frozenset({TargetState.connected, TargetState.skipped, TargetState.unavailable})
_S, _A = TargetState, Actor
# (kind, from) -> {to: the only actor allowed to cause it}. A managed `connected` is witnessed by the
# gateway alone; an MCP `connected` (auth completed) is reported by the renderer that ran the flow.
TRANSITIONS: Dict[Tuple[str, TargetState], Dict[TargetState, Actor]] = {
("connector", _S.pending): {_S.initiated: _A.backend_watcher, _S.failed: _A.backend_watcher, _S.skipped: _A.user},
("connector", _S.initiated): {
_S.connected: _A.backend_watcher, _S.failed: _A.backend_watcher, _S.expired: _A.clock, _S.skipped: _A.user,
},
("connector", _S.failed): {_S.initiated: _A.user, _S.skipped: _A.user},
("connector", _S.expired): {_S.initiated: _A.user, _S.skipped: _A.user},
("mcp", _S.pending): {_S.initiated: _A.renderer_flow, _S.failed: _A.renderer_flow, _S.skipped: _A.user},
("mcp", _S.initiated): {_S.connected: _A.renderer_flow, _S.failed: _A.renderer_flow, _S.skipped: _A.user},
("mcp", _S.failed): {_S.initiated: _A.user, _S.skipped: _A.user},
}
def allowed(kind: str, current: TargetState, to: TargetState) -> Optional[Actor]:
return TRANSITIONS.get((kind, current), {}).get(to)
+3 -10
View File
@@ -1,4 +1,4 @@
"""Connector calls re-enter the normal dispatcher under their composed names."""
"""Route connector calls through the normal dispatch policy pipeline."""
import json
from dataclasses import asdict
@@ -9,11 +9,6 @@ from tools.connectors.gateway.merge import assemble_results, fill_remote_failure
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.connectors.gateway.bridge import run_remote
partition = partition_calls([{"name": name, "arguments": arguments}])
@@ -37,14 +32,12 @@ def dispatch_connector_batch(calls, ids, *, user_task, enabled_tools,
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.
# Check before every entry so /stop prevents unstarted 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.
# Each entry must run its own policy and middleware.
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),
+7 -45
View File
@@ -1,21 +1,6 @@
"""The one module core code imports for connector dispatch.
"""Core boundary for connector search, descriptions, and remote execution.
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.
``tools.connectors.dispatch`` 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``.
Search and description failures degrade to no remote results; exceptions cannot escape this bridge because core dispatch bypasses registry error wrapping.
"""
from __future__ import annotations
@@ -46,11 +31,7 @@ def connector_search_hits(
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.
"""
"""Return no hits on failure so local search behavior is unchanged."""
try:
available = (availability or connectors_available)()
if not available or not queries:
@@ -58,7 +39,6 @@ def connector_search_hits(
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:
@@ -72,25 +52,12 @@ def connector_describe(
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.
"""
"""Return no schemas on failure so local descriptions are unchanged."""
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.
# Resolve each name independently: candidates can overlap across composed names.
wanted: dict[str, tuple[str, ...]] = {}
request_slugs: list[str] = []
for name in names:
@@ -141,17 +108,13 @@ def run_remote(
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.
# Try the conventional prefix first; only confirmed misses get a literal retry.
wire_planned = [
dataclass_replace(
plan,
@@ -198,8 +161,7 @@ def run_remote(
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.
# Retry confirmed misses once without disturbing successful sibling slots.
try:
fallback_results = client.execute(fallback_planned)
fallback_entries = splice_remote_results(fallback_planned, fallback_results)
+17 -76
View File
@@ -1,29 +1,6 @@
"""HTTP client for the connector routes on the managed tool gateway.
"""HTTP client for managed gateway connector routes.
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``.
A dispatch-local idempotency key permits one execute retry; connections are never retried because the gateway cannot deduplicate authorization starts.
"""
from __future__ import annotations
@@ -33,6 +10,7 @@ import uuid
from typing import Any, Callable, Optional, Protocol, Sequence
import requests
from pydantic import ValidationError
from tools.connectors.gateway import wire
from tools.connectors.gateway.errors import (
@@ -48,23 +26,15 @@ 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 is on every enabled tool-search path and must degrade to local-only results.
SEARCH_TIMEOUT_SECONDS = 30.0
SCHEMAS_TIMEOUT_SECONDS = 10.0
_MAX_RETRIES = 1 # D29: at most one retry, same key.
_MAX_RETRIES = 1
class Transport(Protocol):
"""The slice of ``requests`` the client uses; tests inject a fake."""
def request(
self,
@@ -78,25 +48,16 @@ class Transport(Protocol):
def _default_transport() -> Transport:
return requests # module satisfies the protocol
return requests
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.
"""
"""Resolve the connector deployment origin directly, not as a vendor passthrough."""
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
@@ -107,8 +68,6 @@ def _default_header_provider(url: str) -> dict:
class ConnectorClient:
"""One dispatch's connection to the gateway's connector routes."""
def __init__(
self,
*,
@@ -120,10 +79,7 @@ class ConnectorClient:
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)
@@ -135,7 +91,6 @@ class ConnectorClient:
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
)
@@ -147,9 +102,7 @@ class ConnectorClient:
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."""
"""Never retry: the gateway cannot deduplicate authorization starts."""
body = wire.ConnectorConnectionsRequest(
connectors=list(connectors), reinitiate=reinitiate
).model_dump(by_alias=True)
@@ -157,14 +110,9 @@ class ConnectorClient:
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
for _ in range(20):
path = f"{wire.CONNECTORS_PATH}?limit=50"
if cursor:
path += f"&cursor={cursor}"
@@ -174,7 +122,11 @@ class ConnectorClient:
page = payload.get("items")
if not isinstance(page, list) or any(not isinstance(entry, dict) for entry in page):
raise ToolGatewayError("invalid connector list items", code="INVALID_RESPONSE")
items.extend(page)
try:
items.extend(wire.ConnectorListItem.model_validate(entry).model_dump(by_alias=True) for entry in page)
except ValidationError as exc:
raise ToolGatewayError(f"invalid connector list item: {exc.errors()[0].get('msg')}",
code="INVALID_RESPONSE") from exc
cursor = payload.get("nextCursor")
if not cursor:
return items
@@ -183,13 +135,7 @@ class ConnectorClient:
raise ToolGatewayError("connector list pagination incomplete", code="INVALID_RESPONSE")
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.
"""
"""Return gateway results in request order; merge owns length mismatches."""
body = wire.ConnectorExecuteRequest(
tools=[
wire.ConnectorExecuteCall(
@@ -198,8 +144,7 @@ class ConnectorClient:
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.
# Keep the key dispatch-local so its retry reuses it without a shared store.
idempotency_key = str(uuid.uuid4())
payload = self._post(
wire.CONNECTOR_EXECUTE_PATH,
@@ -210,8 +155,6 @@ class ConnectorClient:
parsed = wire.ConnectorExecuteResponse.model_validate(payload)
return [_result_dict(result) for result in parsed.results]
# -- plumbing -------------------------------------------------------
def _post(
self,
path: str,
@@ -247,8 +190,6 @@ class ConnectorClient:
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
)
@@ -285,7 +226,7 @@ class ConnectorClient:
continue
raise error
assert last_error is not None # loop ran at least once
assert last_error is not None
raise last_error
+29 -32
View File
@@ -1,16 +1,6 @@
"""Configuration gate for connector tools.
"""Configuration and availability 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 fails closed:
connectors_available() = config flag AND (free-tier identity OR managed_nous_tools_enabled())
The config flag is the user's off switch. An existing free-tier identity may
attempt connector routes without a subscription preflight (the gateway enforces
its actual grant); every other identity keeps 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.
Availability fails closed; the gateway remains authoritative for entitlement and route availability.
"""
from __future__ import annotations
@@ -28,10 +18,7 @@ __all__ = [
"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.
# Context cap, not a wire limit; the gateway batch cap is deliberately unreachable.
MAX_CALLS_PER_DISPATCH = 10
_FALSE_STRINGS = frozenset({"false", "0", "no", "off", ""})
@@ -39,18 +26,12 @@ _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.
"""
"""Malformed configuration falls back to the enabled default."""
if isinstance(raw, bool):
return cls(enabled=raw)
if isinstance(raw, dict):
@@ -71,7 +52,6 @@ def _coerce_bool(value: Any, fallback: bool) -> bool:
def load_config() -> ConnectorConfig:
"""Load connector config from the user config file."""
try:
from hermes_cli.config import load_config_readonly as _load
@@ -89,12 +69,7 @@ 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.
"""
"""Fail closed so availability failures do not become model-visible errors."""
try:
resolved_loader = config_loader or load_config
if not resolved_loader().enabled:
@@ -104,8 +79,7 @@ def connectors_available(
from tools.managed_tool_gateway import _read_nous_provider_state
from tools.tool_backend_helpers import managed_nous_tools_enabled
# Availability must not mint or refresh an identity. The shared reader
# already hides free-tier identities when nous.guest is disabled.
# Availability must not mint or refresh an identity.
if is_guest_state(_read_nous_provider_state()):
return True
@@ -114,3 +88,26 @@ def connectors_available(
except Exception as e:
logger.debug("Connector availability check failed: %s", e)
return False
def operation_session_key(session_id: Optional[str]) -> str:
"""The key an operation is registered under: the gateway session key the RPCs look up by
(``HERMES_SESSION_KEY``), falling back to the agent's session id where no gateway bound one.
The agent id alone is wrong on the desktop: compaction rotates it mid-turn while the gateway
key stays, and a card keyed by the old id can no longer be driven."""
from gateway.session_context import get_session_env
return get_session_env("HERMES_SESSION_KEY", "") or str(session_id or "")
def session_platform() -> str:
"""The session's surface (``desktop``, ``tui``, ``cli``, a messaging platform, or '').
Decides whether a card exists for this session. Never infer that from a callback being
attached: the GUI bridge attaches callbacks to every backend session, terminal TUI included.
Messaging adapters bind the surface as the session platform; the desktop and TUI gateway bind
it as the session source (``tui_gateway.server._set_session_context``), so both are read."""
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "") or get_session_env("HERMES_SESSION_SOURCE", "")
return str(platform or "").strip().lower()
+13 -44
View File
@@ -1,17 +1,6 @@
"""Typed errors for the connector tool gateway, plus THE envelope parser.
"""Gateway HTTP errors and envelope parsing.
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.
Per-tool execute failures are result entries, not exceptions; connection links are intentionally unredacted for the model to relay.
"""
from __future__ import annotations
@@ -29,12 +18,7 @@ __all__ = [
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.
"""
"""HTTP-level gateway failure; ``retryable`` is decided by the envelope parser."""
def __init__(
self,
@@ -53,31 +37,19 @@ class ToolGatewayError(RuntimeError):
class GatewayAuthError(ToolGatewayError):
"""401/403 — the portal token is missing, expired, or not entitled."""
"""Authentication or entitlement failure."""
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.
"""
"""404 signals callers to degrade silently to local-only behavior."""
class IdempotencyConflict(ToolGatewayError):
"""409 — the idempotency key was reused with a different body.
Always a client bug; never retried.
"""
"""Never retry a reused idempotency key with a different body."""
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.
"""
"""Parse every gateway error envelope without raising on a malformed body."""
code = f"HTTP_{status}"
message = ""
request_id = None
@@ -114,15 +86,10 @@ def render_connection_required(
message: Optional[str] = None,
connect_url: Optional[str] = None,
hint: Optional[str] = None,
card: bool = False,
) -> 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.
"""
"""Single shared CONNECTION_REQUIRED shape. With a card the link stays on the panel and the
model is told a connect card is available; without one the model relays the link."""
payload: dict[str, Any] = {
"code": "CONNECTION_REQUIRED",
"message": message
@@ -134,7 +101,9 @@ def render_connection_required(
}
if connector:
payload["connector"] = connector
if connect_url:
if card:
payload["connect_card_available"] = True
elif connect_url:
payload["connect_url"] = connect_url
if hint:
payload["hint"] = hint
+10 -70
View File
@@ -1,28 +1,6 @@
"""Pure partition / splice / render for mixed tool_call batches.
"""Pure partitioning, result splicing, and rendering 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, ...}}
Correlate all remote results by original call position, never wire ``index``.
"""
from __future__ import annotations
@@ -30,6 +8,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Optional, Sequence
from tools.connectors.gateway.config import session_platform
from tools.connectors.gateway.errors import render_connection_required
from tools.connectors.gateway.names import parse_connector_name
@@ -46,7 +25,6 @@ __all__ = [
@dataclass(frozen=True)
class PlannedCall:
"""A connector-bound entry, pinned to its position in the original array."""
position: int
name: str
@@ -57,26 +35,15 @@ class PlannedCall:
@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.
# Gateway request and response slots preserve this order.
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.
"""
"""Treat malformed input as per-entry errors so sibling calls still run."""
local: list[tuple[int, Mapping[str, Any]]] = []
remote: list[PlannedCall] = []
errors: list[dict[str, Any]] = []
@@ -113,13 +80,7 @@ def partition_calls(calls: Sequence[Any]) -> Partition:
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.
"""
"""Use the shared CONNECTION_REQUIRED shape so links render consistently."""
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
@@ -133,6 +94,7 @@ def render_remote_entry(planned: PlannedCall, remote: Mapping[str, Any]) -> dict
message=message,
connect_url=_opt_str(error.get("connect_url")),
hint=_opt_str(error.get("hint")),
card=session_platform() == "desktop",
)
else:
payload = {"code": code, "message": message}
@@ -149,14 +111,7 @@ 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.
"""
"""Correlate results by request slot; missing slots become per-entry errors."""
results = _as_sequence(remote_results)
entries: list[dict[str, Any]] = []
for slot, plan in enumerate(_as_sequence(planned)):
@@ -180,11 +135,6 @@ def fill_remote_failure(
*,
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
@@ -195,13 +145,7 @@ 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.
"""
"""Preserve call order and fill unclaimed slots with per-entry errors."""
try:
slot_count = max(0, int(total))
except (TypeError, ValueError):
@@ -242,11 +186,7 @@ def _error_entry(position: int, name: str, *, code: str, message: str) -> dict[s
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.
"""
"""Reject strings so malformed calls do not become character entries."""
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return value
return ()
+6 -49
View File
@@ -1,33 +1,6 @@
"""Codec for ``connectors__<connector>__<tool>`` bridge names.
"""Codec for model-facing connector tool 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.
Compose and parse bridge names only here. The composed-name collision resolves prefixed vendor slugs first everywhere.
"""
from __future__ import annotations
@@ -47,15 +20,12 @@ __all__ = [
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.
# Planner sentinel, not a callable connector name.
CONNECTOR_BATCH_SENTINEL = "connectors__execute"
@dataclass(frozen=True)
class ConnectorName:
"""A parsed ``connectors__<connector>__<tool>`` identifier."""
raw: str
connector: str
@@ -63,20 +33,12 @@ class ConnectorName:
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.
"""
"""Prefix match only; malformed names are rejected by the parser."""
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.
"""
"""Use a bounded split so tool slugs retain internal underscores."""
if not isinstance(name, str):
return None
parts = name.split("__", 2)
@@ -89,7 +51,6 @@ def parse_connector_name(name: object) -> Optional[ConnectorName]:
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):]
@@ -97,9 +58,5 @@ def format_connector_name(connector: str, tool: str) -> str:
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.
"""
"""Try the stripped conventional prefix before a literal vendor slug."""
return (f"{connector.upper()}_{tool}", tool)
+22 -38
View File
@@ -1,19 +1,6 @@
"""Wire models for the tool gateway's connector routes.
"""Wire models for the gateway 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.
Tolerate added gateway fields; callers receive plain dictionaries. Correlate results by position, never wire ``index``.
"""
from __future__ import annotations
@@ -22,21 +9,16 @@ 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.
# Hermes dispatch caps batches lower, so client-side chunking is deliberately absent.
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.
# A 200 execute envelope carries per-tool failures as results, not HTTP errors.
ConnectorErrorCode = Literal[
"TOOL_NOT_ALLOWED",
"CONNECTION_REQUIRED",
@@ -46,14 +28,10 @@ ConnectorErrorCode = Literal[
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")
@@ -71,7 +49,7 @@ class ConnectorToolSchema(_Wire):
class ConnectorSearchResult(_Wire):
# NOTE: 1-based vendor passthrough on this route; do not correlate on it.
# Vendor passthrough; result correlation uses array position.
index: int
use_case: str = Field(default="", alias="useCase")
tools: list[str] = Field(default_factory=list)
@@ -96,9 +74,6 @@ class ConnectorSearchResponse(_Wire):
next_steps: list[str] = Field(default_factory=list, alias="nextSteps")
# --- POST v1/connectors/schemas ---------------------------------------------
class ConnectorSchemasRequest(_Wire):
tools: list[str]
@@ -109,9 +84,6 @@ class ConnectorSchemasResponse(_Wire):
suggestions: dict[str, list[str]] = Field(default_factory=dict)
# --- POST v1/connectors/execute ---------------------------------------------
class ConnectorToolError(_Wire):
code: ConnectorErrorCode
message: str
@@ -131,7 +103,7 @@ class ConnectorExecuteRequest(_Wire):
class ConnectorExecuteResult(_Wire):
# NOTE: 0-based on this route (unlike search); still never correlated on.
# Never correlate results by wire index.
index: int = 0
connector: str = ""
tool: str = ""
@@ -140,16 +112,12 @@ class ConnectorExecuteResult(_Wire):
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
@@ -160,9 +128,25 @@ class ConnectorConnectionResult(_Wire):
status: Literal["active", "initiated", "failed"]
connect_url: Optional[str] = Field(default=None, alias="connectUrl")
instruction: Optional[str] = None
# Vendor error_message on ``failed``; the list route never carries it.
status_reason: Optional[str] = Field(default=None, alias="statusReason")
reinitiated: bool = False
# The gateway's seven-state account status. Present only once an account exists for the
# toolkit; a value outside this set is a contract break and fails validation.
ConnectionStatus = Literal["active", "initiated", "failed", "expired", "revoked", "inactive", "initializing"]
class ConnectorListItem(_Wire):
connector: str
enabled: bool = True
connected: bool = False
connection_status: Optional[ConnectionStatus] = Field(default=None, alias="connectionStatus")
status_reason: Optional[str] = Field(default=None, alias="statusReason")
disabled_tools: list[str] = Field(default_factory=list, alias="disabledTools")
class ConnectorConnectionsSummary(_Wire):
total: int = 0
active: int = 0
+50
View File
@@ -0,0 +1,50 @@
"""Live operations, one open per session, found by ``op_id``. The RPC layer reads and drives
operations through here; the tool thread that minted one closes it on settle."""
from __future__ import annotations
import threading
from typing import Dict, Optional
from tools.connectors.operation import ConnectionOperation
class OperationAlreadyOpen(RuntimeError):
def __init__(self, existing: ConnectionOperation):
super().__init__(f"session {existing.session_key!r} already has operation {existing.op_id} open")
self.existing = existing
_open: Dict[str, ConnectionOperation] = {}
_lock = threading.Lock()
def open(operation: ConnectionOperation) -> None: # noqa: A001 - the verb is the API
with _lock:
existing = _open.get(operation.session_key)
if existing is not None and not existing.settled:
raise OperationAlreadyOpen(existing)
_open[operation.session_key] = operation
def current(session_key: str) -> Optional[ConnectionOperation]:
with _lock:
operation = _open.get(session_key)
return operation if operation is not None and not operation.settled else None
def get(session_key: str, op_id: str) -> Optional[ConnectionOperation]:
with _lock:
operation = _open.get(session_key)
return operation if operation is not None and operation.op_id == op_id else None
def close(operation: ConnectionOperation) -> None:
with _lock:
if _open.get(operation.session_key) is operation:
del _open[operation.session_key]
def reset_for_tests() -> None:
with _lock:
_open.clear()
+135 -336
View File
@@ -1,260 +1,147 @@
"""Managed connectors (Nous tool gateway): status, connect / reconnect, and the in-call ``wait``.
"""Managed connectors (Nous tool gateway) on the connection operation.
``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`` blocks
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.
"""
``connect`` mints a link for every target up front and stores it on the target; ``reconnect``
reads status first and reinitiates only what is not connected (``force`` always reinitiates).
On a desktop session the call blocks until the operation settles and the result carries no URL;
the card owns the links. Off the desktop the result carries the URLs and returns at once, until
PR3 delivers them as their own message. The watcher hook reads the gateway list once per tick
(the exact-status route replaces this call when the gateway ships it)."""
from __future__ import annotations
import json
import logging
import threading
import time
from typing import Any, Callable, Dict, List, Optional
from tools.connectors.contract import Actor, TargetState
from tools.connectors.gateway.config import operation_session_key, session_platform
from tools.connectors.operation import ConnectionOperation, Target
from tools.connectors.run import Kind, run_operation
from tools.registry import tool_error
logger = logging.getLogger(__name__)
# (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()
# Statuses on the gateway list that end an attempt. The list's statusReason is generic copy, so the
# detail recorded at mint time is kept; only a missing detail is filled from the list.
_TERMINAL_LIST_STATUS = {"failed": TargetState.failed, "expired": TargetState.expired, "revoked": TargetState.failed}
# 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."
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."
)
def _default_client():
from tools.connectors.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 _status_by_slug(client: Any) -> Dict[str, Dict[str, Any]]:
return {str(i.get("connector", "")).lower(): i for i in client.list_connectors() if isinstance(i, dict)}
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
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."""
if not names:
return
response = client.connections(names, reinitiate=reinitiate)
for entry in response.get("results", []):
name = str(entry.get("connector") or "").lower()
target = operation.target(name)
if target is None:
continue
consecutive_errors = 0
spent += time.monotonic() - poll_started
status = str(entry.get("status") or "")
detail = str(entry.get("status_reason") or entry.get("statusReason") or "")
if status == "active":
operation.transition(name, TargetState.initiated, actor)
operation.transition(name, TargetState.connected, Actor.backend_watcher)
elif status == "initiated":
operation.transition(
name, TargetState.initiated, actor,
connect_url=entry.get("connect_url"), attempt=entry.get("attempt"), 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)
live_entries = []
live_slugs = set()
for item in items or ():
if not isinstance(item, dict):
def _observe(client: Any, operation: ConnectionOperation) -> None:
try:
status = _status_by_slug(client)
except Exception as exc:
logger.debug("connector watch poll failed: %s", exc)
return
for target in operation.targets:
# Only a live attempt (pending, initiated) can be advanced by a gateway read; a failed or expired
# link waits for the user, and a settled op is frozen.
if operation.settled or target.state not in (TargetState.pending, TargetState.initiated):
continue
row = status.get(target.name)
if row is None:
continue
row_status = str(row.get("connectionStatus") or "").lower()
if target.awaiting_new_attempt:
if row.get("connected") or row_status == "active":
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]
target.awaiting_new_attempt = False
if row.get("connected"):
if target.state == TargetState.pending:
operation.transition(target.name, TargetState.initiated, Actor.backend_watcher)
operation.transition(target.name, TargetState.connected, Actor.backend_watcher)
continue
terminal = _TERMINAL_LIST_STATUS.get(row_status)
if terminal is not None and target.state == TargetState.initiated:
# `expired` is the link TTL running out; the gateway reports it, the clock caused it.
actor = Actor.clock if terminal == TargetState.expired else Actor.backend_watcher
operation.transition(target.name, terminal, actor, detail=target.detail or str(row.get("statusReason") or ""))
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)
return
if force:
mint(client, operation, names, reinitiate=True, actor=Actor.backend_watcher)
for target in operation.targets:
if target.state == TargetState.initiated:
target.awaiting_new_attempt = True
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)
return prepare
def _off_desktop_result(client: Any, action: str, names: List[str], force: bool, session_id: str) -> str:
operation = ConnectionOperation([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)
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 run_managed_action(
action: str,
@@ -262,16 +149,13 @@ def run_managed_action(
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,
tool_call_id: Optional[str] = None,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
connectors_available: Optional[Callable[[], bool]] = None,
) -> str:
"""One managed-connector action (status / connect / reconnect / wait). Returns the tool's
JSON string. ``connectors`` are the normalized managed target names."""
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 _default_client)()
if action == "status":
@@ -279,110 +163,25 @@ def run_managed_action(
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,
)
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."
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,
force = bool(args.get("force", False))
session_key = operation_session_key(session_id)
if session_platform() != "desktop" or connection_callback is None:
return _off_desktop_result(client, action, connectors, force, session_key)
return run_operation(
[Target(n, "connector", action) for n in connectors],
Kind(prepare=_prepare(client, action, force), observe=lambda op: _observe(client, op), note=NOTE),
session_key=session_key, tool_call_id=tool_call_id,
connection_callback=connection_callback, with_urls_in_result=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}. "
+57 -79
View File
@@ -1,6 +1,6 @@
"""MCP targets of ``manage_connections``: catalog validation and the approval leg. The card is
reached via ``agent.connection_callback`` through the inline executor; registry dispatch has no
callback and settles targets ``unavailable``."""
"""MCP targets of ``manage_connections``: the renderer runs install / enable / OAuth and answers
through ``connection.respond``; nothing else observes an MCP flow today (PR3 moves OAuth
observation server-side). Calls without an approval callback settle unavailable."""
from __future__ import annotations
@@ -8,36 +8,29 @@ import json
import logging
from typing import Any, Callable, Dict, List, Optional
from tools.connectors.operation import (
CONNECTED,
FAILED,
SETTLED_ALL_RESOLVED,
SETTLED_CONTINUE,
SETTLED_DEADLINE,
SETTLED_INTERRUPT,
SETTLED_UNAVAILABLE,
SKIPPED,
UNAVAILABLE,
ConnectionOperation,
Target,
resolve_wait_timeout,
)
from tools.connectors.contract import Actor, SettleReason, TargetState
from tools.connectors.gateway.config import operation_session_key, session_platform
from tools.connectors.operation import ConnectionOperation, Target
from tools.connectors.run import Kind, run_operation
from tools.registry import tool_error
logger = logging.getLogger(__name__)
# Renderer outcome → operation state. declined = Not now; error = recoverable, operation stays open.
# The renderer's answer vocabulary → target state. A failed approval stays open; a decline resolves.
_OUTCOME_STATES = {
"installed": CONNECTED, "enabled": CONNECTED, "authorized": CONNECTED, "connected": CONNECTED,
"declined": SKIPPED, "skipped": SKIPPED,
"error": FAILED, "failed": FAILED,
"installed": TargetState.connected, "enabled": TargetState.connected, "authorized": TargetState.connected,
"connected": TargetState.connected,
"declined": TargetState.skipped, "skipped": TargetState.skipped,
"error": TargetState.failed, "failed": TargetState.failed,
}
UNAVAILABLE_HINT = "hermes mcp install {name} / hermes mcp login {name}"
NOTE = (
"Settled once; do not re-ask for any target the user skipped or that timed out — continue "
"without it or ask in chat. Tools of a newly installed or authorized server become available "
"on your next turn."
)
def _catalog_names() -> List[str]:
@@ -53,7 +46,6 @@ def _configured_names() -> List[str]:
def validate_mcp_names(action: str, names: List[str]) -> Optional[str]:
"""install: catalog names only; enable/authorize: configured servers only."""
try:
catalog = _catalog_names()
configured = _configured_names()
@@ -76,24 +68,9 @@ def validate_mcp_names(action: str, names: List[str]) -> Optional[str]:
)
def _unavailable_result(operation: ConnectionOperation) -> str:
for target in operation.targets:
operation.record_target(
target.name, UNAVAILABLE, "no approval surface in this session",
hint=UNAVAILABLE_HINT.format(name=target.name),
)
operation.settle(SETTLED_UNAVAILABLE)
payload = operation.result()
payload["status"] = "unavailable"
payload["note"] = (
"This session has no approval card, so local MCP servers cannot be set up here. Tell "
"the user to run the terminal commands in each target's 'hint', then continue."
)
return json.dumps(payload, ensure_ascii=False)
def _apply_answer(operation: ConnectionOperation, raw: str) -> str:
"""Apply the renderer's per-target answer; returns the settle reason the target states imply."""
def apply_answer(operation: ConnectionOperation, raw: str) -> None:
"""Fold the card's ``connection.respond`` payload into the operation. Settlement is derived
from target states afterwards, never from the card's own ``settled_by`` claim."""
try:
answer = json.loads(raw)
except (TypeError, ValueError):
@@ -105,54 +82,55 @@ def _apply_answer(operation: ConnectionOperation, raw: str) -> str:
continue
name = str(entry.get("name") or "").strip().lower()
state = _OUTCOME_STATES.get(str(entry.get("state") or entry.get("status") or "").lower())
if not name or state is None:
target = operation.target(name)
if target is None or state is None:
continue
actor = Actor.user if state == TargetState.skipped else Actor.renderer_flow
extra = {k: v for k, v in entry.items() if k in ("tools",)}
operation.record_target(name, state, str(entry.get("detail") or ""), **extra)
# Derived from target state; the renderer's own claim is ignored.
return SETTLED_ALL_RESOLVED if operation.all_resolved else SETTLED_CONTINUE
if target.state == TargetState.pending and state != TargetState.skipped:
operation.transition(name, TargetState.initiated, Actor.renderer_flow)
operation.transition(name, state, actor, detail=str(entry.get("detail") or ""), **extra)
if answer.get("settled_by") == SettleReason.continue_.value and not operation.all_resolved:
operation.settle(SettleReason.continue_)
def _unavailable(operation: ConnectionOperation) -> str:
for target in operation.targets:
target.state = TargetState.unavailable
target.detail = "no approval surface in this session"
target.extra = {"hint": UNAVAILABLE_HINT.format(name=target.name)}
operation.settle(SettleReason.unavailable)
payload = operation.result()
payload["status"] = "unavailable"
payload["note"] = (
"This session has no approval card, so local MCP servers cannot be set up here. Tell "
"the user to run the terminal commands in each target's 'hint', then continue."
)
return json.dumps(payload, ensure_ascii=False)
def run_mcp_operation(
names: List[str],
action: str,
reason: str,
*,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]],
session_id: Optional[str],
wait_seconds: Optional[float] = None,
tool_call_id: Optional[str] = None,
) -> str:
"""One operation for the MCP targets of a call. Returns the tool's JSON string."""
error = validate_mcp_names(action, names)
if error:
return tool_error(error)
operation = ConnectionOperation(
[Target(n, "mcp", action) for n in names],
session_key=str(session_id or ""),
wait_seconds=resolve_wait_timeout() if wait_seconds is None else wait_seconds,
targets = [Target(n, "mcp", action) for n in names]
session_key = operation_session_key(session_id)
# The surface decides, not the callback: every tui_gateway session has the callback attached,
# the Ink TUI included, and only the desktop renders the card.
if session_platform() != "desktop" or connection_callback is None:
return _unavailable(ConnectionOperation(targets, session_key=session_key))
def prepare(operation: ConnectionOperation) -> None:
pass
return run_operation(
targets, Kind(prepare=prepare, observe=lambda op: None, note=NOTE),
session_key=session_key, tool_call_id=tool_call_id, connection_callback=connection_callback,
with_urls_in_result=False,
)
if connection_callback is None:
return _unavailable_result(operation)
try:
raw = connection_callback(operation.request_payload(reason))
except Exception as exc:
return tool_error(f"MCP approval flow failed: {exc}")
settled_by = _apply_answer(operation, raw or "")
if raw:
operation.settle(settled_by)
else:
# Empty answer: deadline passed or the turn was interrupted.
from tools.interrupt import is_interrupted
operation.settle(SETTLED_INTERRUPT if is_interrupted() else SETTLED_DEADLINE)
payload = operation.result()
payload["status"] = "settled"
payload["note"] = (
"Settled once; do not re-ask for any target the user skipped or that timed out — "
"continue without it or ask in chat. Tools of a newly installed or authorized server "
"become available on your next turn."
)
return json.dumps(payload, ensure_ascii=False)
+102 -83
View File
@@ -1,5 +1,4 @@
"""One backend-owned connection operation per ``manage_connections`` call: targets, a
server-set deadline, exactly-once settlement. Pure data, no I/O."""
"""One backend-owned connection operation per ``manage_connections`` call. Pure data, no I/O."""
from __future__ import annotations
@@ -7,106 +6,122 @@ import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from typing import Any, Callable, ClassVar, Dict, List, Optional
# config.yaml ``connections.wait_timeout_seconds``; floor only, no ceiling.
WAIT_TIMEOUT_DEFAULT_SECONDS = 120.0
WAIT_TIMEOUT_FLOOR_SECONDS = 5.0
from tools.connectors.contract import RESOLVED_STATES, Actor, SettleReason, TargetState, allowed
# Target states. connected/skipped/unavailable are resolved; failed keeps the operation open.
PENDING = "pending"
CONNECTED = "connected"
SKIPPED = "skipped"
FAILED = "failed"
UNAVAILABLE = "unavailable"
# Stamped on unresolved targets at settlement; ``detail`` carries the settle reason.
NOT_CONNECTED = "not_connected"
RESOLVED_STATES = frozenset({CONNECTED, SKIPPED, UNAVAILABLE})
# How an operation settled.
SETTLED_ALL_RESOLVED = "all_resolved"
SETTLED_CONTINUE = "continue"
SETTLED_DEADLINE = "deadline"
SETTLED_INTERRUPT = "interrupt"
SETTLED_UNAVAILABLE = "unavailable"
# Not a config key: a user-tunable wait with clamp rails was a foot-gun (PR1 shipped one, unmerged).
OPERATION_DEADLINE_SECONDS = 300.0
def resolve_wait_timeout(config: Optional[Dict[str, Any]] = None) -> float:
"""``connections.wait_timeout_seconds`` from config.yaml, floored, default 120. Reads only
that key; the executor and clarify budgets never proxy for it."""
if config is None:
try:
from hermes_cli.config import load_config_readonly
config = load_config_readonly() or {}
except Exception:
config = {}
section = config.get("connections") if isinstance(config, dict) else None
raw = section.get("wait_timeout_seconds") if isinstance(section, dict) else None
try:
value = WAIT_TIMEOUT_DEFAULT_SECONDS if raw is None or isinstance(raw, bool) else float(raw)
except (TypeError, ValueError):
value = WAIT_TIMEOUT_DEFAULT_SECONDS
if value != value: # NaN
value = WAIT_TIMEOUT_DEFAULT_SECONDS
return max(WAIT_TIMEOUT_FLOOR_SECONDS, value)
class IllegalTransition(ValueError):
pass
@dataclass
class Target:
name: str
kind: str # "mcp" | "connector"
kind: str
action: str
state: str = PENDING
state: TargetState = TargetState.pending
detail: str = ""
# Renderer-reported fields passed through to the model (e.g. ``tools`` after OAuth).
connect_url: Optional[str] = None
# Opaque per-attempt handle when the gateway mints one (absent today; the status route adds it).
attempt: Optional[str] = None
# `reconnect force` on a connected account: the list reports the OLD account `active` until the user
# signs in again, so `connected` is not believed until the row has read as anything else once.
awaiting_new_attempt: bool = False
# Renderer-reported fields passed through to the model (``tools`` after MCP OAuth).
extra: Dict[str, Any] = field(default_factory=dict)
@property
def resolved(self) -> bool:
return self.state in RESOLVED_STATES
def snapshot(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"name": self.name, "kind": self.kind, "action": self.action, "state": self.state}
def snapshot(self, *, with_url: bool = True) -> Dict[str, Any]:
out: Dict[str, Any] = {"name": self.name, "kind": self.kind, "action": self.action, "state": self.state.value}
if self.detail:
out["detail"] = self.detail
if with_url and self.connect_url:
out["connect_url"] = self.connect_url
if self.attempt:
out["attempt"] = self.attempt
out.update(self.extra)
return out
@dataclass
class ConnectionOperation:
# The gateway installs its ``connection.update`` emitter here once; pure data otherwise.
on_change: ClassVar[Optional[Callable[["ConnectionOperation", Optional[Dict[str, Any]]], None]]] = None
targets: List[Target]
session_key: str = ""
wait_seconds: float = WAIT_TIMEOUT_DEFAULT_SECONDS
# The model's id for the call that opened the operation; the card binds to that tool row only.
tool_call_id: Optional[str] = None
op_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
created_at: float = field(default_factory=time.time)
deadline_at: float = 0.0
settled_at: Optional[float] = None
settled_by: Optional[str] = None
settled_by: Optional[SettleReason] = None
# Set on every transition and on settle; the waiting loop sleeps on it.
wake: threading.Event = field(default_factory=threading.Event, repr=False)
_settled_snapshot: Optional[Dict[str, Any]] = field(default=None, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def __post_init__(self) -> None:
if not self.deadline_at:
self.deadline_at = self.created_at + float(self.wait_seconds)
# -- targets -------------------------------------------------------------
self.deadline_at = self.created_at + OPERATION_DEADLINE_SECONDS
def target(self, name: str) -> Optional[Target]:
return next((t for t in self.targets if t.name == name), None)
def record_target(self, name: str, state: str, detail: str = "", **extra: Any) -> bool:
"""Update a target's live state; False for an unknown name. Allowed after settlement:
the frozen ``result()`` does not change."""
def transition(
self, name: str, to: TargetState, actor: Actor, *, detail: Optional[str] = None,
connect_url: Optional[str] = None, attempt: Optional[str] = None, **extra: Any,
) -> Optional[Dict[str, Any]]:
"""Move one target; the contract decides whether ``actor`` may. Returns the change, or None
when the target is already in ``to``. Allowed after settlement: the frozen result stays."""
target = self.target(name)
if target is None:
return False
raise IllegalTransition(f"unknown target {name!r}")
with self._lock:
target.state = state
target.detail = detail or ""
target.extra = dict(extra)
return True
if target.state == to:
return None
if allowed(target.kind, target.state, to) != actor:
raise IllegalTransition(f"{target.kind} {name}: {target.state.value} -> {to.value} by {actor.value}")
change = {"target": name, "from": target.state.value, "to": to.value, "actor": actor.value}
target.state = to
if detail is not None:
target.detail = detail
change["detail"] = target.detail
if connect_url is not None:
target.connect_url = connect_url
if attempt is not None:
target.attempt = attempt
if extra:
target.extra = dict(extra)
self.wake.set()
self._changed(change)
return change
def refresh(self, name: str, *, connect_url: Optional[str], detail: str) -> None:
"""Replace a target's link and detail without a state change (a repeated failure)."""
target = self.target(name)
if target is None:
raise IllegalTransition(f"unknown target {name!r}")
with self._lock:
target.connect_url = connect_url
target.detail = detail
change = {"target": name, "from": target.state.value, "to": target.state.value, "actor": Actor.user.value,
"detail": detail}
self.wake.set()
self._changed(change)
def _changed(self, change: Optional[Dict[str, Any]]) -> None:
hook = type(self).on_change
if hook is not None:
hook(self, change)
@property
def all_resolved(self) -> bool:
@@ -119,51 +134,55 @@ class ConnectionOperation:
def remaining_seconds(self, now: Optional[float] = None) -> float:
return max(0.0, self.deadline_at - (time.time() if now is None else now))
# -- settlement ----------------------------------------------------------
def settle(self, by: str, now: Optional[float] = None) -> bool:
"""Compare-and-set: first caller freezes the result, later callers get False."""
def settle(self, by: SettleReason, now: Optional[float] = None) -> bool:
"""Compare-and-set: the first caller freezes the result."""
with self._lock:
if self.settled_at is not None:
return False
self.settled_at = time.time() if now is None else now
self.settled_by = by
# Unresolved targets become not_connected so the settled card shows no pending row.
for target in self.targets:
if not target.resolved:
reason = target.detail or by
target.state = NOT_CONNECTED
target.detail = reason
target.state = TargetState.not_connected
self._settled_snapshot = self._snapshot_locked()
return True
self.wake.set()
self._changed(None)
return True
def settle_if_all_resolved(self) -> bool:
return self.all_resolved and self.settle(SETTLED_ALL_RESOLVED)
return self.all_resolved and self.settle(SettleReason.all_resolved)
def _snapshot_locked(self) -> Dict[str, Any]:
def _snapshot_locked(self, *, with_urls: bool = True) -> Dict[str, Any]:
return {
"op_id": self.op_id,
"deadline_at": self.deadline_at,
"settled_at": self.settled_at,
"settled_by": self.settled_by,
"targets": [t.snapshot() for t in self.targets],
"settled_by": self.settled_by.value if self.settled_by else None,
"targets": [t.snapshot(with_url=with_urls) for t in self.targets],
}
def result(self) -> Dict[str, Any]:
def result(self, *, with_urls: bool = True) -> Dict[str, Any]:
"""The settled result (frozen at settle time), or the live snapshot before settlement."""
with self._lock:
if self._settled_snapshot is not None:
return dict(self._settled_snapshot, targets=[dict(t) for t in self._settled_snapshot["targets"]])
return self._snapshot_locked()
targets = [dict(t) for t in self._settled_snapshot["targets"]]
if not with_urls:
for t in targets:
t.pop("connect_url", None)
return dict(self._settled_snapshot, targets=targets)
return self._snapshot_locked(with_urls=with_urls)
def request_payload(self, reason: str = "") -> Dict[str, Any]:
"""The ``connection.request`` payload: identity, targets, server-owned deadline."""
return {
def request_payload(self) -> Dict[str, Any]:
"""The ``connection.request`` payload and the resume snapshot: identity, live target snapshots
(links included, the panel owns them), server-owned deadline."""
with self._lock:
targets = [t.snapshot() for t in self.targets]
payload: Dict[str, Any] = {
"op_id": self.op_id,
"deadline_at": self.deadline_at,
"timeout_seconds": float(self.wait_seconds),
"reason": reason or "",
"targets": [
{"name": t.name, "kind": t.kind, "action": t.action} for t in self.targets
],
"timeout_seconds": OPERATION_DEADLINE_SECONDS,
"targets": targets,
}
if self.tool_call_id:
payload["tool_call_id"] = self.tool_call_id
return payload
+100
View File
@@ -0,0 +1,100 @@
"""The one lifecycle every ``manage_connections`` operation follows, whatever the target kind.
``run_operation`` mints the op, registers it in ``live``, lets the kind prepare its targets (a
managed mint, an MCP catalog check), emits the card through the session callback, then loops:
sleep on ``op.wake`` for at most one tick, run the kind's ``observe`` hook, settle when every
target is resolved or the deadline passes. ``connection.respond`` reaches the loop by transitioning
the op through ``live`` and setting ``wake``; ``/stop`` sets the thread interrupt flag."""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
from tools.connectors import live
from tools.connectors.contract import SettleReason
from tools.connectors.operation import ConnectionOperation, Target
WATCH_INTERVAL_SECONDS = 5.0
# The interrupt flag has no wake hook, so the tick sleep is sliced and the flag read each slice.
_WAKE_SLICE_SECONDS = 0.25
Callback = Callable[[Dict[str, Any]], Optional[str]]
@dataclass
class Kind:
"""Per-kind hooks. ``prepare`` runs once before the card; ``observe`` runs every tick and may
transition targets; ``note`` is the model-facing guidance appended to the settled result."""
prepare: Callable[[ConnectionOperation], None]
observe: Callable[[ConnectionOperation], None]
note: str
def run_operation(
targets: List[Target],
kind: Kind,
*,
session_key: str,
tool_call_id: Optional[str],
connection_callback: Optional[Callback],
tick_seconds: Optional[float] = None,
with_urls_in_result: bool,
) -> str:
"""Block the tool thread until the operation settles; return the tool's JSON string."""
operation = ConnectionOperation(targets, session_key=session_key, tool_call_id=tool_call_id)
try:
live.open(operation)
except live.OperationAlreadyOpen as exc:
from tools.registry import tool_error
return tool_error(
f"a connection operation is already open in this session ({exc.existing.op_id}); it settles "
"when the user finishes with the card, on Continue, or at its deadline. Do not start another."
)
try:
kind.prepare(operation)
if connection_callback is not None and not operation.settled:
connection_callback(operation.request_payload())
_watch(operation, kind, tick_seconds)
finally:
live.close(operation)
payload = operation.result(with_urls=with_urls_in_result)
payload["status"] = "settled"
payload["note"] = kind.note
return json.dumps(payload, ensure_ascii=False)
def _watch(operation: ConnectionOperation, kind: Kind, tick_seconds: Optional[float]) -> None:
from tools.interrupt import is_interrupted
tick = WATCH_INTERVAL_SECONDS if tick_seconds is None else tick_seconds
while not operation.settled:
if is_interrupted():
operation.settle(SettleReason.interrupt)
return
if time.time() >= operation.deadline_at:
operation.settle(SettleReason.deadline)
return
kind.observe(operation)
# The card or the clock may have settled the op during the read; its result is frozen.
if operation.settled or operation.settle_if_all_resolved():
return
_sleep_until_wake(operation, tick)
def _sleep_until_wake(operation: ConnectionOperation, tick: float) -> None:
"""Sleep up to one tick, leaving early on ``wake``, the deadline, or the interrupt flag."""
from tools.interrupt import is_interrupted
until = min(time.time() + tick, operation.deadline_at)
while not operation.settled and not is_interrupted():
remaining = until - time.time()
if remaining <= 0:
break
if operation.wake.wait(min(_WAKE_SLICE_SECONDS, remaining)):
break
operation.wake.clear()
+7 -39
View File
@@ -1,10 +1,6 @@
"""Connector (remote) leg of the tool-search bridge.
"""Remote connector adapter for tool search and descriptions.
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).
Failures return no connector results so local search behavior is unchanged.
"""
from __future__ import annotations
@@ -19,14 +15,10 @@ 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 {}
@@ -41,12 +33,7 @@ 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.
"""
"""Correlate remote response groups by position, never their wire index."""
per_query: List[List[CatalogEntry]] = [[] for _ in queries]
try:
if connector_search is None:
@@ -59,37 +46,22 @@ def connector_entries_by_group(
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
picked: Dict[str, tuple[str, CatalogEntry]] = {}
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.
continue
# Gateway policy matches normalized lowercase connector slugs; tool slugs stay verbatim.
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.
# Keep the slug this composed name resolves to; its twin would execute differently.
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)
@@ -110,10 +82,6 @@ def remote_schemas_for(
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 {}
+3 -11
View File
@@ -1,15 +1,10 @@
"""Target and action validation shared by every leg of ``manage_connections``.
``connectors`` entries are bare slugs or ``{name, mcp}`` objects; bare strings and ``{name}`` are
managed connectors, ``{name, mcp: true}`` is a local MCP server. Connector verbs need managed
targets, MCP verbs need MCP targets. Pure functions, no I/O.
"""
"""Target normalization and action validation for ``manage_connections``."""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
CONNECTOR_ACTIONS = ("status", "connect", "reconnect", "wait")
CONNECTOR_ACTIONS = ("status", "connect", "reconnect")
MCP_ACTIONS = ("install", "enable", "authorize")
ALL_ACTIONS = CONNECTOR_ACTIONS + MCP_ACTIONS
@@ -17,8 +12,6 @@ _TARGET_FIELDS = frozenset({"name", "mcp"})
def normalize_targets(raw: Any) -> Tuple[List[str], List[str], Optional[str]]:
"""``connectors`` → ``(managed names, mcp names, error)``. Bare strings and ``{name}`` are
managed; ``{name, mcp: true}`` is a local MCP. Any other field is an error."""
if raw is None:
return [], [], None
if isinstance(raw, (str, dict)):
@@ -49,7 +42,6 @@ def normalize_targets(raw: Any) -> Tuple[List[str], List[str], Optional[str]]:
def validate_action(action: str, managed: List[str], mcp: List[str]) -> Optional[str]:
"""MCP verbs need ``mcp:true`` targets; connector verbs need managed targets."""
if action not in ALL_ACTIONS:
return (
f"action must be one of {', '.join(ALL_ACTIONS)}. "
@@ -63,7 +55,7 @@ def validate_action(action: str, managed: List[str], mcp: List[str]) -> Optional
return (
f"'{action}' is an MCP action: every target must carry \"mcp\": true "
f"(got managed connector(s) {', '.join(managed)}). Managed connectors use "
"connect / reconnect / wait / status."
"connect / reconnect / status."
)
if not mcp:
return (
+29 -83
View File
@@ -1,60 +1,27 @@
#!/usr/bin/env python3
"""Manage remote connector accounts served through the tool gateway.
"""Connection lifecycle tool for managed gateway accounts and local MCP servers.
``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: managed connectors AND local MCP servers. ``{"name": ..., "mcp": true}`` targets take
``install`` / ``enable`` / ``authorize`` through one connection operation (``operation.py``,
``mcp.py``); the approval card is reached only via the inline executor, so non-GUI surfaces get
``unavailable`` for MCP targets. Managed actions live in ``managed.py``; target and action
validation in ``targets.py``.
De-authentication is deliberately NOT exposed to the model: disconnecting
an account is a user decision, made in the portal dashboard.
Availability: the managed leg is portal-gated in the handler; MCP targets need no sign-in.
Disconnecting accounts remains a portal-only user decision.
"""
from typing import Any, Callable, Dict, Optional
from tools.connectors.gateway import config as gateway_config
from tools.connectors.managed import (
_WAIT_DEFAULT_SECONDS,
_WAIT_MAX_SECONDS,
_WAIT_MIN_SECONDS,
run_managed_action,
)
from tools.connectors.managed import run_managed_action
from tools.connectors.mcp import run_mcp_operation
from tools.connectors.targets import ALL_ACTIONS, MCP_ACTIONS, normalize_targets, validate_action
from tools.registry import registry, tool_error
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,
tool_call_id: Optional[str] = None,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
connectors_available: Optional[Callable[[], bool]] = None,
wait_seconds: Optional[float] = None,
) -> str:
"""Dispatch one ``manage_connections`` action. Returns a JSON string."""
action = str(args.get("action") or "status").strip().lower()
managed, mcp_targets, target_error = normalize_targets(args.get("connectors"))
if target_error:
@@ -65,17 +32,17 @@ def manage_connections(
if action in MCP_ACTIONS:
return run_mcp_operation(
mcp_targets, action, str(args.get("reason") or "").strip(),
connection_callback=connection_callback, session_id=session_id, wait_seconds=wait_seconds,
mcp_targets, action,
connection_callback=connection_callback, session_id=session_id, tool_call_id=tool_call_id,
)
return run_managed_action(
action, managed, args,
client_factory=client_factory, seen_instructions=seen_instructions,
rendered_links=rendered_links, session_id=session_id,
connectors_available=connectors_available,
client_factory=client_factory, session_id=session_id, tool_call_id=tool_call_id,
connection_callback=connection_callback, connectors_available=connectors_available,
)
MANAGE_CONNECTIONS_SCHEMA = {
"name": "manage_connections",
"description": (
@@ -84,33 +51,23 @@ MANAGE_CONNECTIONS_SCHEMA = {
"'connectors': a bare slug or {\"name\": \"gmail\"} is a managed connector; "
"{\"name\": \"linear\", \"mcp\": true} is a local MCP server. "
"Managed 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. "
"starts an authorization for the given connectors; 'reconnect' checks each one and "
"repairs only what is not connected ('force': true restarts even a working one, for an "
"account switch). Pass SEVERAL slugs in one call. In the desktop app the call shows the "
"user a card and blocks until every app is connected, skipped, or the deadline passes; "
"the result lists each target as connected / skipped / not_connected and never carries "
"a link. Elsewhere the result carries a connect_url per app for the USER to open in a "
"browser (never open it yourself); ask them to say when they are done, then use 'status'. "
"When a connector tool call returns CONNECTION_REQUIRED, use 'connect'. "
"MCP actions (targets must carry \"mcp\": true): 'install' adds a catalog entry, "
"'enable' re-enables a disabled configured server, 'authorize' runs its OAuth. "
"They show the user an approval card and block until it settles; the result lists "
"each target as connected / skipped / not_connected. Never hand-edit mcp_servers "
"config — always use this tool. Never re-ask after a skip or timeout: continue "
"without the server or ask in chat. A newly installed or authorized server's tools "
"arrive on your next turn. Off the desktop app the MCP targets come back "
"'unavailable' with the terminal commands to give the user. "
"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."
"They show the user an approval card and block until it settles. Never hand-edit "
"mcp_servers config — always use this tool. Never re-ask after a skip or timeout: continue "
"without the app or ask in chat. A newly installed or authorized server's tools arrive on "
"your next turn. Off the desktop app the MCP targets come back 'unavailable' with the "
"terminal commands to give the user. 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",
@@ -141,19 +98,9 @@ MANAGE_CONNECTIONS_SCHEMA = {
"(e.g. [\"gmail\", {\"name\": \"linear\", \"mcp\": true}]); optional filter for status."
),
},
"reason": {
"type": "string",
"description": "MCP actions: one sentence on the approval card — why this helps right now.",
},
"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."
),
"force": {
"type": "boolean",
"description": "reconnect only: restart the authorization even if the app is connected (account switch).",
},
},
"required": [],
@@ -165,9 +112,8 @@ registry.register(
name="manage_connections",
toolset="connections",
schema=MANAGE_CONNECTIONS_SCHEMA,
# The portal gate is in the handler, not check_fn, so signed-out sessions keep the tool for
# MCP approvals. The registry path has no GUI callback. Module attribute, not a bound name, so
# tests patch ``gateway.config.connectors_available`` once and every reader sees it.
# Keep the portal gate in the handler so signed-out sessions retain MCP approvals.
# Read the module attribute so tests patch ``gateway.config.connectors_available`` at one seam.
handler=lambda args, **kw: manage_connections(
args, session_id=kw.get("session_id"), connectors_available=gateway_config.connectors_available,
),
+1
View File
@@ -59,6 +59,7 @@ New event = `event("<type>", Payload)` in `contracts/events.py`; the emitter is
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` carries skin data |
| Plugin compat notice | — | `plugins.compat_report` (see `plugins/AGENTS.md`) |
| Connection operations (desktop card) | desktop `store/connection-request.ts` | `connection.request` → `connection.update`* → `connection.respond {op_id}`; `connectors.operation.status`. The op lives in `tools/connectors/live.py`; the card never parks the tool thread (`methods_connectors.py`). |
## Shared subagent snapshots
+3 -4
View File
@@ -111,10 +111,9 @@ def _agent_cbs(sid: str) -> dict:
"drive_preview_callback": lambda payload: _ask("preview.act", sid, dict(payload), timeout=45),
# read_window_below (desktop GUI): main process enumerates native windows.
"read_window_below_callback": lambda: _ask("window.read", sid, {}, timeout=30),
# manage_connections approval card (desktop GUI): one ``connection`` server request per
# operation; the renderer answers with the per-target outcomes. Waits the operation's own
# deadline, which the payload carries.
"connection_callback": lambda payload: _connection_request(sid, dict(payload)),
# manage_connections card. Fire-and-forget: the tool thread waits on its own operation
# (tools/connectors/run.py), and the card drives it through connection.respond by op_id.
"connection_callback": lambda payload: _emit("connection.request", sid, dict(payload)) and None,
# tour (desktop GUI): renderer drives driver.js and answers the ``tour`` request.
"tour_callback": lambda payload: _tour_request(sid, payload)}
+4 -1
View File
@@ -1,4 +1,7 @@
"""Connector metadata egress, preserving authorization links and future fields."""
"""Connector UI payload redaction.
Preserve authorization links while redacting credentials.
"""
from agent.redact import _key_has_secret_keyword, redact_sensitive_text
+1
View File
@@ -5,6 +5,7 @@ from . import ( # noqa: F401
billing_delegation_pets,
common,
config_free_tier_control,
connectors_operation,
events,
groups_bot_relay,
liveness,
@@ -15,6 +15,7 @@ from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, ProfileParams, SessionLiveInfo
from .connectors_operation import ConnectionOperationStatus
from .registry import method
# ── config.get ────────────────────────────────────────────────────────────────────────────────
@@ -314,23 +315,17 @@ class ConnectorsConnectParams(ProfileParams):
reconnect: bool = False
class ConnectorConnectEntry(OpenModel):
"""``tools/connections_tool.py`` per-connector authorization outcome."""
class ConnectorsConnectResult(ConnectionOperationStatus):
"""The operation the connect opened (or re-minted on): ``tools/connectors/managed.py``
``_off_desktop_result`` / ``methods_connectors._reissue``. ``status``/``note`` ride along from
the tool result when the call ran through ``manage_connections``."""
connector: str = ""
status: str | None = None
connect_url: str | None = None
note: str | None = None
instruction: str | None = None
class ConnectorsConnectResult(Result):
results: list[ConnectorConnectEntry]
summary: dict[str, JsonValue]
method("connectors.connect", params=ConnectorsConnectParams, result=ConnectorsConnectResult,
doc="Start (or re-initiate) authorization for named connectors; returns per-connector links/status.")
doc="Start (or re-initiate) authorization for named connectors on the session's connection operation.")
# ── image.generate ────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,161 @@
"""The connection operation (``manage_connections`` card): the request event that opens a card,
the update frames that drive it, and the RPCs the card answers through.
Shapes are typed from ``tools/connectors/operation.py`` (``Target.snapshot``,
``ConnectionOperation.request_payload`` / ``_snapshot_locked``) and
``tui_gateway/methods_connectors.py`` (``_operation_view``, ``_connection_update``). The card is a
projection: every frame carries the full target snapshot, and the renderer never derives state.
"""
from __future__ import annotations
from pydantic import Field
from .base import Params, Payload, Result, WireEnum
from .common import ProfileParams
from .registry import event, method
class ConnectionTargetKind(WireEnum):
connector = "connector"
mcp = "mcp"
class ConnectionTargetAction(WireEnum):
authorize = "authorize"
connect = "connect"
enable = "enable"
install = "install"
reconnect = "reconnect"
class ConnectionTargetState(WireEnum):
"""``tools/connectors/contract.py::TargetState``."""
pending = "pending"
initiated = "initiated"
connected = "connected"
skipped = "skipped"
failed = "failed"
expired = "expired"
unavailable = "unavailable"
not_connected = "not_connected"
class ConnectionActor(WireEnum):
"""``tools/connectors/contract.py::Actor``."""
user = "user"
renderer_flow = "renderer_flow"
backend_watcher = "backend_watcher"
clock = "clock"
class ConnectionSettleReason(WireEnum):
"""``tools/connectors/contract.py::SettleReason``."""
all_resolved = "all_resolved"
continue_ = "continue"
deadline = "deadline"
interrupt = "interrupt"
unavailable = "unavailable"
class ConnectionOperationTarget(Payload):
"""``Target.snapshot``: the link minted up front rides here, never in the model result. ``extra``
keys a leg records (``tools``, ``hint``) are typed here as they appear."""
name: str
kind: ConnectionTargetKind
action: ConnectionTargetAction
state: ConnectionTargetState
detail: str | None = None
connect_url: str | None = None
attempt: str | None = None
tools: list[str] | None = None
hint: str | None = None
class ConnectionRequestPayload(Payload):
"""``ConnectionOperation.request_payload``: opens the card; also the ``pending_connection`` resume
snapshot so a client that missed the event restores the card with the server's deadline."""
op_id: str
deadline_at: float
timeout_seconds: float
targets: list[ConnectionOperationTarget]
# The model's id for the call that opened the operation; the card binds to that tool row only.
tool_call_id: str | None = None
event("connection.request", ConnectionRequestPayload,
doc="A connection operation opened on this session; the desktop renders its card.")
class ConnectionOperationStatus(Result):
"""``methods_connectors._operation_view``: the operation's full snapshot."""
op_id: str
deadline_at: float
settled: bool
settled_at: float | None = None
settled_by: ConnectionSettleReason | None = None
targets: list[ConnectionOperationTarget]
class ConnectionUpdatePayload(ConnectionOperationStatus, Payload):
"""``methods_connectors._connection_update``: one target transition (``target``/``from``/``to``/
``actor``) or the settlement (none of those), with the full snapshot."""
target: str | None = None
from_: ConnectionTargetState | None = Field(default=None, alias="from") # ``from`` is a keyword
to: ConnectionTargetState | None = None
actor: ConnectionActor | None = None
detail: str | None = None
event("connection.update", ConnectionUpdatePayload,
doc="One transition or the settlement of an open connection operation.")
class ConnectionOperationParams(ProfileParams):
session_id: str
op_id: str
method("connectors.operation.status", params=ConnectionOperationParams, result=ConnectionOperationStatus,
doc="The current snapshot of one open operation on an owned session.")
class ConnectionAnswerTarget(Params):
"""One row's answer from the card. ``status`` is what the card observed for that row
(``tools/connectors/mcp.py::_OUTCOME_STATES`` maps it onto a target state); ``state`` is the
older spelling of the same field and one of the two is present."""
model_config = Params.model_config | {"extra": "allow"}
name: str
status: str | None = None
state: str | None = None
detail: str | None = None
tools: list[str] | None = None
class ConnectionAnswer(Params):
"""The card's answer: per-target outcomes and an optional Continue
(``settled_by: "continue"``). Settlement is derived from target states afterwards."""
targets: list[ConnectionAnswerTarget] = Field(default_factory=list)
settled_by: ConnectionSettleReason | None = None
class ConnectionRespondParams(ConnectionOperationParams):
result: ConnectionAnswer
class ConnectionRespondResult(Result):
status: str
settled: bool
method("connection.respond", params=ConnectionRespondParams, result=ConnectionRespondResult,
doc="Per-target outcomes from the card, and an optional Continue.")
-67
View File
@@ -143,73 +143,6 @@ server_request("vault.code", params=VaultCodeRequestParams, result=ValueResult,
doc="A one-time / 2FA code the user reads from their device.")
# ── connection operation (manage_connections card) ────────────────────────────────────────────
class ConnectionTargetKind(WireEnum):
connector = "connector"
mcp = "mcp"
class ConnectionAction(WireEnum):
authorize = "authorize"
enable = "enable"
install = "install"
class ConnectionRequestTarget(Params):
"""One row of the card: ``tools/connections_tool_operation.py::ConnectionOperation.request_payload``."""
name: str
kind: ConnectionTargetKind
action: ConnectionAction
class ConnectionRequestParams(ServerRequestParams):
"""``tools/connections_tool_operation.py::ConnectionOperation.request_payload`` — one operation,
N targets, a server-owned deadline (epoch seconds) the restored card keeps."""
op_id: str
deadline_at: float
timeout_seconds: float
reason: str = ""
targets: list[ConnectionRequestTarget]
class ConnectionTargetOutcomeState(WireEnum):
installed = "installed"
enabled = "enabled"
authorized = "authorized"
declined = "declined"
error = "error"
class ConnectionTargetOutcome(Result):
"""The card's answer for one target (``tools/connections_tool_mcp.py::_apply_answer``)."""
name: str
state: ConnectionTargetOutcomeState
detail: str | None = None
tools: list[str] | None = None
class ConnectionSettledBy(WireEnum):
all_resolved = "all_resolved"
continue_ = "continue"
class ConnectionResult(Result):
"""Per-target outcomes. The backend derives the settle reason from target state; the renderer's
``settled_by`` is its own claim and is not trusted."""
settled_by: ConnectionSettledBy
targets: list[ConnectionTargetOutcome]
server_request("connection", params=ConnectionRequestParams, result=ConnectionResult,
doc="The manage_connections approval card: install / enable / authorise local MCP servers.")
# ── desktop GUI bridges ───────────────────────────────────────────────────────────────────────
+4
View File
@@ -10,6 +10,7 @@ from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import (OpenModel, PendingApproval, ProfileParams, SessionLiveInfo, SessionParams, TranscriptMessage,
Usage)
from .connectors_operation import ConnectionRequestPayload
from .registry import method
@@ -88,6 +89,9 @@ class LiveSessionSnapshot(Result):
queued: QueuedPrompt | None = None
pending_approval: PendingApproval | None = None
open_requests: list[OpenRequestEntry] | None = None
# The open connection operation (``tools/connectors/live.current``) as its ``connection.request``
# payload: the card restores with the server's deadline after a reconnect or restart.
pending_connection: ConnectionRequestPayload | None = None
todo_state: TodoState | None = None
auto_continue: AutoContinue | None = None
+131 -25
View File
@@ -1,9 +1,8 @@
"""Connector list and connect RPCs for one session.
"""Session-scoped connector RPCs and the connection-operation bridge.
Both calls run on the RPC pool. Authorization comes from the WebSocket upgrade
authentication (including legacy local and SSH tokens) and from live transport
membership; a profile or identity sent by the renderer does not grant it.
Neither call builds an agent, opens a browser, or waits in a loop.
Live transport ownership, not renderer-supplied profile or identity, authorizes requests.
The operation itself lives in ``tools.connectors.live``; this module reads and drives it and
pushes every transition to the session as ``connection.update``.
"""
import contextvars
@@ -32,20 +31,28 @@ def _connector_owner_matches(sid, owner, profile_home):
and owner.get("profile_home") == profile_home)
def _connector_rpc(rid, params, action):
def _owned_session(rid, params):
"""(owner, None) for a session this transport owns; (None, error reply) otherwise."""
sid = params.get("session_id")
if not isinstance(sid, str) or not sid.strip():
return _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "session_id required")
return None, _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "session_id required")
_, owner = _current_session_steer_authority(sid)
origin = _connector_rpc_origin.get()
if (owner is None or owner.get("_finalized")
or origin is not None and (origin[0] is not owner or origin[1] != owner.get("profile_home"))):
return _connector_rpc_error(rid, 4001, "NOT_OWNER", "session not found or not owned by this transport")
return None, _connector_rpc_error(rid, 4001, "NOT_OWNER", "session not found or not owned by this transport")
if _session_uses_compute_host(owner):
return _connector_rpc_error(rid, 5033, "UNSUPPORTED_RUNTIME", "Connectors must be managed on the session's compute host.")
return None, _connector_rpc_error(rid, 5033, "UNSUPPORTED_RUNTIME", "Connectors must be managed on the session's compute host.")
return owner, None
def _connector_rpc(rid, params, action):
owner, error = _owned_session(rid, params)
if error:
return error
sid = params["session_id"]
allowed = {"session_id"} if action == "status" else {"session_id", "connectors", "reconnect"}
# Shared-primary routing adds a profile parameter. Authorization comes from the live transport checked
# above, so this parameter is accepted and unused.
# ``profile`` is routing metadata, never authorization.
allowed.add("profile")
if set(params) - allowed:
return _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "unsupported connector parameters")
@@ -61,8 +68,7 @@ def _connector_rpc(rid, params, action):
profile_home = owner.get("profile_home")
runtime_token = _current_runtime_session_record.set(owner)
try:
# Bind launch explicitly too: an ambient sibling-profile override must not
# leak into a session whose profile_home=None means the launch profile.
# Bind the launch profile to prevent ambient sibling-profile leakage.
scope = {"profile_home": profile_home or str(_hermes_home)}
with _session_profile_runtime_scope(scope):
tokens = _set_session_context(owner["session_key"], cwd=_session_cwd(owner), ui_session_id=sid)
@@ -74,7 +80,7 @@ def _connector_rpc(rid, params, action):
return _connector_rpc_error(rid, 4001, "NOT_OWNER", "session ownership changed")
return result
except Exception:
# Do not send exception strings: HTTP errors can contain headers/tokens.
# Do not expose exception strings: HTTP errors can contain credentials.
return _connector_rpc_error(rid, 5034, "CONNECTOR_REQUEST_FAILED", "Connector request failed. Try again explicitly.")
finally:
_current_runtime_session_record.reset(runtime_token)
@@ -82,12 +88,10 @@ def _connector_rpc(rid, params, action):
def _dispatch_connector_rpc(rid, sid, owner, profile_home, args):
import model_tools
from tools.connectors import connectors_available
from tools.connectors import connectors_available, live
from tui_gateway.connector_payload import connector_ui_payload
agent = owner.get("agent")
# A cold session has no cached grant yet. Resolve exactly as _make_agent
# does, under that session's profile/cwd, without constructing an LLM.
enabled = (agent.enabled_toolsets if agent is not None
else _load_enabled_toolsets(_resolve_agent_platform(_session_source(owner))))
disabled = agent.disabled_toolsets if agent is not None else None
@@ -98,6 +102,9 @@ def _dispatch_connector_rpc(rid, sid, owner, profile_home, args):
return _connector_rpc_error(rid, 4031, "CONNECTORS_UNAVAILABLE", "Connectors are not available in this session.")
if not _connector_owner_matches(sid, owner, profile_home):
return _connector_rpc_error(rid, 4001, "NOT_OWNER", "session ownership changed")
if args["action"] != "status" and (operation := live.current(owner["session_key"])) is not None:
# The card's Try again / Connect while the model's operation is open: reissue on that op.
return _reissue(rid, operation, args)
raw = model_tools.handle_function_call(
"manage_connections", args, task_id=owner["session_key"],
session_id=getattr(agent, "session_id", None) or owner["session_key"],
@@ -107,28 +114,127 @@ def _dispatch_connector_rpc(rid, sid, owner, profile_home, args):
data = json.loads(raw) if isinstance(raw, str) else raw
if not isinstance(data, dict) or "error" in data:
return _connector_rpc_error(rid, 5034, "CONNECTOR_REQUEST_FAILED", "Connector request failed or was refused by policy.")
key = "connectors" if args["action"] == "status" else "results"
if not isinstance(data.get(key), list) or any(not isinstance(row, dict) for row in data[key]):
return _connector_rpc_error(rid, 5034, "INVALID_CONNECTOR_RESPONSE", "Connector service returned an invalid response.")
if key == "connectors":
return _ok(rid, {"available": True, "connectors": connector_ui_payload(data[key])})
if not data["results"] or not isinstance(data.get("summary"), dict):
if args["action"] == "status":
if not isinstance(data.get("connectors"), list) or any(not isinstance(row, dict) for row in data["connectors"]):
return _connector_rpc_error(rid, 5034, "INVALID_CONNECTOR_RESPONSE", "Connector service returned an invalid response.")
return _ok(rid, {"available": True, "connectors": connector_ui_payload(data["connectors"])})
if not isinstance(data.get("targets"), list):
return _connector_rpc_error(rid, 5034, "INVALID_CONNECTOR_RESPONSE", "Connector service returned no authorization results.")
return _ok(rid, connector_ui_payload(data))
def _reissue(rid, operation, args):
"""Re-mint links for the named targets on the open operation (user actor)."""
from tools.connectors.contract import Actor, TargetState
from tools.connectors.gateway.client import ConnectorClient
from tools.connectors.managed import mint
from tui_gateway.connector_payload import connector_ui_payload
# Only a dead link is re-minted. A waiting target already holds its link (minted up front);
# the card re-opens that one and never calls here for it.
targets = [operation.target(n) for n in args["connectors"]]
if any(t is None for t in targets):
return _connector_rpc_error(rid, 4004, "UNKNOWN_TARGET", "no such target on the open operation")
stale = [t.name for t in targets if t.state in (TargetState.failed, TargetState.expired)]
if len(stale) != len(targets):
return _connector_rpc_error(rid, 4002, "LINK_STILL_VALID",
"only a failed or expired target can be re-minted; reopen the stored link")
mint(ConnectorClient(), operation, stale, reinitiate=True, actor=Actor.user)
return _ok(rid, connector_ui_payload(_operation_view(operation)))
def _live_operation(rid, params, owner):
from tools.connectors import live
op_id = params.get("op_id")
if not isinstance(op_id, str) or not op_id:
return None, _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "op_id required")
operation = live.get(owner["session_key"], op_id)
if operation is None:
return None, _connector_rpc_error(rid, 4004, "UNKNOWN_OPERATION", "no open operation with that op_id in this session")
return operation, None
@method("connectors.list")
def _(rid, params):
"""{session_id} -> {available, connectors}; unknown fields in the connector metadata are passed through."""
return _connector_rpc(rid, params, "status")
@method("connectors.connect")
def _(rid, params):
"""{session_id, connectors, reconnect?} -> manage_connections' results/summary."""
return _connector_rpc(rid, params, "connect")
@method("connectors.operation.status")
def _(rid, params):
from tui_gateway.connector_payload import connector_ui_payload
owner, error = _owned_session(rid, params)
if error:
return error
operation, error = _live_operation(rid, params, owner)
if error:
return error
return _ok(rid, connector_ui_payload(_operation_view(operation)))
@method("connection.respond")
def _(rid, params):
"""The card's answer for the operation named by ``op_id``: per-target user / renderer-flow
transitions and an optional Continue. The contract decides what the card may claim."""
from tools.connectors import live
from tools.connectors.contract import SettleReason
from tools.connectors.mcp import apply_answer
from tools.connectors.operation import IllegalTransition
owner, error = _owned_session(rid, params)
if error:
return error
operation, error = _live_operation(rid, params, owner)
if error:
return error
try:
apply_answer(operation, json.dumps(params["result"]))
except IllegalTransition as exc:
return _connector_rpc_error(rid, 4002, "ILLEGAL_TRANSITION", str(exc))
if not operation.settled and operation.all_resolved:
operation.settle(SettleReason.all_resolved)
if operation.settled:
live.close(operation)
return _ok(rid, {"status": "ok", "settled": operation.settled})
def _operation_view(operation):
return {**operation.result(), "settled": operation.settled}
def _connection_update(operation, change=None):
"""Emit ``connection.update`` for one transition, a link refresh, or settlement. Every frame
carries the full target snapshot so the renderer never reconstructs state from deltas."""
from tui_gateway import server
with server._sessions_lock:
sid = next((s for s, c in server._sessions.items() if c.get("session_key") == operation.session_key), None)
if sid is None:
return
payload = _operation_view(operation)
if change:
payload.update(change)
server._emit("connection.update", sid, payload)
def _install_update_hook():
"""Route every operation change through ``_connection_update``. Idempotent: ``register`` can run
more than once (reload, tests) and must not stack wrappers."""
from tools.connectors import operation as op_module
if getattr(op_module.ConnectionOperation, "_update_hook_installed", False):
return
op_module.ConnectionOperation._update_hook_installed = True
op_module.ConnectionOperation.on_change = staticmethod(_connection_update)
def register(server):
bind_module(globals(), server, skip=("_",))
server._LONG_HANDLERS = server._LONG_HANDLERS | _CONNECTOR_RPC_METHODS
_install_update_hook()
+12 -11
View File
@@ -695,6 +695,16 @@ def _open_requests(sid: str) -> list[dict]:
return []
def _pending_connection_request_payload(sid: str) -> dict | None:
"""The open connection operation on *sid* as its ``connection.request`` payload, so a client
that missed the event (or restarted) restores the card with the server's deadline."""
from tools.connectors import live
session = _sessions.get(sid)
operation = live.current(str(session.get("session_key") or "")) if session else None
return operation.request_payload() if operation is not None else None
def _pending_approval_request_payload(session_key: str) -> dict | None:
"""Read the oldest unresolved approval in a session, if there is one."""
try:
@@ -1312,16 +1322,6 @@ def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> st
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _connection_request(sid: str, payload: dict) -> str:
"""The ``manage_connections`` approval card: one ``connection`` server request per operation.
The renderer answers with ``{settled_by, targets}``; the tool receives it as JSON text (``''``
when the renderer never answered: deadline, cancel, or a client without a card). The wait is the
operation's own deadline, carried in the payload."""
from tui_gateway import server_requests
timeout = float(payload.get("timeout_seconds") or 120)
result = server_requests.send("connection", sid, params=payload, timeout=timeout)
return json.dumps(result, ensure_ascii=False) if result else ""
def _clarify_timeout_seconds() -> float | None:
"""Clarify wait for the TUI/desktop bridge from the canonical config (gateway/CLI parity); 300s
@@ -2797,7 +2797,8 @@ def _live_session_payload(
}
for key, value in (("inflight", inflight), ("queued", queued),
("pending_approval", _pending_approval_request_payload(str(session.get("session_key") or ""))),
("open_requests", _open_requests(sid))):
("open_requests", _open_requests(sid)),
("pending_connection", _pending_connection_request_payload(sid))):
if value:
payload[key] = value
return _attach_todo_state(payload, session)
+4 -5
View File
@@ -64,12 +64,11 @@ One tool for both kinds of external app. A target is a managed connector (`"gmai
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `manage_connections` | Managed actions: `status`, `connect` (returns a link for the user), `reconnect`, `wait` (blocks until the connectors report connected). MCP actions, for `mcp: true` targets only: `install` a catalog entry, `enable` a disabled configured server, `authorize` (OAuth). MCP actions show an approval card on the desktop and block until the user acts or the deadline passes; every target settles as `connected`, `skipped` or `not_connected`. On surfaces with no card (CLI, TUI, messaging) MCP targets return `unavailable` with the `hermes mcp install <name>` / `hermes mcp login <name>` commands to give the user. Cannot disconnect or revoke an account. | — |
| `manage_connections` | Managed actions: `status`, `connect`, `reconnect` (repairs only what is not connected; `force: true` restarts a working one). MCP actions, for `mcp: true` targets only: `install` a catalog entry, `enable` a disabled configured server, `authorize` (OAuth). On the desktop every action shows a card and blocks until each target is connected, skipped, or the deadline passes; the result lists targets as `connected`, `skipped` or `not_connected` and carries no link. On surfaces with no card (CLI, TUI, messaging) managed targets return a `connect_url` per app for the user to open, and MCP targets return `unavailable` with the `hermes mcp install <name>` / `hermes mcp login <name>` commands. Cannot disconnect or revoke an account. | — |
The deadline for one call is `connections.wait_timeout_seconds` in `config.yaml`
(default 120, floor 5). The backend fixes it when the call starts; reopening the chat
or restarting the desktop never extends it. Managed actions additionally need the
portal sign-in the managed tools use; MCP approvals do not.
The deadline for one call is five minutes, fixed by the backend when the call starts;
reopening the chat or restarting the desktop never extends it. Managed actions additionally
need the portal sign-in the managed tools use; MCP approvals do not.
## `code_execution` toolset
+1 -1
View File
@@ -55,7 +55,7 @@ Or in-session:
| `browser` | `browser_back`, `browser_cdp`, `browser_click`, `browser_console`, `browser_dialog`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Core browser automation. Includes `web_search` as a fallback for quick lookups. `browser_cdp` and `browser_dialog` are gated at runtime — registered only when a CDP endpoint is reachable at session start (via `/browser connect`, `browser.cdp_url` config, Browserbase, or Camofox). `browser_dialog` works together with the `pending_dialogs` and `frame_tree` fields that `browser_snapshot` adds when a CDP supervisor is attached. |
| `clarify` | `clarify` | Ask the user a question when the agent needs clarification. |
| `code_execution` | `execute_code` | Run Python scripts that call Hermes tools programmatically. |
| `connections` | `manage_connections` | Connect the user to apps: managed connector accounts through the Nous gateway, and local MCP servers from the catalog (install / enable / authorize behind an approval card on the desktop). |
| `connections` | `manage_connections` | Connect the user to apps: managed connector accounts through the Nous gateway, and local MCP servers from the catalog. On the desktop every action is a card that blocks until the user settles it; off the desktop managed actions return a connect link per app. |
| `coding` | composite (`file` + `terminal` + `search` + `web` + `skills` + `browser` + `todo` + `memory` + `session_search` + `clarify` + `code_execution` + `delegation` + `vision`) | Coding-focused bundle for software work: file editing, terminal, search, web docs, skills, browser, delegate, and code execution. |
| `cronjob` | `cronjob` | Schedule and manage recurring tasks. |
| `debugging` | composite (`file` + `terminal` + `web`) | Debug bundle — file, process/terminal, web extract/search. |
-12
View File
@@ -878,18 +878,6 @@ The MCP threshold is always capped at the (possibly context-scaled) generic per-
Hermes also flags **provider-side elision**: when an MCP or web tool result embeds its own truncation markers (`...N more items`, `"has_more": true`, "saved to sandbox" notes), a one-line notice is appended to the result warning that the visible data is incomplete and should be paged/fetched before treating any enumeration as complete.
## Connections
```yaml
connections:
wait_timeout_seconds: 120 # floor 5, no ceiling
```
How long one `manage_connections` call may stay open: the managed-connector
`wait`, or the approval card for a local MCP install / enable / authorize on the
desktop. The backend fixes the deadline when the call starts; reopening the chat
or restarting the desktop never extends it.
## Global Toolset Disable
To suppress specific toolsets across the CLI and every gateway platform in one
@@ -167,10 +167,11 @@ 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 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. The same tool also installs, enables and authorizes
`CONNECTION_REQUIRED` error. The `manage_connections` tool lists connectors and
their connection state and starts an authorization: in the desktop app the call
shows a card, blocks until each app is connected or skipped, and reports the
outcomes; elsewhere it returns a connect link per app for the user to open.
Disconnecting an account is done by the user in the Portal. The same tool also installs, enables and authorizes
local MCP servers from the catalog (targets with `mcp: true`), so it is
present whether or not you are signed in; only the managed-connector actions
need the sign-in.