228 lines
8.8 KiB
Python
228 lines
8.8 KiB
Python
"""The one module core code imports for connector dispatch.
|
|
|
|
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.
|
|
``model_tools_connectors`` 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``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import replace as dataclass_replace
|
|
from typing import Any, Callable, Optional, Sequence
|
|
|
|
from tools.tool_gateway.config import connectors_available
|
|
from tools.tool_gateway.errors import GatewayUnavailable, ToolGatewayError
|
|
from tools.tool_gateway.merge import fill_remote_failure, splice_remote_results
|
|
from tools.tool_gateway.names import parse_connector_name, vendor_slug_candidates
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = ["connector_describe", "connector_search_hits", "run_remote"]
|
|
|
|
|
|
def _default_client_factory():
|
|
from tools.tool_gateway.client import ConnectorClient
|
|
|
|
return ConnectorClient()
|
|
|
|
|
|
def connector_search_hits(
|
|
queries: Sequence[dict[str, Any]],
|
|
*,
|
|
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.
|
|
"""
|
|
try:
|
|
available = (availability or connectors_available)()
|
|
if not available or not queries:
|
|
return {}
|
|
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:
|
|
logger.debug("Connector search failed silently (D32): %s", exc)
|
|
return {}
|
|
|
|
|
|
def connector_describe(
|
|
names: Sequence[str],
|
|
*,
|
|
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.
|
|
"""
|
|
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.
|
|
wanted: dict[str, tuple[str, ...]] = {}
|
|
request_slugs: list[str] = []
|
|
for name in names:
|
|
parsed = parse_connector_name(name)
|
|
if parsed is None or parsed.raw in wanted:
|
|
continue
|
|
candidates = vendor_slug_candidates(parsed.connector, parsed.tool)
|
|
wanted[parsed.raw] = candidates
|
|
for slug in candidates:
|
|
if slug not in request_slugs:
|
|
request_slugs.append(slug)
|
|
if not wanted:
|
|
return {}
|
|
client = (client_factory or _default_client_factory)()
|
|
response = client.schemas(request_slugs) or {}
|
|
schemas = response.get("schemas") if isinstance(response.get("schemas"), dict) else {}
|
|
tools: dict[str, Any] = {}
|
|
for composed, candidates in wanted.items():
|
|
schema = next(
|
|
(schemas[slug] for slug in candidates
|
|
if isinstance(schemas.get(slug), dict)),
|
|
None,
|
|
)
|
|
if schema is None:
|
|
continue
|
|
tools[composed] = {
|
|
"description": str(schema.get("description") or ""),
|
|
"parameters": schema.get("input_schema") or {},
|
|
}
|
|
return {"tools": tools}
|
|
except GatewayUnavailable:
|
|
logger.debug("Connector describe skipped: gateway dark")
|
|
return {}
|
|
except Exception as exc:
|
|
logger.debug("Connector describe failed silently (D32): %s", exc)
|
|
return {}
|
|
|
|
|
|
def run_remote(
|
|
planned,
|
|
dispatch_id: Optional[str],
|
|
*,
|
|
availability: Optional[Callable[[], bool]],
|
|
client_factory: Optional[Callable[[], Any]],
|
|
) -> list[dict[str, Any]]:
|
|
try:
|
|
available = (availability or connectors_available)()
|
|
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.
|
|
wire_planned = [
|
|
dataclass_replace(
|
|
plan,
|
|
tool=vendor_slug_candidates(plan.connector, plan.tool)[0],
|
|
)
|
|
for plan in planned
|
|
]
|
|
try:
|
|
client = (client_factory or _default_client_factory)()
|
|
remote_results = client.execute(wire_planned)
|
|
entries = splice_remote_results(planned, remote_results)
|
|
except ToolGatewayError as exc:
|
|
logger.debug(
|
|
"Connector execute for dispatch %s failed (%s): %s",
|
|
dispatch_id,
|
|
exc.code,
|
|
exc,
|
|
)
|
|
return fill_remote_failure(
|
|
planned, f"The connector gateway request failed: {exc}"
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Connector execute for dispatch %s failed unexpectedly: %s",
|
|
dispatch_id,
|
|
exc,
|
|
)
|
|
return fill_remote_failure(
|
|
planned, "The connector gateway request failed unexpectedly."
|
|
)
|
|
|
|
fallback_slots: list[int] = []
|
|
fallback_planned = []
|
|
for slot, (plan, entry) in enumerate(zip(planned, entries)):
|
|
primary, literal = vendor_slug_candidates(plan.connector, plan.tool)
|
|
error = entry.get("error") if isinstance(entry, dict) else None
|
|
if (
|
|
primary != literal
|
|
and isinstance(error, dict)
|
|
and error.get("code") == "TOOL_NOT_FOUND"
|
|
):
|
|
fallback_slots.append(slot)
|
|
fallback_planned.append(dataclass_replace(plan, tool=literal))
|
|
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.
|
|
try:
|
|
fallback_results = client.execute(fallback_planned)
|
|
fallback_entries = splice_remote_results(fallback_planned, fallback_results)
|
|
except ToolGatewayError as exc:
|
|
logger.debug(
|
|
"Connector execute fallback for dispatch %s failed (%s): %s",
|
|
dispatch_id,
|
|
exc.code,
|
|
exc,
|
|
)
|
|
fallback_entries = fill_remote_failure(
|
|
fallback_planned, f"The connector gateway request failed: {exc}"
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Connector execute fallback for dispatch %s failed unexpectedly: %s",
|
|
dispatch_id,
|
|
exc,
|
|
)
|
|
fallback_entries = fill_remote_failure(
|
|
fallback_planned, "The connector gateway request failed unexpectedly."
|
|
)
|
|
for slot, fallback_entry in zip(fallback_slots, fallback_entries):
|
|
entries[slot] = fallback_entry
|
|
return entries
|