Files
hermes-agent/tools/tool_gateway/client.py
Siddharth Balyan 0e927c914d Guided first launch behind HERMES_GUEST_ONBOARDING: intro, guided chat, first task in default (NS-848, PR B1) (#107958)
* feat(desktop): port guided onboarding substrate

Add seeded session creation, transcript directives, profile routing, and the shared window and pane primitives needed by the guided flow. Keep later-step mounts deferred and exclude provider selection and retry machinery.

* refactor(desktop): anti-slop cleanup for substrate

Assemble seed parameters in the existing create helper and use the owning transcript attribute type. Read the guaranteed gateway and connection contracts directly to remove runtime type probes and unchecked assertions.

* test(desktop): create-overrides invariants

Verify that reasoning and title overrides do not select a provider or model. Empty overrides and seeds add no parameters.

* feat(desktop): port first-run cinematic window

Play the cinematic behind the guest onboarding launch flag using bundled Collapse and JetBrains Mono. Give the native window its own controller and restore the app on skip, renderer deadman or native watchdog.

Drop the perf scenario because it depends on the removed replay hook. Guided chat kickoff and app-shell gate wiring remain with their later steps.

* refactor(desktop): anti-slop cleanup for cinematic

Preserve audio and canvas behavior through named types and inferred results. Split the viewport node and frame drawing to keep control flow bounded. Cut comments that only repeat the code.

* feat(desktop): add onboarding gate and answers stores

Track cinematic, guided chat, handoff and completion in one phase record. Queue the guide after the intro and share pending kickoff work between callers.

Keep existing saved answers while dropping retired preferences. Leave intro seen-state ownership with the cinematic store.

* feat(desktop): port guided onboarding chat

Add guided setup cards, runbooks, machine context, and onboarding presence. Connect transcript rendering and first-build progress to the desktop behind the onboarding flag. Leave session kickoff and handoff execution for the next step.

* refactor(desktop): anti-slop cleanup for guided chat

Keep directive and layout lookups typed. Remove unsafe test casts and isolate onboarding transcript calculations without changing the flow.

* feat(desktop): connect guided onboarding to durable first-build handoff

Start the guide only after its profile backend confirms bootstrap readiness. Seed or adopt the welcome chat, then transfer the first build to default with a durable receipt and explicit retry.

Wire cinematic completion, screen stand-down, layout growth and progress check-ins. Save agreed preferences before creating the build and release prompt slots after storage refusal.

* refactor(desktop): anti-slop cleanup for onboarding handoff

Reuse the gateway request and error contracts. Isolate guide adoption and snapshot validation while preserving receipt recovery and reasoning overrides.

Validate persisted receipt fields at the JSON boundary without coercion. Keep corrupt identities rejected and retain only the permitted test mocks.

* fix(desktop): guided chat review fixes

Wire the native machine probe so guided setup can suggest a name and offer the right first task. Restore the comments that explain the flow boundaries.

The directive registration uses the launch flag to preserve ordinary chat. Ruling 6 folds active.ts into assembly to keep activity ownership together and removes the second greeting source so the seeded and visible greetings agree.

* fix(desktop): handoff review fixes

Probe the guide backend before switching profiles so a readiness refusal keeps classic onboarding on the current backend.

Restore list-valued personalization coverage and routing rationale. Remove the obsolete setup status fixture.

* chore(desktop): onboarding script cull and rehearsal recipe

Document a temporary-state rehearsal using the existing onboarding flag and optional portal stand-in. Keep the main scripts unchanged and retain window growth for the guided chat.

* fix(connectors): reject incomplete catalog responses

* feat(gateway): scope connector controls to the owning session

* feat(desktop): connect apps through native session-owned controls

* feat(desktop): gate connector cards and enable free-tier access

Use the launch flag before mounting connector controls so classic transcripts add no status requests. Allow existing free-tier identities through the read-only tool gateway gate and test the owning-profile RPC path with A’s launch gate. Keep authorization links out of previews.

* style(desktop): format connector translations

Apply Prettier to the connector copy blocks while preserving upstream translations and free-tier wording.

* refactor(desktop): anti-slop cleanup for connector card

Use the transcript JSON contract and concrete RPC parameters. Preserve malformed-value filtering at one string boundary and make the fixture and row types explicit. Keep connector execution and cancellation behavior unchanged.

* feat(desktop): detect initial language from the OS

Use the native machine locale when no supported language is saved. Preserve explicit choices and leave inferred languages out of config.

* refactor(desktop): anti-slop cleanup for initial locale detection

Keep unvalidated config values at the existing validation boundary. Pass no saved choice after that boundary has ruled it out, preserving locale precedence.

* test(desktop): onboarding port test set

Make native window tests reject duplicate IPC handlers and isolate disabled onboarding. Assert the active gate mock when onboarding re-enables.

Keep the test set limited to behavior carried by the port.

* fix(desktop): recover failed guide kickoff and reveal once

The review found that a failed guide create stranded the solo shell and draft profile, and solo boot faded an already visible window a second time. Restore the prior route and layout, release onboarding through its existing phase record, and surface create failures. Let the film own the reveal while solo boot animates the visible resize.

* fix(desktop): preserve transcript ownership across cards and handoff

The review reproduced answers submitted to the focused chat, repeated questions disabled across sessions, handoff recovery using foreground identity, and mount-dependent progress history. Target each card’s own composer, scope settlement to its message and session, carry the issuing guide through handoff, and derive progress from its transcript with streaming activity. Reuse the existing owner ladder for exact and profile-only routes.

* fix(gateway): preserve connector ownership with profile routing

The review found that shared-primary profile metadata was rejected before connector dispatch, while desktop controls treated a missing registry id as missing ownership. Accept profile only as routing metadata and keep the live transport as authorization. Resolve card ownership through the existing exact/profile ladder, retaining ambient routing only for the single-backend case.

* fix(desktop): resolve plugin roots and gate the Basic layout

The review found that the first plugin build was seeded with a different installation’s fixed path, and the director ruled that flag-off layouts must match main. Resolve the running desktop’s plugin root before seeding a plugin build and register Basic only when onboarding is enabled. Keep the runbook wording and the ordinary four layout presets intact.

* fix(desktop): clear review-fix slop findings

The slop gate flagged an undocumented layout-data assertion and unknown-return types in the new test selectors. Record the layout registry invariant and preserve each selector’s return type. The only remaining production finding is the accepted connector-tools baseline.

* fix(desktop): detect the OS language on a fresh install

The review found that the merged English config default prevented the
desktop from probing the OS language on a fresh install. Add an opt-in
saved-values read so an absent choice remains distinct from saved English.

Preserve default-valued English only for explicit language saves; unrelated
settings saves must not turn a merged default into a language choice.
Older backends ignore the new query options and keep returning merged
English, preserving their existing desktop behavior.

* test(desktop): make the flag-off layout registry test deterministic

The flag-off test awaited the full controller import, pulling in the UI
graph and installing application watchers just to read layout presets.
That import took 9.5 seconds locally and timed out in the director's run.

Move the existing trees and registration into a small layout-presets
module. Production and the synchronous test use the same flag-gated
registration, without starting the controller in the test. Keep the real
registry invariant and dispose the test's contributions after completion.

* fix(desktop): keep the transcript parser and ::ask behind the onboarding flag

Register the guided chat's question card only with onboarding enabled.
Restore main's whole-paragraph parser and contribution rendering when the
flag is off, including its streaming prose behavior. Keep segmentation for
the guided flow until B4 decides the parser's wider use.

Restore main's two parser test files so its existing product and plugin
contracts remain the flag-off check.

* test: drop the onboarding and connector tests pending a later ticket

Apply the director's ruling to remove B1's added test files and restore
main's existing suites. Keep only the gateway route-reader mock contract
that main's profile tests need against the shipped activation behavior;
their cases and assertions stay intact.

The flow's shape is not settled and B3/B4 rewrite it. The connector layer
will also be reworked. The live CDP run is the flow check until a follow-up
ticket brings tests back.

---------

Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
2026-09-11 15:45:43 +05:30

310 lines
12 KiB
Python

"""HTTP client for the connector routes on the managed tool gateway.
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``.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Callable, Optional, Protocol, Sequence
import requests
from tools.tool_gateway import wire
from tools.tool_gateway.errors import (
GatewayAuthError,
GatewayUnavailable,
ToolGatewayError,
parse_gateway_error,
)
from tools.tool_gateway.merge import PlannedCall
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_TIMEOUT_SECONDS = 30.0
SCHEMAS_TIMEOUT_SECONDS = 10.0
_MAX_RETRIES = 1 # D29: at most one retry, same key.
class Transport(Protocol):
"""The slice of ``requests`` the client uses; tests inject a fake."""
def request(
self,
method: str,
url: str,
*,
headers: Optional[dict] = None,
json: Optional[dict] = None,
timeout: Optional[float] = None,
) -> Any: ...
def _default_transport() -> Transport:
return requests # module satisfies the protocol
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.
"""
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
def _default_header_provider(url: str) -> dict:
from tools.managed_gateway_auth import managed_gateway_auth_headers
return managed_gateway_auth_headers(url)
class ConnectorClient:
"""One dispatch's connection to the gateway's connector routes."""
def __init__(
self,
*,
transport: Optional[Transport] = None,
endpoint_resolver: Optional[Callable[[], Optional[str]]] = None,
header_provider: Optional[Callable[[str], dict]] = None,
) -> None:
self._transport = transport or _default_transport()
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)
payload = self._post(
wire.CONNECTOR_SEARCH_PATH, body,
timeout=SEARCH_TIMEOUT_SECONDS, retries=0,
)
parsed = wire.ConnectorSearchResponse.model_validate(payload)
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
)
payload = self._post(
wire.CONNECTOR_SCHEMAS_PATH, body, timeout=SCHEMAS_TIMEOUT_SECONDS
)
return wire.ConnectorSchemasResponse.model_validate(payload).model_dump()
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."""
body = wire.ConnectorConnectionsRequest(
connectors=list(connectors), reinitiate=reinitiate
).model_dump(by_alias=True)
payload = self._post(wire.CONNECTOR_CONNECTIONS_PATH, body, retries=0)
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
path = f"{wire.CONNECTORS_PATH}?limit=50"
if cursor:
path += f"&cursor={cursor}"
payload = self._request("GET", path, None)
if not isinstance(payload, dict) or "error" in payload:
raise ToolGatewayError("invalid connector list page", code="INVALID_RESPONSE")
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)
cursor = payload.get("nextCursor")
if not cursor:
return items
if not isinstance(cursor, str):
raise ToolGatewayError("invalid connector list cursor", code="INVALID_RESPONSE")
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.
"""
body = wire.ConnectorExecuteRequest(
tools=[
wire.ConnectorExecuteCall(
connector=plan.connector, tool=plan.tool, arguments=plan.arguments
)
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.
idempotency_key = str(uuid.uuid4())
payload = self._post(
wire.CONNECTOR_EXECUTE_PATH,
body,
timeout=EXECUTE_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
parsed = wire.ConnectorExecuteResponse.model_validate(payload)
return [_result_dict(result) for result in parsed.results]
# -- plumbing -------------------------------------------------------
def _post(
self,
path: str,
body: dict[str, Any],
*,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
idempotency_key: Optional[str] = None,
retries: int = _MAX_RETRIES,
) -> Any:
return self._request(
"POST", path, body,
timeout=timeout, idempotency_key=idempotency_key, retries=retries,
)
def _request(
self,
method: str,
path: str,
body: Optional[dict[str, Any]],
*,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
idempotency_key: Optional[str] = None,
retries: int = _MAX_RETRIES,
) -> Any:
origin = self._endpoint_resolver()
if not origin:
raise GatewayUnavailable(
"no tool gateway origin resolves", code="NO_ORIGIN"
)
url = f"{origin.rstrip('/')}/{path}"
last_error: Optional[ToolGatewayError] = None
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
)
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["x-idempotency-key"] = idempotency_key
try:
response = self._transport.request(
method, url, headers=headers, json=body, timeout=timeout
)
except Exception as exc:
last_error = ToolGatewayError(
f"transport failure: {exc}", code="TRANSPORT_ERROR", retryable=True
)
logger.debug(
"Connector %s attempt %d transport failure: %s", path, attempt + 1, exc
)
continue
status = int(getattr(response, "status_code", 0))
if 200 <= status < 300:
return response.json()
error = parse_gateway_error(status, _safe_json(response))
if error.retryable and attempt < retries:
last_error = error
logger.debug(
"Connector %s attempt %d got %d; retrying with same key",
path,
attempt + 1,
status,
)
continue
raise error
assert last_error is not None # loop ran at least once
raise last_error
def _result_dict(result: wire.ConnectorExecuteResult) -> dict[str, Any]:
error = None
if result.error is not None:
error = {"code": result.error.code, "message": result.error.message}
if result.error.connector:
error["connector"] = result.error.connector
if result.error.connect_url:
error["connect_url"] = result.error.connect_url
if result.error.hint:
error["hint"] = result.error.hint
return {"data": result.data, "error": error}
def _safe_json(response: Any) -> Any:
try:
return response.json()
except Exception:
return getattr(response, "text", None)