mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting the Ink/npm flow unconditionally; with the dual-engine dispatch merged in, _resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built in the repo, routing the call away from the path under test (first subprocess became 'node --version' instead of 'npm run build'). Pin the engine to ink via an autouse fixture, mirroring the existing pinning precedent in test_tui_resume_flow.py.
This commit is contained in:
parent
ab37440ce6
commit
e1067dbbe5
756 changed files with 79874 additions and 19585 deletions
|
|
@ -86,6 +86,7 @@ class AudioBridge:
|
|||
["pactl", "unload-module", str(mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort teardown — never raise from here.
|
||||
|
|
@ -111,6 +112,7 @@ class AudioBridge:
|
|||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
|
|
@ -135,6 +137,7 @@ class AudioBridge:
|
|||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Roll back the null-sink we just created so we don't leak it.
|
||||
|
|
@ -142,6 +145,7 @@ class AudioBridge:
|
|||
["pactl", "unload-module", str(sink_mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
|
|||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=effective_cwd, env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
stdout = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
|
|
|||
|
|
@ -695,6 +695,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
subprocess.run(
|
||||
[uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install,
|
||||
check=True, timeout=120, capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
print(" ✓ Dependencies up to date")
|
||||
except Exception as e:
|
||||
|
|
@ -1101,6 +1102,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
[uv_path, "pip", "install", "--python", sys.executable,
|
||||
"--quiet", "--upgrade", f"hindsight-client>={_MIN_CLIENT_VERSION}"],
|
||||
check=True, timeout=120, capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ def _ensure_sdk_installed() -> bool:
|
|||
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(" Installed.\n")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from pathlib import Path
|
|||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.profiles import _get_default_hermes_home
|
||||
from plugins.plugin_utils import SingletonSlot
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -627,6 +628,7 @@ class HonchoClientConfig:
|
|||
root = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True, text=True, cwd=cwd, timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if root.returncode == 0:
|
||||
return Path(root.stdout.strip()).name
|
||||
|
|
@ -737,7 +739,7 @@ class HonchoClientConfig:
|
|||
return self.workspace_id
|
||||
|
||||
|
||||
_honcho_client: Honcho | None = None
|
||||
_honcho_client_slot: SingletonSlot = SingletonSlot()
|
||||
|
||||
|
||||
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
|
|
@ -745,11 +747,14 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
|
||||
When no config is provided, attempts to load ~/.honcho/config.json
|
||||
first, falling back to environment variables.
|
||||
"""
|
||||
global _honcho_client
|
||||
|
||||
if _honcho_client is not None:
|
||||
return _honcho_client
|
||||
Thread-safe: the client is built exactly once even under concurrent
|
||||
first calls (double-checked locking via ``SingletonSlot``), so racing
|
||||
threads can't each construct a client and leak the loser's connection.
|
||||
"""
|
||||
cached = _honcho_client_slot.peek()
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if config is None:
|
||||
config = HonchoClientConfig.from_global_config()
|
||||
|
|
@ -762,111 +767,116 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
"For local instances, set HONCHO_BASE_URL instead."
|
||||
)
|
||||
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true). On failure we surface
|
||||
# the original ImportError-shape message so existing callers still get
|
||||
# the "go run hermes honcho setup" hint they used to.
|
||||
try:
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.honcho", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module missing — fall through to the raw import below.
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable or unexpected error. Don't crash here; let the
|
||||
# actual import attempt produce the canonical error message.
|
||||
pass
|
||||
|
||||
try:
|
||||
from honcho import Honcho
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
"Install it with: pip install honcho-ai "
|
||||
"(or run `hermes honcho setup` to configure)."
|
||||
)
|
||||
|
||||
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
# Everything below is the expensive part the issue flags: lazy SDK
|
||||
# install, config resolution, and client construction. Run it inside the
|
||||
# slot's factory so it executes exactly once even when several threads
|
||||
# race the first call — the slot's double-checked lock serializes them and
|
||||
# the losers get the winner's client instead of building their own.
|
||||
def _build() -> "Honcho":
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true). On failure we surface
|
||||
# the original ImportError-shape message so existing callers still get
|
||||
# the "go run hermes honcho setup" hint they used to.
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.honcho", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module missing — fall through to the raw import below.
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable or unexpected error. Don't crash here; let the
|
||||
# actual import attempt produce the canonical error message.
|
||||
pass
|
||||
|
||||
# Fall back to the default so an unconfigured install cannot hang
|
||||
# indefinitely on a stalled Honcho request.
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _DEFAULT_HTTP_TIMEOUT
|
||||
try:
|
||||
from honcho import Honcho
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
"Install it with: pip install honcho-ai "
|
||||
"(or run `hermes honcho setup` to configure)."
|
||||
)
|
||||
|
||||
if resolved_base_url:
|
||||
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
# For local: only use config.api_key if the host block explicitly
|
||||
# sets apiKey (meaning the user wants local auth). Otherwise skip
|
||||
# the stored key -- it's likely a cloud key that would break local.
|
||||
_is_local = resolved_base_url and (
|
||||
"localhost" in resolved_base_url
|
||||
or "127.0.0.1" in resolved_base_url
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
effective_api_key = config.api_key if _host_has_key else "local"
|
||||
else:
|
||||
effective_api_key = config.api_key
|
||||
# Fall back to the default so an unconfigured install cannot hang
|
||||
# indefinitely on a stalled Honcho request.
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _DEFAULT_HTTP_TIMEOUT
|
||||
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
if resolved_base_url:
|
||||
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
"environment": config.environment,
|
||||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
# For local: only use config.api_key if the host block explicitly
|
||||
# sets apiKey (meaning the user wants local auth). Otherwise skip
|
||||
# the stored key -- it's likely a cloud key that would break local.
|
||||
_is_local = resolved_base_url and (
|
||||
"localhost" in resolved_base_url
|
||||
or "127.0.0.1" in resolved_base_url
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
effective_api_key = config.api_key if _host_has_key else "local"
|
||||
else:
|
||||
effective_api_key = config.api_key
|
||||
|
||||
_honcho_client = Honcho(**kwargs)
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
|
||||
return _honcho_client
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
"environment": config.environment,
|
||||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
|
||||
return Honcho(**kwargs)
|
||||
|
||||
return _honcho_client_slot.get(_build)
|
||||
|
||||
|
||||
def reset_honcho_client() -> None:
|
||||
"""Reset the Honcho client singleton (useful for testing)."""
|
||||
global _honcho_client
|
||||
_honcho_client = None
|
||||
_honcho_client_slot.reset()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,39 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_CACHE: list[str] | None = None
|
||||
|
||||
# Anthropic model families that still accept an explicit "disable thinking"
|
||||
# request (the manual ``thinking: {type: "disabled"}`` form OpenRouter emits
|
||||
# for ``reasoning: {enabled: false}``). Everything Claude 4.6 and newer —
|
||||
# including future date-stamped / named models (fable, mythos-class, …) —
|
||||
# mandates reasoning and returns HTTP 400 on any disable form. We therefore
|
||||
# default *unknown* Anthropic models to "cannot disable" (the modern contract)
|
||||
# and keep only this explicit legacy allowlist of models that can. Mirrors the
|
||||
# default-to-newest philosophy in agent/anthropic_adapter._get_anthropic_max_output.
|
||||
_ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS = (
|
||||
"claude-3", # 3, 3.5, 3.7
|
||||
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
|
||||
"claude-sonnet-4-0", "claude-sonnet-4.0",
|
||||
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
|
||||
"claude-opus-4-5", "claude-opus-4.5",
|
||||
"claude-sonnet-4-5", "claude-sonnet-4.5",
|
||||
"claude-haiku-4-5", "claude-haiku-4.5",
|
||||
)
|
||||
|
||||
|
||||
def _anthropic_reasoning_is_mandatory(model: str | None) -> bool:
|
||||
"""Return True for Anthropic models that reject any disable-thinking form.
|
||||
|
||||
Claude 4.6+ (adaptive thinking) and newer named models have no "off"
|
||||
switch — sending ``reasoning: {enabled: false}`` makes OpenRouter emit
|
||||
``thinking: {type: "disabled"}``, which these models 400 on. Unknown /
|
||||
new Anthropic model names default to mandatory so the next un-numbered
|
||||
release doesn't reintroduce the 400.
|
||||
"""
|
||||
m = (model or "").lower()
|
||||
if not m.startswith(("anthropic/", "claude")) and "claude" not in m:
|
||||
return False
|
||||
return not any(sub in m for sub in _ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS)
|
||||
|
||||
|
||||
class OpenRouterProfile(ProviderProfile):
|
||||
"""OpenRouter aggregator — provider preferences, reasoning config passthrough."""
|
||||
|
|
@ -90,17 +123,54 @@ class OpenRouterProfile(ProviderProfile):
|
|||
the same backend server across turns.
|
||||
"""
|
||||
extra_body: dict[str, Any] = {}
|
||||
top_level: dict[str, Any] = {}
|
||||
extra_headers: dict[str, Any] = {}
|
||||
if supports_reasoning:
|
||||
if reasoning_config is not None:
|
||||
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
|
||||
# future named models) use *adaptive* thinking: the model decides
|
||||
# how much to think, and OpenRouter ignores ``reasoning.effort`` for
|
||||
# them entirely. Sending any ``reasoning`` field is therefore both
|
||||
# pointless and actively harmful:
|
||||
# - ``{enabled: false}`` → OpenRouter emits Anthropic's manual
|
||||
# ``thinking: {type: "disabled"}``, which these models 400 on.
|
||||
# - any enabled form, on a tool-continuation turn whose prior
|
||||
# assistant tool_call carries no thinking block (chat_completions
|
||||
# never replays signed thinking blocks), ALSO makes OpenRouter
|
||||
# emit ``thinking: {type: "disabled"}`` → the same 400 on every
|
||||
# turn after the first tool call.
|
||||
# The only reliable behavior is to omit ``reasoning`` and let the
|
||||
# model default to adaptive. See hermes-agent#42991 (disable case)
|
||||
# and the tool-replay follow-up.
|
||||
#
|
||||
# ``reasoning.effort`` being ignored does NOT mean these models have
|
||||
# no effort lever — OpenRouter honors the requested effort on the
|
||||
# top-level ``verbosity`` field instead (it maps to Anthropic's
|
||||
# ``output_config.effort``; ``reasoning.effort`` is accepted but
|
||||
# ignored — confirmed by OpenRouter's Claude migration docs and a
|
||||
# live token-spend probe in hermes-agent#43432). Route the existing
|
||||
# ``reasoning_config["effort"]`` (sourced from
|
||||
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
|
||||
# already sets keeps working for these models. We still send NO
|
||||
# ``reasoning`` field, preserving the #42991 400 fix.
|
||||
if _anthropic_reasoning_is_mandatory(model):
|
||||
cfg = reasoning_config or {}
|
||||
effort = cfg.get("effort")
|
||||
# Only emit when effort is actually requested and reasoning
|
||||
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
|
||||
# model keeps its own adaptive default (``high``).
|
||||
if cfg.get("enabled", True) is not False and effort and effort != "none":
|
||||
top_level["verbosity"] = effort
|
||||
elif reasoning_config is not None:
|
||||
extra_body["reasoning"] = dict(reasoning_config)
|
||||
else:
|
||||
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
|
||||
|
||||
extra_headers: dict[str, Any] = {}
|
||||
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
|
||||
extra_headers["x-grok-conv-id"] = session_id
|
||||
if extra_headers:
|
||||
top_level["extra_headers"] = extra_headers
|
||||
|
||||
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
|
||||
return extra_body, top_level
|
||||
|
||||
|
||||
openrouter = OpenRouterProfile(
|
||||
|
|
|
|||
|
|
@ -227,7 +227,30 @@ def _trace_key(task_id: str, session_id: str) -> str:
|
|||
return f"thread:{threading.get_ident()}"
|
||||
|
||||
|
||||
def _truncate_text(value: str, max_chars: int) -> str:
|
||||
def _is_base64_data_uri(value: str) -> bool:
|
||||
prefix = value[:200].lower()
|
||||
return prefix.startswith("data:") and ";base64," in prefix
|
||||
|
||||
|
||||
def _redact_data_uri(value: str) -> dict[str, Any]:
|
||||
header = value.split(",", 1)[0] if "," in value else "data:"
|
||||
media_type = header[5:].split(";", 1)[0] if header.startswith("data:") else ""
|
||||
return {
|
||||
"type": "data_uri",
|
||||
"media_type": media_type or None,
|
||||
"omitted": True,
|
||||
"length": len(value),
|
||||
}
|
||||
|
||||
|
||||
def _truncate_text(value: str, max_chars: int) -> Any:
|
||||
# Langfuse SDK treats data:*;base64 strings as media and attempts to
|
||||
# decode them. Truncating those strings produces invalid base64 and noisy
|
||||
# "Error parsing base64 data URI" logs. Observability only needs metadata,
|
||||
# not raw image/audio payloads, so redact the whole data URI before it
|
||||
# reaches the SDK.
|
||||
if _is_base64_data_uri(value):
|
||||
return _redact_data_uri(value)
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
|
||||
|
|
@ -837,8 +860,16 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str =
|
|||
if output.get("tool_calls"):
|
||||
state.turn_tool_calls.extend(output["tool_calls"])
|
||||
|
||||
# Extract usage: prefer response object, fall back to usage dict from post_api_request
|
||||
if response is not None:
|
||||
# Extract usage: prefer a real response object that carries usage, else
|
||||
# fall back to the usage summary dict from post_api_request.
|
||||
#
|
||||
# post_api_request passes `response` as a SANITIZED dict (no ``.usage``
|
||||
# attribute) alongside a separate `usage` summary dict. Gating on
|
||||
# ``response is not None`` here took the response-object path on that dict,
|
||||
# where ``getattr(response, "usage", None)`` is always None — so usage and
|
||||
# cost were silently dropped for every gateway turn. Gate on a real
|
||||
# ``.usage`` attribute instead so the usage-dict fallback below is reached.
|
||||
if getattr(response, "usage", None) is not None:
|
||||
usage_details, cost_details = _usage_and_cost(
|
||||
response,
|
||||
provider=provider,
|
||||
|
|
|
|||
|
|
@ -163,7 +163,11 @@ agent_version = "local"
|
|||
|
||||
When `HERMES_NEMO_RELAY_PLUGINS_TOML` is set and initializes successfully, NeMo
|
||||
Relay owns exporter lifecycle through that config. The direct
|
||||
`HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped.
|
||||
`HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped. If the same
|
||||
`plugins.toml` observability config enables `atif`, the direct
|
||||
`HERMES_NEMO_RELAY_ATIF_*` fallback setup is also skipped so Hermes does not
|
||||
double-export trajectories on teardown. If `plugins.toml` initialization fails,
|
||||
Hermes keeps the direct env-var fallbacks active for that run.
|
||||
|
||||
To enable NeMo Relay managed execution intercepts for provider and tool calls,
|
||||
include an adaptive component in the same `plugins.toml`:
|
||||
|
|
@ -173,8 +177,8 @@ include an adaptive component in the same `plugins.toml`:
|
|||
kind = "adaptive"
|
||||
enabled = true
|
||||
|
||||
[components.config]
|
||||
mode = "route"
|
||||
[components.config.tool_parallelism]
|
||||
mode = "observe_only"
|
||||
```
|
||||
|
||||
When the adaptive component is enabled and the installed NeMo Relay runtime
|
||||
|
|
@ -182,15 +186,16 @@ exposes `llm.execute(...)` / `tools.execute(...)`, Hermes routes LLM and tool
|
|||
execution through those middleware boundaries. The observer hooks still emit
|
||||
session, turn, approval, and subagent marks; the plugin skips its manual
|
||||
`llm.call` and `tools.call` spans for executions that are already managed by
|
||||
NeMo Relay.
|
||||
NeMo Relay. `tool_parallelism.mode = "observe_only"` keeps tool scheduling
|
||||
observational while still wrapping the real execution boundary.
|
||||
|
||||
For the full generic Hermes middleware contract, see
|
||||
[`docs/middleware/README.md`](../../../docs/middleware/README.md).
|
||||
|
||||
## Canonical Local Examples
|
||||
|
||||
The examples below use the official `nemo-relay==0.3` distribution and a local
|
||||
Ollama model served through the OpenAI-compatible API.
|
||||
The observe-only examples in this section use the official `nemo-relay==0.3`
|
||||
distribution and a local Ollama model served through the OpenAI-compatible API.
|
||||
|
||||
```bash
|
||||
pip install "nemo-relay==0.3"
|
||||
|
|
@ -404,8 +409,8 @@ version = 1
|
|||
kind = "adaptive"
|
||||
enabled = true
|
||||
|
||||
[components.config]
|
||||
mode = "route"
|
||||
[components.config.tool_parallelism]
|
||||
mode = "observe_only"
|
||||
```
|
||||
|
||||
Enable it for Hermes:
|
||||
|
|
@ -438,11 +443,12 @@ for the same execution.
|
|||
### Local Adaptive E2E
|
||||
|
||||
This example enables both NeMo Relay observability export and adaptive execution
|
||||
middleware for a local Hermes run.
|
||||
middleware for a local Hermes run. This path requires a NeMo Relay runtime that
|
||||
supports `[components.config.tool_parallelism]`; the `nemo-relay==0.3`
|
||||
install used by the earlier observability-only examples does not support this
|
||||
adaptive config.
|
||||
|
||||
```bash
|
||||
pip install "nemo-relay==0.3"
|
||||
|
||||
export HERMES_HOME=/tmp/hermes-middleware-test/hermes-home
|
||||
mkdir -p "$HERMES_HOME" /tmp/hermes-middleware-test/nemo-relay
|
||||
|
||||
|
|
@ -484,8 +490,8 @@ agent_version = "local"
|
|||
kind = "adaptive"
|
||||
enabled = true
|
||||
|
||||
[components.config]
|
||||
mode = "route"
|
||||
[components.config.tool_parallelism]
|
||||
mode = "observe_only"
|
||||
TOML
|
||||
|
||||
export HERMES_NEMO_RELAY_PLUGINS_TOML=/tmp/hermes-middleware-test/nemo-relay/plugins.toml
|
||||
|
|
@ -510,8 +516,8 @@ middleware_execution_ok
|
|||
Expected ATOF shape:
|
||||
|
||||
```jsonl
|
||||
{"kind":"scope","category":"llm","name":"custom","scope_category":"start","metadata":{"session_id":"middleware-demo-session"},"data":{"mode":"route"}}
|
||||
{"kind":"scope","category":"tool","name":"terminal","scope_category":"start","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal"},"data":{"mode":"route"}}
|
||||
{"kind":"scope","category":"llm","name":"custom","scope_category":"start","metadata":{"session_id":"middleware-demo-session"},"data":{"mode":"observe_only"}}
|
||||
{"kind":"scope","category":"tool","name":"terminal","scope_category":"start","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal"},"data":{"mode":"observe_only"}}
|
||||
{"kind":"scope","category":"tool","name":"terminal","scope_category":"end","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal","status":"ok"},"data":"{\"output\":\"middleware_execution_ok\",\"exit_code\":0,\"error\":null}"}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import logging
|
|||
import os
|
||||
import threading
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
|
@ -44,7 +45,7 @@ class _Settings:
|
|||
plugins_toml_path: str = ""
|
||||
plugins_config: dict[str, Any] | None = None
|
||||
adaptive_enabled: bool = False
|
||||
adaptive_mode: str = "observe"
|
||||
adaptive_mode: str = "observe_only"
|
||||
atof_enabled: bool = False
|
||||
atof_output_directory: str = ""
|
||||
atof_filename: str = "hermes-atof.jsonl"
|
||||
|
|
@ -65,9 +66,11 @@ class _Runtime:
|
|||
self.sessions: dict[str, _SessionState] = {}
|
||||
self.subagent_parents: dict[str, _SubagentParent] = {}
|
||||
self.atof_exporter: Any = None
|
||||
self._atof_subscriber_name = "hermes.nemo_relay.atof"
|
||||
self._plugin_config_initialized = self._configure_plugins_toml()
|
||||
self._plugin_config_needs_reinit = False
|
||||
if not self._plugin_config_initialized:
|
||||
self._configure_atof()
|
||||
self._activate_direct_fallbacks()
|
||||
|
||||
def _configure_plugins_toml(self) -> bool:
|
||||
if not self.settings.plugins_config:
|
||||
|
|
@ -78,17 +81,45 @@ class _Runtime:
|
|||
return False
|
||||
try:
|
||||
self._ensure_plugin_config_output_dirs(self.settings.plugins_config)
|
||||
result = initialize(self.settings.plugins_config)
|
||||
if inspect.isawaitable(result):
|
||||
asyncio.run(result)
|
||||
_resolve_awaitable(initialize(self.settings.plugins_config))
|
||||
return True
|
||||
except RuntimeError:
|
||||
logger.debug("NeMo Relay plugins.toml init skipped inside a running event loop")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.debug("NeMo Relay plugins.toml init failed: %s", exc, exc_info=True)
|
||||
return False
|
||||
|
||||
def _clear_plugins_toml(self) -> None:
|
||||
if not self._plugin_config_initialized:
|
||||
return
|
||||
plugin_mod = getattr(self.nemo_relay, "plugin", None)
|
||||
clear = getattr(plugin_mod, "clear", None)
|
||||
if not callable(clear):
|
||||
return
|
||||
try:
|
||||
_resolve_awaitable(clear())
|
||||
finally:
|
||||
self._plugin_config_initialized = False
|
||||
self._plugin_config_needs_reinit = bool(self.settings.plugins_config)
|
||||
|
||||
def _activate_direct_fallbacks(self) -> None:
|
||||
self._plugin_config_needs_reinit = False
|
||||
self._configure_atof()
|
||||
|
||||
def _maybe_reinitialize_plugins_toml(self) -> None:
|
||||
if not self._plugin_config_needs_reinit or self._plugin_config_initialized:
|
||||
return
|
||||
self._plugin_config_initialized = self._configure_plugins_toml()
|
||||
if not self._plugin_config_initialized:
|
||||
self._activate_direct_fallbacks()
|
||||
return
|
||||
self._clear_atof()
|
||||
self._plugin_config_needs_reinit = False
|
||||
|
||||
def _plugins_toml_owns_exporter(self, exporter_name: str) -> bool:
|
||||
return self._plugin_config_initialized and _observability_exporter_enabled(
|
||||
self.settings.plugins_config,
|
||||
exporter_name,
|
||||
)
|
||||
|
||||
def _ensure_plugin_config_output_dirs(self, config: dict[str, Any]) -> None:
|
||||
for component in config.get("components", []):
|
||||
if not isinstance(component, dict):
|
||||
|
|
@ -109,7 +140,7 @@ class _Runtime:
|
|||
Path(output_directory).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _configure_atof(self) -> None:
|
||||
if not self.settings.atof_enabled:
|
||||
if not self.settings.atof_enabled or self.atof_exporter is not None:
|
||||
return
|
||||
config = self.nemo_relay.AtofExporterConfig()
|
||||
if self.settings.atof_output_directory:
|
||||
|
|
@ -121,16 +152,28 @@ class _Runtime:
|
|||
else:
|
||||
config.mode = self.nemo_relay.AtofExporterMode.Append
|
||||
self.atof_exporter = self.nemo_relay.AtofExporter(config)
|
||||
self.atof_exporter.register("hermes.nemo_relay.atof")
|
||||
self.atof_exporter.register(self._atof_subscriber_name)
|
||||
|
||||
def _clear_atof(self) -> None:
|
||||
if self.atof_exporter is None:
|
||||
return
|
||||
deregister = getattr(self.atof_exporter, "deregister", None)
|
||||
if callable(deregister):
|
||||
try:
|
||||
deregister(self._atof_subscriber_name)
|
||||
except Exception:
|
||||
logger.debug("NeMo Relay ATOF deregister failed", exc_info=True)
|
||||
self.atof_exporter = None
|
||||
|
||||
def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState:
|
||||
self._maybe_reinitialize_plugins_toml()
|
||||
session_id = _session_id(kwargs)
|
||||
state = self.sessions.get(session_id)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = _SessionState(session_id=session_id)
|
||||
if self.settings.atif_enabled:
|
||||
if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"):
|
||||
state.atif_exporter = self.nemo_relay.AtifExporter(
|
||||
session_id,
|
||||
self.settings.atif_agent_name,
|
||||
|
|
@ -189,6 +232,13 @@ class _Runtime:
|
|||
state.atif_exporter.deregister(state.atif_subscriber_name)
|
||||
except Exception:
|
||||
logger.debug("NeMo Relay ATIF deregister failed", exc_info=True)
|
||||
if self._plugin_config_initialized and not self.sessions:
|
||||
try:
|
||||
self._clear_plugins_toml()
|
||||
except Exception:
|
||||
logger.debug("NeMo Relay plugins.toml clear failed", exc_info=True)
|
||||
elif self.settings.plugins_config and not self.sessions:
|
||||
self._plugin_config_needs_reinit = True
|
||||
|
||||
def mark(self, name: str, kwargs: dict[str, Any]) -> None:
|
||||
state = self.ensure_session(kwargs)
|
||||
|
|
@ -235,6 +285,43 @@ class _Runtime:
|
|||
and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None))
|
||||
)
|
||||
|
||||
def _run_managed_with_downstream_preservation(
|
||||
self,
|
||||
next_call: Callable[[Any], Any],
|
||||
normalize_payload: Callable[[Any], Any],
|
||||
shape_response: Callable[[Any], Any],
|
||||
make_managed_execute: Callable[[Callable[[Any], Any]], Any],
|
||||
) -> Any:
|
||||
# NeMo Relay's native managed execution may wrap a failing callback as an
|
||||
# internal runtime error, hiding the real downstream provider/tool
|
||||
# exception. Capture the original here and re-raise it after managed
|
||||
# execution so Hermes retry classification still sees it. The LLM and tool
|
||||
# paths share this scaffolding; they differ only in payload normalization,
|
||||
# response shaping, and the Relay call itself.
|
||||
raw_response: dict[str, Any] = {"set": False, "value": None}
|
||||
callback_error: Exception | None = None
|
||||
downstream_error: BaseException | None = None
|
||||
|
||||
def _impl(next_payload: Any) -> Any:
|
||||
nonlocal callback_error, downstream_error
|
||||
try:
|
||||
raw = next_call(normalize_payload(next_payload))
|
||||
except Exception as exc:
|
||||
callback_error = exc
|
||||
downstream_error = _original_downstream_error(exc)
|
||||
raise
|
||||
raw_response["set"] = True
|
||||
raw_response["value"] = raw
|
||||
return shape_response(raw)
|
||||
|
||||
try:
|
||||
managed_result = _resolve_awaitable(make_managed_execute(_impl))
|
||||
except Exception as exc:
|
||||
if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error):
|
||||
raise downstream_error
|
||||
raise
|
||||
return raw_response["value"] if raw_response["set"] else managed_result
|
||||
|
||||
def execute_llm(self, kwargs: dict[str, Any]) -> Any:
|
||||
state = self.ensure_session(kwargs)
|
||||
request_body = _jsonable(kwargs.get("request") or {})
|
||||
|
|
@ -243,38 +330,37 @@ class _Runtime:
|
|||
if not callable(next_call):
|
||||
return request_body
|
||||
|
||||
raw_response: dict[str, Any] = {"set": False, "value": None}
|
||||
|
||||
def _impl(next_request: Any) -> Any:
|
||||
def _normalize(next_request: Any) -> Any:
|
||||
next_body = getattr(next_request, "content", next_request)
|
||||
raw = next_call(next_body if isinstance(next_body, dict) else request_body)
|
||||
raw_response["set"] = True
|
||||
raw_response["value"] = raw
|
||||
return _llm_response_payload(raw)
|
||||
return next_body if isinstance(next_body, dict) else request_body
|
||||
|
||||
async def _managed_execute() -> Any:
|
||||
result = self.nemo_relay.llm.execute(
|
||||
str(kwargs.get("provider") or "llm"),
|
||||
request,
|
||||
_impl,
|
||||
handle=state.handle,
|
||||
data=_jsonable(
|
||||
{
|
||||
"turn_id": kwargs.get("turn_id"),
|
||||
"api_request_id": kwargs.get("api_request_id"),
|
||||
"api_call_count": kwargs.get("api_call_count"),
|
||||
"mode": self.settings.adaptive_mode,
|
||||
}
|
||||
),
|
||||
metadata=_metadata(kwargs),
|
||||
model_name=str(kwargs.get("model") or ""),
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
def _make_managed(impl: Callable[[Any], Any]) -> Any:
|
||||
async def _managed_execute() -> Any:
|
||||
result = self.nemo_relay.llm.execute(
|
||||
str(kwargs.get("provider") or "llm"),
|
||||
request,
|
||||
impl,
|
||||
handle=state.handle,
|
||||
data=_jsonable(
|
||||
{
|
||||
"turn_id": kwargs.get("turn_id"),
|
||||
"api_request_id": kwargs.get("api_request_id"),
|
||||
"api_call_count": kwargs.get("api_call_count"),
|
||||
"mode": self.settings.adaptive_mode,
|
||||
}
|
||||
),
|
||||
metadata=_metadata(kwargs),
|
||||
model_name=str(kwargs.get("model") or ""),
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
managed_result = _resolve_awaitable(_managed_execute())
|
||||
return raw_response["value"] if raw_response["set"] else managed_result
|
||||
return _managed_execute()
|
||||
|
||||
return self._run_managed_with_downstream_preservation(
|
||||
next_call, _normalize, _llm_response_payload, _make_managed
|
||||
)
|
||||
|
||||
def execute_tool(self, kwargs: dict[str, Any]) -> Any:
|
||||
state = self.ensure_session(kwargs)
|
||||
|
|
@ -284,37 +370,35 @@ class _Runtime:
|
|||
if not callable(next_call):
|
||||
return args
|
||||
|
||||
raw_response: dict[str, Any] = {"set": False, "value": None}
|
||||
def _normalize(next_args: Any) -> Any:
|
||||
return next_args if isinstance(next_args, dict) else args
|
||||
|
||||
def _impl(next_args: Any) -> Any:
|
||||
effective_args = next_args if isinstance(next_args, dict) else args
|
||||
raw = next_call(effective_args)
|
||||
raw_response["set"] = True
|
||||
raw_response["value"] = raw
|
||||
return _jsonable(raw)
|
||||
def _make_managed(impl: Callable[[Any], Any]) -> Any:
|
||||
async def _managed_execute() -> Any:
|
||||
result = self.nemo_relay.tools.execute(
|
||||
tool_name,
|
||||
args,
|
||||
impl,
|
||||
handle=state.handle,
|
||||
data=_jsonable(
|
||||
{
|
||||
"turn_id": kwargs.get("turn_id"),
|
||||
"api_request_id": kwargs.get("api_request_id"),
|
||||
"tool_call_id": kwargs.get("tool_call_id"),
|
||||
"mode": self.settings.adaptive_mode,
|
||||
}
|
||||
),
|
||||
metadata=_metadata(kwargs),
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
async def _managed_execute() -> Any:
|
||||
result = self.nemo_relay.tools.execute(
|
||||
tool_name,
|
||||
args,
|
||||
_impl,
|
||||
handle=state.handle,
|
||||
data=_jsonable(
|
||||
{
|
||||
"turn_id": kwargs.get("turn_id"),
|
||||
"api_request_id": kwargs.get("api_request_id"),
|
||||
"tool_call_id": kwargs.get("tool_call_id"),
|
||||
"mode": self.settings.adaptive_mode,
|
||||
}
|
||||
),
|
||||
metadata=_metadata(kwargs),
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
return _managed_execute()
|
||||
|
||||
managed_result = _resolve_awaitable(_managed_execute())
|
||||
return raw_response["value"] if raw_response["set"] else managed_result
|
||||
return self._run_managed_with_downstream_preservation(
|
||||
next_call, _normalize, _jsonable, _make_managed
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
|
|
@ -611,11 +695,29 @@ def _enabled_component_config(
|
|||
|
||||
def _adaptive_mode(config: dict[str, Any] | None) -> str:
|
||||
if not isinstance(config, dict):
|
||||
return "observe"
|
||||
return "observe_only"
|
||||
tool_parallelism = config.get("tool_parallelism")
|
||||
if isinstance(tool_parallelism, dict):
|
||||
mode = tool_parallelism.get("mode")
|
||||
if isinstance(mode, str) and mode.strip():
|
||||
return mode.strip()
|
||||
mode = config.get("mode")
|
||||
if isinstance(mode, str) and mode.strip():
|
||||
return mode.strip()
|
||||
return "observe"
|
||||
return "observe_only"
|
||||
|
||||
|
||||
def _observability_exporter_enabled(
|
||||
plugins_config: dict[str, Any] | None,
|
||||
exporter_name: str,
|
||||
) -> bool:
|
||||
observability_config = _enabled_component_config(plugins_config, "observability")
|
||||
if not isinstance(observability_config, dict):
|
||||
return False
|
||||
exporter_config = observability_config.get(exporter_name)
|
||||
if not isinstance(exporter_config, dict):
|
||||
return False
|
||||
return exporter_config.get("enabled", True) is not False
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
|
|
@ -739,6 +841,30 @@ def _value(obj: Any, key: str, default: Any = None) -> Any:
|
|||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def _original_downstream_error(exc: Exception) -> BaseException:
|
||||
# Hermes wraps downstream execution failures in a local/private exception
|
||||
# class, so detect the wrapper by shape instead of importing it here.
|
||||
original = getattr(exc, "original", None)
|
||||
if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance(original, BaseException):
|
||||
return original
|
||||
return exc
|
||||
|
||||
|
||||
def _is_relay_wrapped_callback_error(exc: Exception, callback_error: Exception | None) -> bool:
|
||||
# NeMo Relay re-wraps a failing callback as ``RuntimeError("internal error:
|
||||
# <ClassName>: <message>")``. Match by prefix rather than exact equality so a
|
||||
# trailing traceback/suffix in a future Relay version doesn't silently defeat
|
||||
# the unwrap; the class-name + message prefix still discriminates the real
|
||||
# downstream failure from unrelated Relay-internal errors. If Relay drops the
|
||||
# leading ``internal error:`` shape entirely, this returns False and Hermes
|
||||
# falls back to surfacing Relay's error (the pre-fix behavior) rather than
|
||||
# masking it.
|
||||
if callback_error is None or not isinstance(exc, RuntimeError):
|
||||
return False
|
||||
expected = f"internal error: {callback_error.__class__.__name__}: {callback_error}"
|
||||
return str(exc).startswith(expected)
|
||||
|
||||
|
||||
def _llm_response_payload(response: Any) -> Any:
|
||||
"""Return the LLM response shape NeMo Relay's ATIF conversion expects."""
|
||||
payload = _jsonable(response)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import tempfile
|
|||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from typing import Callable, Dict, List, Optional, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -68,6 +69,43 @@ from gateway.platforms.base import (
|
|||
from tools.url_safety import is_safe_url
|
||||
|
||||
|
||||
async def _wait_for_ready_or_bot_exit(
|
||||
ready_event: asyncio.Event,
|
||||
bot_task: asyncio.Task,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
"""Wait until Discord is ready, or surface early bot startup failure.
|
||||
|
||||
``discord.py`` startup errors (including SOCKS/proxy failures from
|
||||
aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()``
|
||||
only waits on ``ready_event``, a dead background task still burns the full
|
||||
ready timeout before the gateway supervisor can reconnect. Racing the ready
|
||||
event against the bot task keeps failures fast and preserves the original
|
||||
exception for logging/classification.
|
||||
"""
|
||||
ready_task = asyncio.create_task(ready_event.wait())
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{ready_task, bot_task},
|
||||
timeout=timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if not done:
|
||||
raise asyncio.TimeoutError
|
||||
if bot_task in done:
|
||||
exc = bot_task.exception()
|
||||
if exc is not None:
|
||||
raise exc
|
||||
if not ready_task.done():
|
||||
raise RuntimeError("Discord bot task exited before ready")
|
||||
await ready_task
|
||||
finally:
|
||||
if not ready_task.done():
|
||||
ready_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ready_task
|
||||
|
||||
|
||||
def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]:
|
||||
"""Return discord.py's bundled Windows opus DLL path when present."""
|
||||
if sys.platform != "win32":
|
||||
|
|
@ -520,6 +558,7 @@ class VoiceReceiver:
|
|||
],
|
||||
check=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -601,6 +640,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
|
||||
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
||||
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
||||
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
|
||||
# linked text-channel id; set by run.py. Lets the inactivity timer leave
|
||||
# the bot in the channel when the user deliberately picked text-only
|
||||
# (/voice off) instead of leaving (/voice leave).
|
||||
self._voice_mode_getter: Optional[Callable] = None # set by run.py
|
||||
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
|
||||
# Installed once per guild on join; lets acks / TTS / the "thinking"
|
||||
# loop overlap in one outgoing stream instead of stop-and-swap.
|
||||
|
|
@ -616,6 +660,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
self._typing_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._bot_task: Optional[asyncio.Task] = None
|
||||
self._post_connect_task: Optional[asyncio.Task] = None
|
||||
# True while disconnect() is intentionally closing discord.py. The
|
||||
# bot task's done callback uses this to distinguish an operator/service
|
||||
# shutdown from a runtime websocket crash.
|
||||
self._disconnecting = False
|
||||
# Dedup cache: prevents duplicate bot responses when Discord
|
||||
# RESUME replays events after reconnects.
|
||||
self._dedup = MessageDeduplicator()
|
||||
|
|
@ -628,6 +676,65 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
# scanning channel.history() on cache miss (cold start / restart).
|
||||
self._last_self_message_id: Dict[str, str] = {}
|
||||
|
||||
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
|
||||
"""Surface post-startup discord.py task exits to the gateway supervisor.
|
||||
|
||||
discord.py reconnects normal gateway interruptions internally. When its
|
||||
top-level ``Bot.start()`` task actually exits after the adapter has been
|
||||
marked running, the Discord websocket is dead while the Hermes gateway
|
||||
process can remain alive. Treat that split-brain state as a retryable
|
||||
fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can
|
||||
remove this adapter and queue Discord for the existing reconnect watcher.
|
||||
"""
|
||||
if getattr(self, "_disconnecting", False):
|
||||
# Intentional service/operator shutdown. Drain the task result so
|
||||
# asyncio doesn't emit "exception was never retrieved" warnings.
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.exception()
|
||||
return
|
||||
|
||||
# Ignore stale callbacks from an older client if a reconnect already
|
||||
# installed a newer Bot.start() task on this adapter instance.
|
||||
if self._bot_task is not None and task is not self._bot_task:
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.exception()
|
||||
return
|
||||
|
||||
if not self._running:
|
||||
# Startup failures are handled by _wait_for_ready_or_bot_exit() in
|
||||
# connect(); this callback is only for post-startup split-brain.
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.exception()
|
||||
return
|
||||
|
||||
try:
|
||||
exc = task.exception()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as err: # pragma: no cover - defensive
|
||||
exc = err
|
||||
|
||||
if exc is None:
|
||||
message = "Discord gateway task exited without an exception"
|
||||
else:
|
||||
message = f"Discord gateway task exited: {exc}"
|
||||
|
||||
logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False)
|
||||
self._set_fatal_error("discord_gateway_task_exited", message, retryable=True)
|
||||
|
||||
async def _notify() -> None:
|
||||
try:
|
||||
await self._notify_fatal_error()
|
||||
except Exception as notify_exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"[%s] Failed to notify gateway supervisor about Discord task exit: %s",
|
||||
self.name,
|
||||
notify_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_notify())
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Discord and start receiving events."""
|
||||
if not DISCORD_AVAILABLE:
|
||||
|
|
@ -788,6 +895,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
# Must run BEFORE the user allowlist check so that bots
|
||||
# permitted by DISCORD_ALLOW_BOTS are not rejected for
|
||||
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
|
||||
_role_authorized = False
|
||||
if getattr(message.author, "bot", False):
|
||||
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
|
||||
if allow_bots == "none":
|
||||
|
|
@ -811,6 +919,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
is_dm=_is_dm,
|
||||
):
|
||||
return
|
||||
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
|
||||
|
||||
# Multi-agent filtering: if the message mentions specific bots
|
||||
# but NOT this bot, the sender is talking to another agent —
|
||||
|
|
@ -852,7 +961,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if "*" not in _free_channels and not (_channel_ids & _free_channels):
|
||||
return
|
||||
|
||||
await self._handle_message(message)
|
||||
await self._handle_message(message, role_authorized=_role_authorized)
|
||||
|
||||
@self._client.event
|
||||
async def on_voice_state_update(member, before, after):
|
||||
|
|
@ -892,25 +1001,55 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
self._register_slash_commands()
|
||||
|
||||
# Start the bot in background
|
||||
self._disconnecting = False
|
||||
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
|
||||
self._bot_task.add_done_callback(self._handle_bot_task_done)
|
||||
|
||||
# Wait for ready
|
||||
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
|
||||
# Wait for ready, but fail fast if discord.py's background startup
|
||||
# task dies first (for example on SOCKS/proxy connect errors).
|
||||
await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30)
|
||||
|
||||
self._running = True
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
|
||||
# Cancel the background bot task so it cannot fire on_message after
|
||||
# this adapter is discarded. Without this, the task keeps running and
|
||||
# a later successful reconnect leaves two active Discord clients that
|
||||
# each process every message, producing duplicate threads/responses.
|
||||
await self._cancel_bot_task()
|
||||
self._release_platform_lock()
|
||||
return False
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
|
||||
# Same zombie-client hazard as the timeout branch: the background
|
||||
# client.start() task may already be running when a later setup
|
||||
# step raises. Cancel it so the discarded adapter cannot connect.
|
||||
await self._cancel_bot_task()
|
||||
self._release_platform_lock()
|
||||
return False
|
||||
|
||||
async def _cancel_bot_task(self) -> None:
|
||||
"""Cancel and await the background client.start() task, if running."""
|
||||
if self._bot_task and not self._bot_task.done():
|
||||
self._bot_task.cancel()
|
||||
try:
|
||||
await self._bot_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._bot_task = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Discord."""
|
||||
self._disconnecting = True
|
||||
# Cancel the bot task before closing the client. If connect() timed out
|
||||
# and returned False, the background client.start() task may still be
|
||||
# running; calling client.close() alone is not enough to stop it because
|
||||
# discord.py's reconnect loop can ignore the closed flag while a
|
||||
# WebSocket handshake is in flight. Explicitly cancelling the task here
|
||||
# ensures the zombie client cannot receive or dispatch any further events.
|
||||
await self._cancel_bot_task()
|
||||
# Clean up all active voice connections before closing the client
|
||||
for guild_id in list(self._voice_clients.keys()):
|
||||
try:
|
||||
|
|
@ -2264,6 +2403,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
except asyncio.CancelledError:
|
||||
return
|
||||
text_ch_id = self._voice_text_channels.get(guild_id)
|
||||
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
|
||||
# the channel (leaving is ``/voice leave``). The inactivity timer only
|
||||
# counts the bot's OWN audio as activity, so under voice-off mode it
|
||||
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
|
||||
# text channel with "Left voice channel (inactivity timeout)." Honor the
|
||||
# user's choice: skip the auto-disconnect while voice replies are off.
|
||||
# (The timer re-arms when the bot next speaks or hears a user.)
|
||||
_mode_getter = getattr(self, "_voice_mode_getter", None)
|
||||
if text_ch_id is not None and _mode_getter is not None:
|
||||
try:
|
||||
if _mode_getter(str(text_ch_id)) == "off":
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await self.leave_voice_channel(guild_id)
|
||||
# Notify the runner so it can clean up voice_mode state
|
||||
if self._on_voice_disconnect and text_ch_id:
|
||||
|
|
@ -2394,6 +2547,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
is_dm=False,
|
||||
):
|
||||
continue
|
||||
# A user speaking to the bot is activity too — not just the
|
||||
# bot's own playback. Reset the inactivity timer so an active
|
||||
# listener isn't disconnected mid-conversation (this also
|
||||
# covers voice-on text-only sessions that never play audio).
|
||||
self._reset_voice_timeout(guild_id)
|
||||
await self._process_voice_input(guild_id, user_id, pcm_data)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
|
@ -4701,7 +4859,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
raise Exception(f"HTTP {resp.status}")
|
||||
return await resp.read()
|
||||
|
||||
async def _handle_message(self, message: DiscordMessage) -> None:
|
||||
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
|
||||
"""Handle incoming Discord messages."""
|
||||
# In server channels (not DMs), require the bot to be @mentioned
|
||||
# UNLESS the channel is in the free-response list or the message is
|
||||
|
|
@ -4885,6 +5043,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
guild_id=str(guild.id) if guild else None,
|
||||
parent_chat_id=parent_channel_id,
|
||||
message_id=str(message.id),
|
||||
role_authorized=role_authorized,
|
||||
)
|
||||
|
||||
# Build media URLs -- download image attachments to local cache so the
|
||||
|
|
@ -5610,6 +5769,7 @@ def _define_discord_view_classes() -> None:
|
|||
self.allowed_role_ids = allowed_role_ids or set()
|
||||
self.resolved = False
|
||||
self._selected_provider: str = ""
|
||||
self._pending_expensive_model: str = ""
|
||||
|
||||
self._build_provider_select()
|
||||
|
||||
|
|
@ -5692,6 +5852,41 @@ def _define_discord_view_classes() -> None:
|
|||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
def _build_expensive_confirm(self, model_id: str):
|
||||
"""Build confirmation buttons for unusually expensive models."""
|
||||
self.clear_items()
|
||||
self._pending_expensive_model = model_id
|
||||
|
||||
confirm_btn = discord.ui.Button(
|
||||
label="Switch anyway",
|
||||
style=discord.ButtonStyle.red,
|
||||
custom_id="model_expensive_confirm",
|
||||
)
|
||||
confirm_btn.callback = self._on_expensive_confirm
|
||||
self.add_item(confirm_btn)
|
||||
|
||||
cancel_btn = discord.ui.Button(
|
||||
label="Cancel",
|
||||
style=discord.ButtonStyle.grey,
|
||||
custom_id="model_expensive_cancel",
|
||||
)
|
||||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
async def _expensive_warning_for(self, model_id: str):
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
# Pricing lookup can hit models.dev / a /models endpoint on a
|
||||
# cache miss — keep it off the event loop.
|
||||
return await asyncio.to_thread(
|
||||
expensive_model_warning,
|
||||
model_id,
|
||||
provider=self._selected_provider,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _on_provider_selected(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
|
|
@ -5721,7 +5916,11 @@ def _define_discord_view_classes() -> None:
|
|||
view=self,
|
||||
)
|
||||
|
||||
async def _on_model_selected(self, interaction: discord.Interaction):
|
||||
async def _switch_selected_model(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
model_id: str,
|
||||
):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already resolved~", ephemeral=True
|
||||
|
|
@ -5734,7 +5933,6 @@ def _define_discord_view_classes() -> None:
|
|||
return
|
||||
|
||||
self.resolved = True
|
||||
model_id = interaction.data["values"][0]
|
||||
self.clear_items()
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
|
|
@ -5763,6 +5961,50 @@ def _define_discord_view_classes() -> None:
|
|||
view=None,
|
||||
)
|
||||
|
||||
async def _on_model_selected(self, interaction: discord.Interaction):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already resolved~", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
model_id = interaction.data["values"][0]
|
||||
warning = await self._expensive_warning_for(model_id)
|
||||
if warning is not None:
|
||||
self._build_expensive_confirm(model_id)
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚠ Expensive Model Warning",
|
||||
description=warning.message,
|
||||
color=discord.Color.red(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
return
|
||||
|
||||
await self._switch_selected_model(interaction, model_id)
|
||||
|
||||
async def _on_expensive_confirm(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._pending_expensive_model:
|
||||
await interaction.response.send_message(
|
||||
"Model selection expired.", ephemeral=True
|
||||
)
|
||||
return
|
||||
await self._switch_selected_model(
|
||||
interaction,
|
||||
self._pending_expensive_model,
|
||||
)
|
||||
|
||||
async def _on_back(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
|
|||
],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.warning("decode_to_pcm failed for %s: %s", path, e)
|
||||
|
|
|
|||
137
plugins/platforms/photon/README.md
Normal file
137
plugins/platforms/photon/README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Photon iMessage platform plugin
|
||||
|
||||
This plugin connects Hermes Agent to iMessage (and other Spectrum
|
||||
interfaces) through [Photon][photon] — a managed service that handles
|
||||
iMessage line allocation, delivery, and abuse-prevention so users don't
|
||||
have to run their own Mac relay.
|
||||
|
||||
The free tier uses Photon's shared iMessage line pool and is the path we
|
||||
recommend for everyone who doesn't already pay for a dedicated number.
|
||||
|
||||
## Architecture
|
||||
|
||||
Like Discord and Slack, Photon is a **persistent-connection** channel — no
|
||||
public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a
|
||||
long-lived **gRPC stream** to Photon for both directions. Because the SDK is
|
||||
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
|
||||
talks to it over loopback.
|
||||
|
||||
```
|
||||
gRPC (spectrum-ts)
|
||||
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
|
||||
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
|
||||
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
|
||||
└─────────────────────────┘ └──────────┬───────────┘
|
||||
GET /inbound (NDJSON) │ ▲ POST /send
|
||||
inbound events ▼ │ /typing
|
||||
┌──────────────────────┐
|
||||
│ PhotonAdapter │
|
||||
│ (Python, in gateway) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream,
|
||||
normalizes each message, and streams it to the adapter over a loopback
|
||||
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
|
||||
a `MessageEvent` to the gateway. It reconnects automatically if the stream
|
||||
drops; the sidecar owns the gRPC reconnect to Photon.
|
||||
- **Outbound**: `send` / `send_typing` are loopback POSTs to the sidecar,
|
||||
authenticated with a shared `X-Hermes-Sidecar-Token`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# One-shot setup: device login (opens browser) + project + user + sidecar deps
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# Start the gateway
|
||||
hermes gateway start --platform photon
|
||||
```
|
||||
|
||||
`hermes photon setup` does, in order:
|
||||
|
||||
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
|
||||
`https://app.photon.codes/` for approval and stores the bearer token.
|
||||
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
|
||||
3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the
|
||||
project secret, and persist both.
|
||||
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
|
||||
a user with that number already exists).
|
||||
5. **Print the assigned iMessage line** — the number you text to reach your
|
||||
agent.
|
||||
6. **Install the sidecar deps** (`spectrum-ts`).
|
||||
|
||||
There is no separate `login` command; like every other Hermes channel,
|
||||
onboarding goes through one setup surface. Re-running `setup` reuses an
|
||||
existing token/project, so it's safe to run again to finish a partial setup.
|
||||
Run `hermes photon status` to see what's configured.
|
||||
|
||||
## Credentials
|
||||
|
||||
Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
|
||||
channel keeps its token), and the adapter reads them from the environment:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=<spectrumProjectId> # the SDK's projectId
|
||||
PHOTON_PROJECT_SECRET=<projectSecret>
|
||||
```
|
||||
|
||||
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"credential_pool": {
|
||||
"photon": [
|
||||
{ "access_token": "<device-bearer>", "issued_at": ... }
|
||||
],
|
||||
"photon_project": [
|
||||
{
|
||||
"dashboard_project_id": "<dashboard id>",
|
||||
"spectrum_project_id": "<spectrumProjectId>",
|
||||
"project_secret": "<projectSecret>",
|
||||
"name": "Hermes Agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on ids.** A Photon project has two identifiers: the dashboard `id`
|
||||
> (used for management API calls) and the `spectrumProjectId` (what the SDK
|
||||
> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id.
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
All env vars are documented in `plugin.yaml`. The most important:
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---------------------------|----------------------------|--------------------------------------|
|
||||
| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)|
|
||||
| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret |
|
||||
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
|
||||
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
|
||||
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
|
||||
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
|
||||
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
|
||||
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
|
||||
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
|
||||
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
|
||||
|
||||
## Attachments & limitations
|
||||
|
||||
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
|
||||
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
|
||||
adapter caches them to the shared media cache and populates `media_urls` /
|
||||
`media_types`, so the agent sees the real image/file or can transcribe the
|
||||
voice note — parity with the BlueBubbles iMessage channel. Media larger than
|
||||
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
|
||||
fails, falls back to a text marker (`[Photon attachment received: …]` or
|
||||
`[Photon voice received: …]`) so the agent still knows something arrived.
|
||||
- **Outbound attachments are supported.** Images, voice notes, video, and
|
||||
documents are sent via `space.send(attachment(...))` /
|
||||
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
||||
endpoint; a caption is delivered as a separate text bubble after the media.
|
||||
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
|
||||
yet exposed; the sidecar is the natural place to add them.
|
||||
|
||||
[photon]: https://photon.codes/
|
||||
4
plugins/platforms/photon/__init__.py
Normal file
4
plugins/platforms/photon/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Photon Spectrum (iMessage) platform plugin entry point."""
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
1162
plugins/platforms/photon/adapter.py
Normal file
1162
plugins/platforms/photon/adapter.py
Normal file
File diff suppressed because it is too large
Load diff
1065
plugins/platforms/photon/auth.py
Normal file
1065
plugins/platforms/photon/auth.py
Normal file
File diff suppressed because it is too large
Load diff
387
plugins/platforms/photon/cli.py
Normal file
387
plugins/platforms/photon/cli.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"""
|
||||
``hermes photon ...`` CLI subcommands — registered by the plugin via
|
||||
``ctx.register_cli_command()``.
|
||||
|
||||
Subcommands:
|
||||
|
||||
setup full first-time setup (device login + project + user + sidecar)
|
||||
status show login + project + sidecar dep state
|
||||
install-sidecar npm install inside plugins/platforms/photon/sidecar/
|
||||
|
||||
The device-code login runs automatically as the first step of ``setup``;
|
||||
there is no standalone ``login`` verb (matching how every other Hermes
|
||||
gateway channel onboards through a single setup surface).
|
||||
|
||||
Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook
|
||||
to register, so there are no webhook subcommands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
from . import auth as photon_auth
|
||||
|
||||
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring
|
||||
|
||||
def register_cli(parser: argparse.ArgumentParser) -> None:
|
||||
"""Wire up `hermes photon ...` subcommands."""
|
||||
subs = parser.add_subparsers(dest="photon_command", required=False)
|
||||
|
||||
p_setup = subs.add_parser(
|
||||
"setup",
|
||||
help="First-time setup (device login + project + user + sidecar)",
|
||||
)
|
||||
p_setup.add_argument("--project-name", default=None,
|
||||
help="Project name (default: 'Hermes Agent')")
|
||||
p_setup.add_argument("--phone", default=None,
|
||||
help="Your E.164 phone number (e.g. +15551234567)")
|
||||
p_setup.add_argument("--first-name", default=None)
|
||||
p_setup.add_argument("--last-name", default=None)
|
||||
p_setup.add_argument("--email", default=None)
|
||||
p_setup.add_argument("--no-browser", action="store_true",
|
||||
help="Don't try to open a browser for device login; print the URL only")
|
||||
p_setup.add_argument("--skip-sidecar-install", action="store_true",
|
||||
help="Skip `npm install` inside the sidecar directory")
|
||||
|
||||
subs.add_parser("status", help="Show login + project + sidecar dep state")
|
||||
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
|
||||
|
||||
parser.set_defaults(func=dispatch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "photon_command", None)
|
||||
if sub is None:
|
||||
# No subcommand given — show status by default.
|
||||
return _cmd_status(args)
|
||||
if sub == "setup":
|
||||
return _cmd_setup(args)
|
||||
if sub == "status":
|
||||
return _cmd_status(args)
|
||||
if sub == "install-sidecar":
|
||||
return _cmd_install_sidecar(args)
|
||||
print(f"unknown subcommand: {sub}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
|
||||
def _run_device_login(args: argparse.Namespace) -> int:
|
||||
"""Run the RFC 8628 device-code login flow and persist the token.
|
||||
|
||||
Internal helper — invoked as the first step of ``setup``. There is
|
||||
no standalone ``hermes photon login`` command; Photon onboards
|
||||
through the single ``setup`` surface like every other channel.
|
||||
"""
|
||||
def _print_code(code):
|
||||
target = code.verification_uri_complete or code.verification_uri
|
||||
print()
|
||||
print("┌─ Photon device login ────────────────────────────────────────")
|
||||
print(f"│ Open this URL: {target}")
|
||||
print(f"│ Enter the code: {code.user_code}")
|
||||
print("│ (waiting for approval — Ctrl-C to cancel)")
|
||||
print("└──────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
|
||||
try:
|
||||
token = photon_auth.login_device_flow(
|
||||
open_browser=not args.no_browser,
|
||||
on_user_code=_print_code,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"login failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# Don't print any portion of the token — even a prefix can help a
|
||||
# shoulder-surfer or accidentally leak into a screen recording.
|
||||
_ = token
|
||||
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
# 1. Login (skip if we already have a token).
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("[1/5] No Photon token found — running device login...")
|
||||
rc = _run_device_login(args)
|
||||
if rc != 0:
|
||||
return rc
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("login completed but token was not stored", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print("[1/5] Reusing existing Photon token")
|
||||
|
||||
# 2. Find or create the "Hermes Agent" project.
|
||||
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
|
||||
dashboard_id = photon_auth.load_dashboard_project_id()
|
||||
try:
|
||||
if dashboard_id:
|
||||
print("[2/5] Reusing configured Photon project")
|
||||
else:
|
||||
existing = photon_auth.find_project_by_name(token, name)
|
||||
if existing and existing.get("id"):
|
||||
dashboard_id = existing["id"]
|
||||
print(f"[2/5] Found existing project '{name}'")
|
||||
else:
|
||||
print(f"[2/5] Creating Photon project '{name}'...")
|
||||
created = photon_auth.create_project(token, name=name)
|
||||
dashboard_id = created.get("id")
|
||||
print(" ✓ project created")
|
||||
except Exception as e:
|
||||
print(f"project setup failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not dashboard_id:
|
||||
print("could not resolve a Photon project id", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
|
||||
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
|
||||
try:
|
||||
print("[3/5] Enabling Spectrum and provisioning credentials...")
|
||||
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
|
||||
spectrum_id = proj.get("spectrumProjectId")
|
||||
if not spectrum_id:
|
||||
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
|
||||
return 1
|
||||
spectrum_id = str(spectrum_id)
|
||||
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id=spectrum_id,
|
||||
project_secret=secret,
|
||||
dashboard_project_id=dashboard_id,
|
||||
name=name,
|
||||
)
|
||||
# spectrum_id is an opaque non-secret id; safe to show.
|
||||
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
|
||||
except Exception as e:
|
||||
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 4. Register the operator's phone number as a Spectrum user (idempotent).
|
||||
phone = args.phone or _prompt(
|
||||
color(
|
||||
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
|
||||
Colors.CYAN,
|
||||
)
|
||||
)
|
||||
agent_number = None
|
||||
registered_phone = None
|
||||
registered_user_id = None
|
||||
if not phone:
|
||||
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
||||
else:
|
||||
# Name/email are optional and never prompted for — pass --first-name /
|
||||
# --email if you want them sent to the dashboard.
|
||||
first_name = args.first_name
|
||||
email = args.email
|
||||
try:
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
spectrum_id, secret,
|
||||
phone_number=phone,
|
||||
first_name=first_name,
|
||||
last_name=args.last_name,
|
||||
email=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f" invalid phone number: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f" user registration failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
||||
registered_phone = phone
|
||||
registered_user_id = user.get("id")
|
||||
# The number to text the agent is the user's assigned iMessage line
|
||||
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
|
||||
# no dedicated entry in /lines, so this per-user field is the source of
|
||||
# truth — and we already have it from the (reused) user object.
|
||||
agent_number = photon_auth.user_assigned_line(user)
|
||||
# Allowlist the operator and make their DM the cron home channel —
|
||||
# otherwise the gateway denies their own inbound messages
|
||||
# ("Unauthorized user") and has no default space for cron delivery.
|
||||
_autoconfigure_access(phone)
|
||||
|
||||
# 5. Surface the agent's iMessage number (the number to text the agent).
|
||||
if not agent_number:
|
||||
# No per-user assignment — fall back to a dedicated line if the project
|
||||
# has one provisioned in its line inventory.
|
||||
try:
|
||||
line = photon_auth.get_imessage_line(token, dashboard_id)
|
||||
if line:
|
||||
agent_number = line.get("phoneNumber")
|
||||
except Exception as e:
|
||||
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
|
||||
if agent_number:
|
||||
print()
|
||||
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
|
||||
print(
|
||||
color("│ 📱 ", Colors.GREEN)
|
||||
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
|
||||
)
|
||||
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
|
||||
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
|
||||
else:
|
||||
print(" No iMessage line assigned yet — check the Photon dashboard.")
|
||||
if registered_phone:
|
||||
try:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number=registered_phone,
|
||||
assigned_phone_number=agent_number,
|
||||
user_id=str(registered_user_id) if registered_user_id else None,
|
||||
dashboard_project_id=dashboard_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
|
||||
|
||||
# 6. Sidecar deps (spectrum-ts).
|
||||
if args.skip_sidecar_install:
|
||||
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
|
||||
else:
|
||||
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
|
||||
rc = _install_sidecar()
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start --platform photon")
|
||||
return 0
|
||||
|
||||
|
||||
def _autoconfigure_access(phone: str) -> None:
|
||||
"""Allowlist the operator and set their DM as the cron home channel.
|
||||
|
||||
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
|
||||
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
|
||||
(the default space for cron delivery) to the operator's E.164 number. Each
|
||||
is only filled when unset, so a hand-tuned allowlist / home channel is
|
||||
never clobbered on a re-run.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except ImportError:
|
||||
return
|
||||
for key, label in (
|
||||
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
||||
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
|
||||
):
|
||||
try:
|
||||
if get_env_value(key):
|
||||
print(f" {key} already set — leaving it as-is.")
|
||||
continue
|
||||
save_env_value(key, phone)
|
||||
print(f" ✓ {label} ({key})")
|
||||
except Exception as e:
|
||||
print(f" could not set {key}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
_refresh_status_numbers()
|
||||
# Defer the credential rows to auth.print_credential_summary — its emit
|
||||
# callback is the only sink that sees credential-derived strings, so
|
||||
# cli.py keeps zero taint flow according to CodeQL.
|
||||
photon_auth.print_credential_summary(print)
|
||||
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
||||
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
|
||||
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
||||
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_status_numbers() -> None:
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
if phone and assigned:
|
||||
return
|
||||
spectrum_id, project_secret = photon_auth.load_project_credentials()
|
||||
if not spectrum_id or not project_secret:
|
||||
return
|
||||
try:
|
||||
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
|
||||
return _install_sidecar()
|
||||
|
||||
|
||||
def _install_sidecar() -> int:
|
||||
npm = shutil.which("npm") or "npm"
|
||||
if not shutil.which(npm):
|
||||
print(
|
||||
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
|
||||
"and re-run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
# Always pull the newest published spectrum-ts so every setup runs against
|
||||
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
|
||||
# to the current release before installing — a plain `npm install` would
|
||||
# stay pinned to whatever the committed lockfile already resolved.
|
||||
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install", "spectrum-ts@latest"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print("npm install failed", file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-setup entry point
|
||||
#
|
||||
# `hermes gateway setup` discovers platforms via the registry and calls each
|
||||
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
|
||||
# in the unified setup wizard alongside every other channel — same onboarding
|
||||
# surface, no Photon-specific detour. It runs the identical device-login +
|
||||
# project + user + sidecar flow as ``hermes photon setup`` with interactive
|
||||
# defaults (phone is prompted when stdin is a TTY).
|
||||
|
||||
def gateway_setup() -> None:
|
||||
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
|
||||
args = argparse.Namespace(
|
||||
photon_command="setup",
|
||||
project_name=None,
|
||||
phone=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=False,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
_cmd_setup(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small interactive helpers
|
||||
|
||||
def _prompt(prompt: str, *, secret: bool = False) -> str:
|
||||
if not sys.stdin.isatty():
|
||||
return ""
|
||||
try:
|
||||
if secret:
|
||||
return getpass.getpass(prompt).strip()
|
||||
return input(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
76
plugins/platforms/photon/plugin.yaml
Normal file
76
plugins/platforms/photon/plugin.yaml
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: photon-platform
|
||||
label: iMessage via Photon
|
||||
kind: platform
|
||||
version: 0.2.0
|
||||
description: >
|
||||
Photon Spectrum gateway adapter for Hermes Agent.
|
||||
Connects to iMessage (and other Spectrum interfaces) through Photon's
|
||||
managed Spectrum platform. Both directions run over the `spectrum-ts`
|
||||
SDK's long-lived gRPC stream via a small supervised Node sidecar —
|
||||
inbound messages arrive on the SDK's `app.messages` stream (no webhook,
|
||||
no public URL, no signing secret), and outbound messages are sent over
|
||||
the same sidecar.
|
||||
|
||||
The plugin ships with a `hermes photon` CLI for the one-time device
|
||||
login + project + user setup. Runtime credentials are written to
|
||||
``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id,
|
||||
``PHOTON_PROJECT_SECRET``) like every other channel, with management
|
||||
metadata (device token, dashboard project id) in ``~/.hermes/auth.json``.
|
||||
Photon's free shared-line model lets users get started without a paid plan.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: PHOTON_PROJECT_ID
|
||||
description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)"
|
||||
prompt: "Photon Spectrum project id"
|
||||
url: "https://app.photon.codes/"
|
||||
password: false
|
||||
- name: PHOTON_PROJECT_SECRET
|
||||
description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)"
|
||||
prompt: "Photon project secret"
|
||||
url: "https://app.photon.codes/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: PHOTON_SIDECAR_PORT
|
||||
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
|
||||
prompt: "Sidecar control port"
|
||||
password: false
|
||||
- name: PHOTON_SIDECAR_AUTOSTART
|
||||
description: "Spawn the Node sidecar on connect (true/false, default true)"
|
||||
prompt: "Auto-start the sidecar?"
|
||||
password: false
|
||||
- name: PHOTON_NODE_BIN
|
||||
description: "Path to the node binary (default: shutil.which('node'))"
|
||||
prompt: "Node executable path"
|
||||
password: false
|
||||
- name: PHOTON_DASHBOARD_HOST
|
||||
description: "Photon Dashboard API host (default https://app.photon.codes)"
|
||||
prompt: "Dashboard host"
|
||||
password: false
|
||||
- name: PHOTON_SPECTRUM_HOST
|
||||
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
|
||||
prompt: "Spectrum API host"
|
||||
password: false
|
||||
- name: PHOTON_ALLOWED_USERS
|
||||
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: PHOTON_ALLOW_ALL_USERS
|
||||
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REQUIRE_MENTION
|
||||
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
|
||||
prompt: "Require a mention in group chats?"
|
||||
password: false
|
||||
- name: PHOTON_MENTION_PATTERNS
|
||||
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
|
||||
prompt: "Group mention patterns"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL
|
||||
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
|
||||
prompt: "Home Photon target"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
52
plugins/platforms/photon/sidecar/README.md
Normal file
52
plugins/platforms/photon/sidecar/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Photon sidecar
|
||||
|
||||
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
|
||||
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
|
||||
send-message endpoint today; replies therefore go through this sidecar.
|
||||
|
||||
The sidecar:
|
||||
|
||||
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
|
||||
- exposes a loopback-only HTTP control channel for the Python adapter
|
||||
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
|
||||
- drains the inbound message stream so `spectrum-ts` keeps its
|
||||
reconnect/heartbeat machinery alive (real inbound delivery is via
|
||||
Photon's signed webhook hitting our Python aiohttp server)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd plugins/platforms/photon/sidecar
|
||||
npm install
|
||||
```
|
||||
|
||||
The Hermes plugin's `hermes photon setup` command runs `npm install`
|
||||
here automatically.
|
||||
|
||||
## Run standalone
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
|
||||
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
|
||||
node index.mjs
|
||||
```
|
||||
|
||||
In normal use, the Python adapter supervises this process — start,
|
||||
restart on crash, kill on shutdown — and never asks the user to run
|
||||
it by hand.
|
||||
|
||||
## Why a sidecar at all?
|
||||
|
||||
Photon publishes webhooks (inbound) but their docs state explicitly:
|
||||
|
||||
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
|
||||
> SDK instance to reply. No public HTTP send endpoint exists today.
|
||||
|
||||
— https://photon.codes/docs/webhooks/events
|
||||
|
||||
When Photon ships an HTTP send endpoint, the plan is to retire this
|
||||
sidecar entirely and call it directly from Python. The plugin's
|
||||
outbound code path is already isolated behind a single helper
|
||||
(`_sidecar_send` in `adapter.py`) to make that swap a one-file change.
|
||||
542
plugins/platforms/photon/sidecar/index.mjs
Normal file
542
plugins/platforms/photon/sidecar/index.mjs
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
// Hermes Agent — Photon Spectrum sidecar
|
||||
//
|
||||
// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions
|
||||
// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the
|
||||
// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no
|
||||
// Python SDK and no public HTTP message API).
|
||||
//
|
||||
// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a
|
||||
// long-lived gRPC stream. We serialize each `[space, message]` to a
|
||||
// normalized JSON event and stream it to the Python adapter over a
|
||||
// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while
|
||||
// no consumer is attached so a backlog isn't pulled-and-lost before the
|
||||
// gateway connects.
|
||||
// Outbound (Hermes -> gRPC): `/send` drives `space.send(...)`; `/typing`
|
||||
// sends the documented `typing("start" | "stop")` content builder.
|
||||
//
|
||||
// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`):
|
||||
// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank
|
||||
// lines are heartbeats. One consumer at a time.
|
||||
// - POST /healthz -> {"ok": true}
|
||||
// - POST /send -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "text": "..."}
|
||||
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
|
||||
// "mimeType": "..." | null, "caption": "..." | null,
|
||||
// "kind": "attachment" | "voice"}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// body: {"spaceId": "...", "state": "start" | "stop"}
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
//
|
||||
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
|
||||
// exiting. Logs go to stderr; Python supervises restart.
|
||||
//
|
||||
// Env vars (required):
|
||||
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
|
||||
// PHOTON_PROJECT_SECRET
|
||||
// PHOTON_SIDECAR_PORT
|
||||
// PHOTON_SIDECAR_TOKEN
|
||||
// Optional:
|
||||
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
|
||||
|
||||
import http from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
|
||||
const projectId = process.env.PHOTON_PROJECT_ID;
|
||||
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
|
||||
const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
|
||||
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
|
||||
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
|
||||
|
||||
// Inbound binary content is read into memory and base64-inlined on the NDJSON
|
||||
// event so the Python adapter can cache the real bytes (and the agent can see
|
||||
// images / transcribe voice). Cap the size we inline — above it we forward
|
||||
// metadata only and the adapter surfaces a text marker, so one large clip can't
|
||||
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
|
||||
const MAX_INLINE_ATTACHMENT_BYTES =
|
||||
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
|
||||
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
|
||||
const E164_RE = /^\+\d{6,}$/;
|
||||
const MAX_KNOWN_SPACES = 2048;
|
||||
|
||||
if (!projectId || !projectSecret || !sharedToken) {
|
||||
console.error(
|
||||
"photon-sidecar: PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET and " +
|
||||
"PHOTON_SIDECAR_TOKEN must all be set."
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Lazy-load spectrum-ts so a missing install fails with a clear message
|
||||
// instead of a cryptic module-resolution error during import.
|
||||
let Spectrum, imessage, attachment, voice, spectrumText, spectrumTyping;
|
||||
try {
|
||||
({
|
||||
Spectrum,
|
||||
attachment,
|
||||
voice,
|
||||
text: spectrumText,
|
||||
typing: spectrumTyping,
|
||||
} = await import("spectrum-ts"));
|
||||
({ imessage } = await import("spectrum-ts/providers/imessage"));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: spectrum-ts is not installed. Run `npm install` " +
|
||||
"inside plugins/platforms/photon/sidecar/. Original error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const app = await Spectrum({
|
||||
projectId,
|
||||
projectSecret,
|
||||
providers: [imessage.config()],
|
||||
options: { flattenGroups: true },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.
|
||||
|
||||
// At most one Python consumer is attached at a time (the gateway adapter).
|
||||
let consumerRes = null;
|
||||
let consumerWaiters = [];
|
||||
const knownSpaces = new Map();
|
||||
|
||||
function rememberKnownSpace(id, space) {
|
||||
if (!id || typeof id !== "string" || !space) return;
|
||||
if (knownSpaces.has(id)) knownSpaces.delete(id);
|
||||
knownSpaces.set(id, space);
|
||||
if (knownSpaces.size > MAX_KNOWN_SPACES) {
|
||||
const oldest = knownSpaces.keys().next().value;
|
||||
if (oldest) knownSpaces.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function phoneTargetFromSpaceId(spaceId) {
|
||||
if (typeof spaceId !== "string") return null;
|
||||
if (E164_RE.test(spaceId)) return spaceId;
|
||||
const dmGuid = spaceId.match(DM_CHAT_GUID_RE);
|
||||
return dmGuid ? dmGuid[1] : null;
|
||||
}
|
||||
|
||||
function rememberInboundSpace(space, message) {
|
||||
const msgSpace = message?.space || {};
|
||||
const ids = [space?.id, msgSpace.id];
|
||||
for (const id of ids) {
|
||||
rememberKnownSpace(id, space);
|
||||
const phone = phoneTargetFromSpaceId(id);
|
||||
if (phone) rememberKnownSpace(phone, space);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForConsumer() {
|
||||
if (consumerRes) return Promise.resolve();
|
||||
return new Promise((resolve) => consumerWaiters.push(resolve));
|
||||
}
|
||||
|
||||
function setConsumer(res) {
|
||||
consumerRes = res;
|
||||
const waiters = consumerWaiters;
|
||||
consumerWaiters = [];
|
||||
for (const resolve of waiters) resolve();
|
||||
}
|
||||
|
||||
function clearConsumer(res) {
|
||||
if (consumerRes === res) consumerRes = null;
|
||||
}
|
||||
|
||||
// Write one NDJSON line to the active consumer. Blocks until a consumer is
|
||||
// connected; if the write fails (consumer vanished mid-flight) we wait for a
|
||||
// new consumer and retry, so a message is never silently dropped here.
|
||||
async function deliver(line) {
|
||||
for (;;) {
|
||||
await waitForConsumer();
|
||||
const res = consumerRes;
|
||||
if (!res) continue;
|
||||
try {
|
||||
const flushed = res.write(line + "\n");
|
||||
if (!flushed) await once(res, "drain");
|
||||
return;
|
||||
} catch {
|
||||
clearConsumer(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeBinaryContent(content) {
|
||||
const meta = {
|
||||
type: content.type,
|
||||
id: content.id ?? null,
|
||||
name: content.name ?? null,
|
||||
mimeType: content.mimeType ?? null,
|
||||
size: typeof content.size === "number" ? content.size : null,
|
||||
};
|
||||
if (content.type === "voice" && typeof content.duration === "number") {
|
||||
meta.duration = content.duration;
|
||||
}
|
||||
|
||||
// Read the bytes eagerly and base64-inline them as `data` so the Python
|
||||
// adapter can cache the real file (the agent then sees images and can run
|
||||
// STT on voice notes). Spectrum content objects may not outlive this stream
|
||||
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
|
||||
// size is known up front) is forwarded as metadata only and the adapter falls
|
||||
// back to a text marker. A read failure must never break the inbound loop.
|
||||
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
|
||||
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
|
||||
console.error(
|
||||
`photon-sidecar: ${label} (${meta.size} bytes) ` +
|
||||
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
|
||||
);
|
||||
return meta;
|
||||
}
|
||||
if (typeof content.read === "function") {
|
||||
try {
|
||||
const buf = await content.read();
|
||||
// Guard the case where size was unknown but the bytes turn out to be
|
||||
// over the cap.
|
||||
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
|
||||
console.error(
|
||||
`photon-sidecar: ${label} (${buf.length} bytes) ` +
|
||||
`exceeds inline cap after read; forwarding metadata only`
|
||||
);
|
||||
return meta;
|
||||
}
|
||||
meta.data = Buffer.from(buf).toString("base64");
|
||||
meta.encoding = "base64";
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`photon-sidecar: failed to read ${content.type} bytes ` +
|
||||
"(forwarding metadata only): " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async function normalizeContent(content) {
|
||||
if (!content || typeof content !== "object") {
|
||||
return { type: "unknown" };
|
||||
}
|
||||
if (content.type === "text") {
|
||||
return { type: "text", text: content.text || "" };
|
||||
}
|
||||
if (content.type === "attachment" || content.type === "voice") {
|
||||
return await normalizeBinaryContent(content);
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
async function normalizeEvent(space, message) {
|
||||
try {
|
||||
const msgSpace = message.space || {};
|
||||
const ts = message.timestamp;
|
||||
return {
|
||||
messageId: message.id ?? null,
|
||||
platform: message.platform || space.__platform || "iMessage",
|
||||
space: {
|
||||
id: space.id ?? msgSpace.id ?? null,
|
||||
// iMessage spaces carry `type` ("dm"|"group") and `phone` directly.
|
||||
type: space.type ?? msgSpace.type ?? "dm",
|
||||
phone: space.phone ?? msgSpace.phone ?? null,
|
||||
},
|
||||
sender: { id: message.sender ? message.sender.id : null },
|
||||
content: await normalizeContent(message.content),
|
||||
timestamp:
|
||||
ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: failed to normalize inbound message: " + String(e)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// spectrum-ts handles in-session gRPC reconnects internally, but if the async
|
||||
// iterator itself throws or ends, this consumer would stop forever. Wrap it in
|
||||
// a re-subscribe loop with capped exponential backoff + jitter so inbound
|
||||
// always recovers (the adapter dedupes any catch-up replay).
|
||||
(async () => {
|
||||
let backoff = 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
for await (const [space, message] of app.messages) {
|
||||
backoff = 1000; // healthy traffic — reset
|
||||
// Only forward inbound messages (ignore our own outbound echoes).
|
||||
if (message && message.direction && message.direction !== "inbound") {
|
||||
continue;
|
||||
}
|
||||
rememberInboundSpace(space, message);
|
||||
const event = await normalizeEvent(space, message);
|
||||
if (!event) continue;
|
||||
await deliver(JSON.stringify(event));
|
||||
}
|
||||
console.error("photon-sidecar: inbound stream ended — re-subscribing");
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: inbound stream errored — restarting: " +
|
||||
(e && e.message ? e.message : String(e))
|
||||
);
|
||||
}
|
||||
await new Promise((r) =>
|
||||
setTimeout(r, backoff + Math.random() * backoff * 0.2)
|
||||
);
|
||||
backoff = Math.min(backoff * 2, 30000);
|
||||
}
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP control + inbound server (loopback only).
|
||||
|
||||
// Control-message bodies are tiny; cap the body so a compromised local peer
|
||||
// can't OOM the sidecar by streaming an unbounded request (defence-in-depth on
|
||||
// the loopback channel).
|
||||
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
throw new Error("request body too large");
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf-8");
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error("invalid JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorized(res) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
}
|
||||
|
||||
function badRequest(res, msg) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: msg }));
|
||||
}
|
||||
|
||||
function serverError(res) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
// Don't leak stack traces or raw exception text to the caller — even
|
||||
// though we listen on loopback, the supervisor logs the real error
|
||||
// and the client only needs a generic failure signal.
|
||||
res.end(JSON.stringify({ ok: false, error: "internal sidecar error" }));
|
||||
}
|
||||
|
||||
function ok(res, data) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, ...data }));
|
||||
}
|
||||
|
||||
function handleInbound(req, res) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/x-ndjson");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
// One consumer at a time — a fresh connection (e.g. after a reconnect)
|
||||
// supersedes the previous one.
|
||||
if (consumerRes && consumerRes !== res) {
|
||||
try {
|
||||
consumerRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setConsumer(res);
|
||||
// Heartbeat keeps the socket warm through idle periods and lets the Python
|
||||
// side detect a dead pipe promptly.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write("\n");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 25000);
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
clearConsumer(res);
|
||||
};
|
||||
req.on("close", cleanup);
|
||||
req.on("aborted", cleanup);
|
||||
res.on("error", cleanup);
|
||||
}
|
||||
|
||||
async function resolveSpace(spaceId) {
|
||||
const cached = knownSpaces.get(spaceId);
|
||||
if (cached) return cached;
|
||||
|
||||
const phoneTarget = phoneTargetFromSpaceId(spaceId);
|
||||
// A bare E.164 phone number addresses a DM. Resolve the user, then the (DM)
|
||||
// space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can
|
||||
// pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of
|
||||
// an opaque inbound space id. Photon also represents DM chat ids as
|
||||
// `any;-;+1...`; normalize those through the same path so replies to inbound
|
||||
// DMs still resolve after Python stores the inbound `space.id`.
|
||||
if (phoneTarget && imessage) {
|
||||
try {
|
||||
const im = imessage(app);
|
||||
const user = await im.user(phoneTarget);
|
||||
const space = await im.space(user);
|
||||
rememberKnownSpace(spaceId, space);
|
||||
rememberKnownSpace(phoneTarget, space);
|
||||
rememberKnownSpace(space?.id, space);
|
||||
return space;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: phone->DM resolution failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
// No cache hit and not a phone/DM target. spectrum-ts exposes no API to
|
||||
// rehydrate an arbitrary opaque space id: a Space is only obtained from the
|
||||
// inbound `[space, message]` stream (cached above in `knownSpaces`) or
|
||||
// reconstructed for a DM from its phone number. So a group space whose cache
|
||||
// entry was lost — e.g. after a sidecar restart with no fresh inbound message
|
||||
// in that group — cannot be resolved here; a new inbound message in the group
|
||||
// re-warms the cache. DMs are unaffected (reconstructed from the phone).
|
||||
throw new Error(`unable to resolve space id ${spaceId}`);
|
||||
}
|
||||
|
||||
// Constant-time token comparison — don't leak the token via `!==` timing.
|
||||
const _tokenBuf = Buffer.from(sharedToken);
|
||||
function tokenOk(header) {
|
||||
if (typeof header !== "string") return false;
|
||||
const h = Buffer.from(header);
|
||||
return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!tokenOk(req.headers["x-hermes-sidecar-token"])) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
// Long-lived inbound NDJSON stream.
|
||||
if (req.method === "GET" && req.url === "/inbound") {
|
||||
return handleInbound(req, res);
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
return res.end();
|
||||
}
|
||||
try {
|
||||
if (req.url === "/healthz") {
|
||||
return ok(res, {});
|
||||
}
|
||||
if (req.url === "/shutdown") {
|
||||
ok(res, {});
|
||||
setTimeout(() => process.kill(process.pid, "SIGTERM"), 50);
|
||||
return;
|
||||
}
|
||||
const body = await readBody(req);
|
||||
if (req.url === "/send") {
|
||||
const { spaceId, text } = body || {};
|
||||
if (!spaceId || typeof text !== "string") {
|
||||
return badRequest(res, "spaceId and text are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
const result = await space.send(spectrumText(text));
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/send-attachment") {
|
||||
const { spaceId, path, name, mimeType, caption, kind } =
|
||||
body || {};
|
||||
if (!spaceId || typeof path !== "string" || !path) {
|
||||
return badRequest(res, "spaceId and path are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
|
||||
// spectrum-ts infers name + MIME from the file extension; pass
|
||||
// overrides only when Hermes supplied them so a known-good
|
||||
// inference isn't clobbered with an empty string.
|
||||
const opts = {};
|
||||
if (name) opts.name = name;
|
||||
if (mimeType) opts.mimeType = mimeType;
|
||||
const builder =
|
||||
kind === "voice"
|
||||
? voice(path, Object.keys(opts).length ? opts : undefined)
|
||||
: attachment(path, Object.keys(opts).length ? opts : undefined);
|
||||
|
||||
const result = await space.send(builder);
|
||||
|
||||
// iMessage delivers the caption as a separate bubble; send it
|
||||
// after the media so the attachment renders first.
|
||||
if (caption && typeof caption === "string") {
|
||||
try {
|
||||
await space.send(spectrumText(caption));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: attachment sent but caption failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/typing") {
|
||||
const { spaceId, state = "start" } = body || {};
|
||||
if (!spaceId) return badRequest(res, "spaceId is required");
|
||||
if (state !== "start" && state !== "stop") {
|
||||
return badRequest(res, "state must be start or stop");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
await space.send(spectrumTyping(state));
|
||||
return ok(res, {});
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.end(JSON.stringify({ ok: false, error: "not found" }));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: handler error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
// serverError() intentionally returns a generic message — see its
|
||||
// body for the rationale.
|
||||
return serverError(res);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, bind, () => {
|
||||
console.error(`photon-sidecar: listening on ${bind}:${port}`);
|
||||
});
|
||||
|
||||
async function shutdown(signal) {
|
||||
console.error(`photon-sidecar: received ${signal}, stopping...`);
|
||||
try {
|
||||
await Promise.race([
|
||||
app.stop(),
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("photon-sidecar: app.stop() failed: " + String(e));
|
||||
}
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 500).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
// Don't let a stray promise rejection take the process down silently — handlers
|
||||
// catch their own errors, so log and keep serving (Python supervises restart on
|
||||
// a real fatal exit).
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(
|
||||
"photon-sidecar: unhandledRejection: " +
|
||||
(reason && reason.stack ? reason.stack : String(reason))
|
||||
);
|
||||
});
|
||||
1703
plugins/platforms/photon/sidecar/package-lock.json
generated
Normal file
1703
plugins/platforms/photon/sidecar/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
plugins/platforms/photon/sidecar/package.json
Normal file
24
plugins/platforms/photon/sidecar/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^1.18.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "8.6.1",
|
||||
"@opentelemetry/otlp-transformer": "0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.218.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
name: simplex-platform
|
||||
label: SimpleX Chat
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
description: >
|
||||
SimpleX Chat gateway adapter for Hermes Agent.
|
||||
Connects to a local simplex-chat daemon via WebSocket and relays
|
||||
|
|
@ -9,7 +9,7 @@ description: >
|
|||
SimpleX is decentralised and assigns no persistent user IDs —
|
||||
every contact is an opaque internal ID generated at connection
|
||||
time, making it one of the most private messengers available.
|
||||
author: Mibayy
|
||||
author: Mibayy, jooray
|
||||
# ``requires_env`` and ``optional_env`` entries are surfaced in the
|
||||
# ``hermes config`` UI via the platform-plugin env var injector in
|
||||
# ``hermes_cli/config.py``.
|
||||
|
|
@ -27,6 +27,18 @@ optional_env:
|
|||
description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all contacts? (true/false)"
|
||||
password: false
|
||||
- name: SIMPLEX_AUTO_ACCEPT
|
||||
description: "Auto-accept incoming contact requests (default: true)"
|
||||
prompt: "Auto-accept contact requests? (true/false)"
|
||||
password: false
|
||||
- name: SIMPLEX_GROUP_ALLOWED
|
||||
description: >-
|
||||
Comma-separated SimpleX group IDs the bot should participate in, or
|
||||
'*' to allow any group. Omit to ignore group messages entirely
|
||||
(safer default — a bot in a group otherwise processes every
|
||||
member's traffic).
|
||||
prompt: "Allowed group IDs (comma-separated, or '*' for any)"
|
||||
password: false
|
||||
- name: SIMPLEX_HOME_CHANNEL
|
||||
description: "Default contact/group ID for cron / notification delivery"
|
||||
prompt: "Home channel contact/group ID (or empty)"
|
||||
|
|
@ -35,3 +47,10 @@ optional_env:
|
|||
description: "Human label for the home channel (defaults to the ID)"
|
||||
prompt: "Home channel display name (or empty)"
|
||||
password: false
|
||||
- name: HERMES_SIMPLEX_TEXT_BATCH_DELAY
|
||||
description: >-
|
||||
Quiet-period seconds (default: 0.8) used to concatenate rapid-fire
|
||||
inbound text messages into a single MessageEvent — same pattern as
|
||||
Telegram's text batching.
|
||||
prompt: "Text batch flush delay in seconds (default 0.8)"
|
||||
password: false
|
||||
|
|
|
|||
135
plugins/plugin_utils.py
Normal file
135
plugins/plugin_utils.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Shared concurrency helpers for plugin authors.
|
||||
|
||||
The most common plugin footgun is the lazy process-wide singleton:
|
||||
|
||||
_client = None
|
||||
|
||||
def get_client():
|
||||
global _client
|
||||
if _client is not None:
|
||||
return _client
|
||||
_client = ExpensiveClient(...) # <-- TOCTOU: two threads both run this
|
||||
return _client
|
||||
|
||||
When two threads call ``get_client()`` before the singleton is set, both pass
|
||||
the ``is not None`` guard, both run the expensive initialization, and the
|
||||
second write clobbers the first — leaking whatever resource the first client
|
||||
opened (connections, file handles, background threads).
|
||||
|
||||
Multi-threaded agent sessions share one process (delegated tool calls,
|
||||
background workers, the self-improvement fork), so this race is reachable in
|
||||
practice. Rather than make every plugin author remember to hand-roll
|
||||
double-checked locking, this module gives them two thread-safe primitives:
|
||||
|
||||
* :func:`lazy_singleton` — decorator for the zero-arg accessor case.
|
||||
* :class:`SingletonSlot` — manual slot for accessors that build different
|
||||
instances depending on a config/key argument.
|
||||
|
||||
Both are import-light (stdlib ``threading`` only) so any plugin can import
|
||||
them without dragging in heavyweight host modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import threading
|
||||
from typing import Callable, Generic, Optional, TypeVar
|
||||
|
||||
__all__ = ["lazy_singleton", "SingletonSlot"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def lazy_singleton(factory: Callable[[], T]) -> Callable[[], T]:
|
||||
"""Wrap a zero-argument factory into a thread-safe lazy singleton accessor.
|
||||
|
||||
The wrapped callable returns the same instance on every call; the factory
|
||||
runs exactly once even under concurrent first calls, using double-checked
|
||||
locking. A ``.reset()`` attribute is attached for tests/teardown.
|
||||
|
||||
Example::
|
||||
|
||||
@lazy_singleton
|
||||
def get_client():
|
||||
return ExpensiveClient(load_config())
|
||||
|
||||
client = get_client() # built once, safe across threads
|
||||
get_client.reset() # drop the instance (next call rebuilds)
|
||||
|
||||
Note: if the factory raises, no instance is cached and the next call
|
||||
retries (the lock is released either way).
|
||||
"""
|
||||
lock = threading.Lock()
|
||||
box: list = [] # one-element [instance]; empty == not yet built
|
||||
|
||||
@functools.wraps(factory)
|
||||
def accessor() -> T:
|
||||
if box:
|
||||
return box[0]
|
||||
with lock:
|
||||
if box: # re-check inside the lock
|
||||
return box[0]
|
||||
instance = factory()
|
||||
box.append(instance)
|
||||
return instance
|
||||
|
||||
def reset() -> None:
|
||||
with lock:
|
||||
box.clear()
|
||||
|
||||
accessor.reset = reset # type: ignore[attr-defined]
|
||||
return accessor
|
||||
|
||||
|
||||
class SingletonSlot(Generic[T]):
|
||||
"""Thread-safe lazy slot for accessors that take a build argument.
|
||||
|
||||
Use this when the cached instance depends on a config/key passed to the
|
||||
accessor (so a bare zero-arg :func:`lazy_singleton` doesn't fit). The slot
|
||||
caches the first successfully-built instance and ignores the argument on
|
||||
subsequent calls — matching the established "first config wins" singleton
|
||||
semantics most plugins already rely on.
|
||||
|
||||
Example::
|
||||
|
||||
_slot: SingletonSlot[Honcho] = SingletonSlot()
|
||||
|
||||
def get_honcho_client(config=None):
|
||||
return _slot.get(lambda: Honcho(**resolve(config)))
|
||||
|
||||
def reset_honcho_client():
|
||||
_slot.reset()
|
||||
|
||||
The factory runs at most once even under concurrent first calls. If the
|
||||
factory raises, nothing is cached and the next call retries.
|
||||
"""
|
||||
|
||||
__slots__ = ("_lock", "_value", "_set")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._value: Optional[T] = None
|
||||
self._set = False
|
||||
|
||||
def get(self, factory: Callable[[], T]) -> T:
|
||||
# Fast path: already built, no lock needed (a set bool + ref read is
|
||||
# atomic under CPython's GIL).
|
||||
if self._set:
|
||||
return self._value # type: ignore[return-value]
|
||||
with self._lock:
|
||||
if self._set: # re-check inside the lock
|
||||
return self._value # type: ignore[return-value]
|
||||
value = factory()
|
||||
self._value = value
|
||||
self._set = True
|
||||
return value
|
||||
|
||||
def peek(self) -> Optional[T]:
|
||||
"""Return the cached instance without building it (None if unset)."""
|
||||
return self._value if self._set else None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop the cached instance so the next ``get()`` rebuilds it."""
|
||||
with self._lock:
|
||||
self._value = None
|
||||
self._set = False
|
||||
|
|
@ -291,6 +291,7 @@ def _build_payload(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fal_client: Any = None
|
||||
_fal_client_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_fal_client() -> Any:
|
||||
|
|
@ -298,13 +299,19 @@ def _load_fal_client() -> Any:
|
|||
|
||||
Delegates the actual import to :func:`tools.fal_common.import_fal_client`
|
||||
so the ``lazy_deps`` ensure-install handling stays in one place.
|
||||
|
||||
Thread-safe via double-checked locking: concurrent first calls import
|
||||
the SDK exactly once instead of each racing thread re-running the import.
|
||||
"""
|
||||
global _fal_client
|
||||
if _fal_client is not None:
|
||||
return _fal_client
|
||||
from tools.fal_common import import_fal_client
|
||||
_fal_client = import_fal_client()
|
||||
return _fal_client
|
||||
with _fal_client_lock:
|
||||
if _fal_client is not None: # re-check inside the lock
|
||||
return _fal_client
|
||||
from tools.fal_common import import_fal_client
|
||||
_fal_client = import_fal_client()
|
||||
return _fal_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
"""Parallel.ai web search + content extraction — plugin form.
|
||||
|
||||
Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses two
|
||||
distinct Parallel SDK clients:
|
||||
Subclasses :class:`agent.web_search_provider.WebSearchProvider`.
|
||||
|
||||
- ``Parallel`` (sync) — for :meth:`search`
|
||||
- ``AsyncParallel`` (async) — for :meth:`extract`
|
||||
Search runs on one of two transports, picked by credential:
|
||||
|
||||
This is the first plugin to exercise the **async-extract** code path in
|
||||
the ABC: :meth:`extract` is declared ``async def``, and the dispatcher
|
||||
in :func:`tools.web_tools.web_extract_tool` detects coroutines via
|
||||
- **No key →** the free hosted Search MCP at ``https://search.parallel.ai/mcp``
|
||||
(anonymous Streamable-HTTP JSON-RPC). This makes ``web_search`` work out of
|
||||
the box with zero setup, which is why ``parallel`` is the keyless default
|
||||
backend in :func:`tools.web_tools._get_backend`.
|
||||
- **``PARALLEL_API_KEY`` →** the ``parallel`` SDK's v1 ``search`` / ``extract``
|
||||
REST endpoints (objective-tuned, mode-selectable, higher rate limits).
|
||||
|
||||
Extract mirrors search: keyed uses the async SDK (``AsyncParallel``) v1
|
||||
``extract``; keyless uses the free MCP's ``web_fetch``. :meth:`extract` is
|
||||
declared ``async def`` and the dispatcher in
|
||||
:func:`tools.web_tools.web_extract_tool` detects coroutines via
|
||||
:func:`inspect.iscoroutinefunction` and awaits.
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
|
@ -17,25 +23,66 @@ Config keys this provider responds to::
|
|||
search_backend: "parallel" # explicit per-capability
|
||||
extract_backend: "parallel" # explicit per-capability
|
||||
backend: "parallel" # shared fallback
|
||||
# Optional: search mode (default "agentic"; also "fast" or "one-shot")
|
||||
# via the PARALLEL_SEARCH_MODE env var.
|
||||
# Optional: search mode (default "advanced"; also "basic")
|
||||
# via the PARALLEL_SEARCH_MODE env var. REST path only.
|
||||
|
||||
Env vars::
|
||||
|
||||
PARALLEL_API_KEY=... # https://parallel.ai (required)
|
||||
PARALLEL_SEARCH_MODE=agentic # optional: agentic|fast|one-shot
|
||||
PARALLEL_API_KEY=... # https://parallel.ai (optional — unlocks
|
||||
# the v1 REST Search API; without it,
|
||||
# search and extract use the free MCP)
|
||||
PARALLEL_SEARCH_MODE=advanced # optional: basic|advanced (legacy
|
||||
# fast/one-shot map to basic, agentic to
|
||||
# advanced). REST path only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Free hosted Search MCP — anonymous-friendly, used when no PARALLEL_API_KEY is
|
||||
# configured. Docs: https://docs.parallel.ai/integrations/mcp/search-mcp
|
||||
_MCP_SEARCH_URL = "https://search.parallel.ai/mcp"
|
||||
_MCP_PROTOCOL_VERSION = "2025-06-18"
|
||||
# Deliberately generic client identity. Project policy (see the telemetry PR
|
||||
# policy in AGENTS.md) forbids third-party usage attribution without an
|
||||
# explicit user opt-in, so neither clientInfo nor the User-Agent names
|
||||
# hermes. MCP requires *a* clientInfo; a neutral one satisfies the spec
|
||||
# without attributing traffic.
|
||||
_MCP_CLIENT_NAME = "mcp-web-client"
|
||||
_MCP_CLIENT_VERSION = "1.0.0"
|
||||
_MCP_USER_AGENT = f"{_MCP_CLIENT_NAME}/{_MCP_CLIENT_VERSION}"
|
||||
_MCP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# Free-tier attribution. The hosted Search MCP is free to use; surfacing this
|
||||
# on keyless results credits Parallel and matches the free-tier terms
|
||||
# (https://parallel.ai/customer-terms).
|
||||
_FREE_MCP_ATTRIBUTION = (
|
||||
"Search powered by the free Parallel Web Search MCP (https://parallel.ai)."
|
||||
)
|
||||
|
||||
|
||||
def _new_session_id() -> str:
|
||||
"""Mint a fresh Parallel ``session_id`` for a single tool call.
|
||||
|
||||
Per-call rather than process-global: one process serves many unrelated
|
||||
chats in the gateway/batch runners, and a shared id would pool their
|
||||
searches into one Parallel session. The prefix is deliberately generic
|
||||
(no hermes attribution — telemetry policy).
|
||||
"""
|
||||
return f"{_MCP_CLIENT_NAME}-{uuid.uuid4().hex}"
|
||||
|
||||
# Module-level note: the canonical cache slots ``_parallel_client`` and
|
||||
# ``_async_parallel_client`` live on :mod:`tools.web_tools` so tests that do
|
||||
# ``tools.web_tools._parallel_client = None`` between cases see fresh state.
|
||||
|
|
@ -133,11 +180,319 @@ _get_async_parallel_client = _get_async_client
|
|||
|
||||
|
||||
def _resolve_search_mode() -> str:
|
||||
"""Return the validated PARALLEL_SEARCH_MODE value (default "agentic")."""
|
||||
mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip()
|
||||
if mode not in {"fast", "one-shot", "agentic"}:
|
||||
mode = "agentic"
|
||||
return mode
|
||||
"""Return the validated v1 search mode (default "advanced").
|
||||
|
||||
V1 collapses the three Beta modes into two. We accept the v1 values
|
||||
directly and map the legacy Beta values for back-compat with anyone who
|
||||
still sets ``PARALLEL_SEARCH_MODE=fast|one-shot|agentic``:
|
||||
|
||||
- ``fast`` / ``one-shot`` → ``basic`` (lower latency)
|
||||
- ``agentic`` → ``advanced`` (higher quality, the v1 default)
|
||||
"""
|
||||
mode = os.getenv("PARALLEL_SEARCH_MODE", "advanced").lower().strip()
|
||||
if mode == "basic" or mode in {"fast", "one-shot"}:
|
||||
return "basic"
|
||||
# advanced, legacy "agentic", and anything unrecognized → the v1 default.
|
||||
return "advanced"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Free Search MCP transport (keyless path)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A small hand-rolled Streamable-HTTP JSON-RPC client for the hosted Search
|
||||
# MCP, rather than the full MCP-client subsystem: we only call two tools
|
||||
# (``web_search`` / ``web_fetch``), so keeping it inline lets web_search and
|
||||
# web_extract stay ordinary tools with the MCP endpoint as just their wire
|
||||
# protocol.
|
||||
|
||||
|
||||
def _mcp_headers(
|
||||
session_id: str | None,
|
||||
api_key: str | None,
|
||||
protocol_version: str | None = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Headers for an MCP request.
|
||||
|
||||
A Bearer token is attached only when we actually hold a key — the free
|
||||
endpoint is anonymous, and sending an empty/garbage token would make it
|
||||
401 instead of serving the anonymous tier. After ``initialize`` the
|
||||
Streamable-HTTP spec expects the negotiated ``MCP-Protocol-Version`` on
|
||||
every follow-up request, so we echo it once known.
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"User-Agent": _MCP_USER_AGENT,
|
||||
}
|
||||
if session_id:
|
||||
headers["Mcp-Session-Id"] = session_id
|
||||
if protocol_version:
|
||||
headers["MCP-Protocol-Version"] = protocol_version
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def _iter_mcp_messages(text: str):
|
||||
"""Yield JSON-RPC message dicts from a plain-JSON or SSE response body.
|
||||
|
||||
Handles ``application/json`` (a single object) and ``text/event-stream``
|
||||
(SSE: events separated by blank lines; an event's one-or-more ``data:``
|
||||
lines concatenate into a single JSON payload). Unparseable chunks and
|
||||
non-``data`` SSE fields (``event:``/``id:``/comments) are skipped.
|
||||
"""
|
||||
def _emit(payload):
|
||||
# Streamable HTTP allows batching responses/notifications into a JSON
|
||||
# array — flatten so callers always see individual message dicts.
|
||||
if isinstance(payload, list):
|
||||
yield from payload
|
||||
elif payload is not None:
|
||||
yield payload
|
||||
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return
|
||||
if body.startswith("{") or body.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
yield from _emit(parsed)
|
||||
return
|
||||
|
||||
data_lines: List[str] = []
|
||||
|
||||
def _flush():
|
||||
if not data_lines:
|
||||
return None
|
||||
try:
|
||||
return json.loads("\n".join(data_lines))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
for raw in body.split("\n"):
|
||||
line = raw.rstrip("\r")
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].lstrip())
|
||||
elif line.strip() == "": # event boundary
|
||||
yield from _emit(_flush())
|
||||
data_lines = []
|
||||
yield from _emit(_flush())
|
||||
|
||||
|
||||
def _mcp_response_envelope(text: str, request_id: str) -> Dict[str, Any]:
|
||||
"""Select the JSON-RPC response for *request_id* from an MCP response body.
|
||||
|
||||
Streamable-HTTP servers may emit progress/log notifications before the
|
||||
final result, so we scan the whole stream and return the result/error
|
||||
message whose ``id`` matches our request. Falls back to the last
|
||||
result/error-bearing message if no id matches; ``{}`` if none is present.
|
||||
"""
|
||||
fallback: Dict[str, Any] = {}
|
||||
for msg in _iter_mcp_messages(text):
|
||||
if not isinstance(msg, dict) or not ("result" in msg or "error" in msg):
|
||||
continue
|
||||
if msg.get("id") == request_id:
|
||||
return msg
|
||||
fallback = msg
|
||||
return fallback
|
||||
|
||||
|
||||
def _mcp_payload(envelope: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract the tool result payload from a ``tools/call`` envelope.
|
||||
|
||||
Prefers ``structuredContent`` (authoritative machine-readable form);
|
||||
otherwise scans text blocks for the first JSON-parseable one. Raises on a
|
||||
JSON-RPC error or a tool-level ``isError``.
|
||||
"""
|
||||
if "error" in envelope:
|
||||
raise RuntimeError(f"Parallel MCP error: {str(envelope['error'])[:500]}")
|
||||
result = envelope.get("result") or {}
|
||||
if result.get("isError"):
|
||||
raise RuntimeError(f"Parallel MCP tool error: {str(result)[:500]}")
|
||||
|
||||
structured = result.get("structuredContent")
|
||||
if isinstance(structured, dict):
|
||||
return structured
|
||||
|
||||
for block in result.get("content", []) or []:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = str(block.get("text") or "")
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"Parallel MCP returned no parseable content: {str(result)[:500]}"
|
||||
)
|
||||
|
||||
|
||||
def _mcp_call(
|
||||
tool_name: str, arguments: Dict[str, Any], api_key: str | None
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the MCP handshake then a single ``tools/call`` and return its payload.
|
||||
|
||||
initialize → (capture ``Mcp-Session-Id``) → notifications/initialized →
|
||||
tools/call ``tool_name``. Returns the parsed tool payload dict (see
|
||||
:func:`_mcp_payload`). A Bearer token is attached only when *api_key* is set.
|
||||
"""
|
||||
with httpx.Client(timeout=_MCP_TIMEOUT_SECONDS) as client:
|
||||
# 1. initialize — capture the server-assigned MCP session id.
|
||||
init_id = str(uuid.uuid4())
|
||||
init = client.post(
|
||||
_MCP_SEARCH_URL,
|
||||
headers=_mcp_headers(None, api_key),
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": init_id,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": _MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": _MCP_CLIENT_NAME,
|
||||
"version": _MCP_CLIENT_VERSION,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
init.raise_for_status()
|
||||
# Only echo a session id the server actually issued. Stateless
|
||||
# Streamable-HTTP servers may omit it; inventing one and sending it on
|
||||
# follow-up requests can get those requests rejected (the server never
|
||||
# created that session). When absent, the Mcp-Session-Id header is simply
|
||||
# omitted (see _mcp_headers). This is separate from the tool-arg
|
||||
# ``session_id`` below, which is a client-minted rate-limit/grouping id.
|
||||
mcp_session_id = init.headers.get("mcp-session-id")
|
||||
init_env = _mcp_response_envelope(init.text, init_id)
|
||||
# Echo the negotiated protocol version on every post-init request, per
|
||||
# the Streamable-HTTP spec (servers may enforce it).
|
||||
negotiated_version = (
|
||||
(init_env.get("result") or {}).get("protocolVersion")
|
||||
or _MCP_PROTOCOL_VERSION
|
||||
)
|
||||
|
||||
# 2. notifications/initialized — required handshake ack.
|
||||
client.post(
|
||||
_MCP_SEARCH_URL,
|
||||
headers=_mcp_headers(mcp_session_id, api_key, negotiated_version),
|
||||
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
)
|
||||
|
||||
# 3. tools/call.
|
||||
call_id = str(uuid.uuid4())
|
||||
call = client.post(
|
||||
_MCP_SEARCH_URL,
|
||||
headers=_mcp_headers(mcp_session_id, api_key, negotiated_version),
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": call_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": tool_name, "arguments": arguments},
|
||||
},
|
||||
)
|
||||
call.raise_for_status()
|
||||
return _mcp_payload(_mcp_response_envelope(call.text, call_id))
|
||||
|
||||
|
||||
def _mcp_web_search(query: str, limit: int, api_key: str | None) -> Dict[str, Any]:
|
||||
"""Run a ``web_search`` tool call against the hosted Search MCP.
|
||||
|
||||
Returns the standard provider search shape
|
||||
(``{"success": True, "data": {"web": [...]}}``). The MCP serves a fixed
|
||||
result count, so ``limit`` is applied client-side. The MCP requires
|
||||
``objective`` (REST treats it as optional), so we mirror the query.
|
||||
"""
|
||||
payload = _mcp_call(
|
||||
"web_search",
|
||||
{
|
||||
"objective": query,
|
||||
"search_queries": [query],
|
||||
"session_id": _new_session_id(),
|
||||
},
|
||||
api_key,
|
||||
)
|
||||
|
||||
web_results: List[Dict[str, Any]] = []
|
||||
for i, result in enumerate((payload.get("results") or [])[: max(limit, 1)]):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
excerpts = result.get("excerpts") or []
|
||||
web_results.append(
|
||||
{
|
||||
"url": result.get("url") or "",
|
||||
"title": result.get("title") or "",
|
||||
"description": " ".join(excerpts) if excerpts else "",
|
||||
"position": i + 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Credit the free tier (anonymous path only — keyed search uses REST and
|
||||
# carries no attribution).
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"web": web_results},
|
||||
"provider": "parallel",
|
||||
"attribution": _FREE_MCP_ATTRIBUTION,
|
||||
}
|
||||
|
||||
|
||||
def _mcp_web_fetch(urls: List[str], api_key: str | None) -> List[Dict[str, Any]]:
|
||||
"""Run a ``web_fetch`` tool call against the hosted Search MCP.
|
||||
|
||||
Returns the per-URL extract shape that
|
||||
:func:`tools.web_tools.web_extract_tool` expects — exactly one row per input
|
||||
URL, in request order (including duplicates). We pass ``full_content=True``
|
||||
so the page body comes back as markdown (matching the keyed SDK path and
|
||||
what extract callers/summarizers expect), falling back to excerpts only when
|
||||
full content is absent. Any input the MCP didn't return is emitted as a
|
||||
per-URL error row.
|
||||
"""
|
||||
payload = _mcp_call(
|
||||
"web_fetch",
|
||||
{"urls": list(urls), "full_content": True, "session_id": _new_session_id()},
|
||||
api_key,
|
||||
)
|
||||
|
||||
# Index the response by URL, then emit one row per *input* URL in order so
|
||||
# duplicates and positional alignment with the request list are preserved.
|
||||
by_url: Dict[str, Dict[str, Any]] = {}
|
||||
for item in payload.get("results") or []:
|
||||
if isinstance(item, dict) and item.get("url"):
|
||||
by_url.setdefault(item["url"], item)
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for url in urls:
|
||||
item = by_url.get(url)
|
||||
if item is None:
|
||||
results.append(
|
||||
{
|
||||
"url": url,
|
||||
"title": "",
|
||||
"content": "",
|
||||
"error": "extraction failed (no content returned)",
|
||||
"metadata": {"sourceURL": url},
|
||||
}
|
||||
)
|
||||
continue
|
||||
title = item.get("title") or ""
|
||||
# Prefer the full page body; fall back to joined excerpts (mirrors the
|
||||
# keyed SDK extract path).
|
||||
content = item.get("full_content") or "\n\n".join(item.get("excerpts") or [])
|
||||
results.append(
|
||||
{
|
||||
"url": url,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"raw_content": content,
|
||||
"metadata": {"sourceURL": url, "title": title},
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class ParallelWebSearchProvider(WebSearchProvider):
|
||||
|
|
@ -152,7 +507,14 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
return "Parallel"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when ``PARALLEL_API_KEY`` is set to a non-empty value."""
|
||||
"""Return True when ``PARALLEL_API_KEY`` is set.
|
||||
|
||||
Deliberately key-based: this gates the registry's active-provider walk
|
||||
and the ``hermes tools`` picker (auto-selecting Parallel for a user who
|
||||
hasn't named it), so it must not claim availability on the keyless path.
|
||||
The keyless free-MCP path is reached independently via
|
||||
:func:`tools.web_tools._get_backend`'s ``parallel`` terminal default.
|
||||
"""
|
||||
return bool(os.getenv("PARALLEL_API_KEY", "").strip())
|
||||
|
||||
def supports_search(self) -> bool:
|
||||
|
|
@ -164,9 +526,11 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
|
||||
"""Execute a Parallel search (sync).
|
||||
|
||||
Uses the ``beta.search`` endpoint with the configured mode
|
||||
(``PARALLEL_SEARCH_MODE`` env var, default "agentic"). Limit is
|
||||
capped at 20 server-side.
|
||||
With ``PARALLEL_API_KEY`` set, uses the v1 ``search`` REST endpoint with
|
||||
the configured mode (``PARALLEL_SEARCH_MODE`` env var, default
|
||||
"advanced"; limit requested via advanced_settings.max_results, capped at
|
||||
20). Without a key, falls back to the free hosted Search MCP so search
|
||||
still works with zero setup.
|
||||
"""
|
||||
try:
|
||||
from tools.interrupt import is_interrupted
|
||||
|
|
@ -174,19 +538,31 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
if is_interrupted():
|
||||
return {"success": False, "error": "Interrupted"}
|
||||
|
||||
api_key = os.getenv("PARALLEL_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
logger.info(
|
||||
"Parallel search (free MCP): '%s' (limit=%d)", query, limit
|
||||
)
|
||||
return _mcp_web_search(query, limit, api_key=None)
|
||||
|
||||
mode = _resolve_search_mode()
|
||||
logger.info(
|
||||
"Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit
|
||||
"Parallel search (v1 REST): '%s' (mode=%s, limit=%d)",
|
||||
query, mode, limit,
|
||||
)
|
||||
response = _get_sync_client().beta.search(
|
||||
# v1 Search API. Request the caller's limit via max_results (capped
|
||||
# at 20) so we don't rely on the API default — the slice below can
|
||||
# only trim, not ask for more.
|
||||
response = _get_sync_client().search(
|
||||
search_queries=[query],
|
||||
objective=query,
|
||||
mode=mode,
|
||||
max_results=min(limit, 20),
|
||||
session_id=_new_session_id(),
|
||||
advanced_settings={"max_results": min(max(limit, 1), 20)},
|
||||
)
|
||||
|
||||
web_results = []
|
||||
for i, result in enumerate(response.results or []):
|
||||
for i, result in enumerate((response.results or [])[: max(limit, 1)]):
|
||||
excerpts = result.excerpts or []
|
||||
web_results.append(
|
||||
{
|
||||
|
|
@ -197,6 +573,8 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
}
|
||||
)
|
||||
|
||||
# Paid/REST path: no attribution and no "[Parallel]" label — the
|
||||
# branding is specifically for the free Search MCP tier.
|
||||
return {"success": True, "data": {"web": web_results}}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
|
@ -212,7 +590,12 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
async def extract(
|
||||
self, urls: List[str], **kwargs: Any
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Extract content from one or more URLs via the async SDK.
|
||||
"""Extract content from one or more URLs.
|
||||
|
||||
With ``PARALLEL_API_KEY`` set, uses the async SDK's v1 ``extract`` for
|
||||
full page content. Without a key, falls back to the free hosted Search
|
||||
MCP's ``web_fetch`` tool so extraction works with zero setup, mirroring
|
||||
the keyless search path.
|
||||
|
||||
Returns the legacy list-of-results shape that
|
||||
:func:`tools.web_tools.web_extract_tool` expects: one entry per
|
||||
|
|
@ -227,10 +610,21 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
{"url": u, "error": "Interrupted", "title": ""} for u in urls
|
||||
]
|
||||
|
||||
logger.info("Parallel extract: %d URL(s)", len(urls))
|
||||
response = await _get_async_client().beta.extract(
|
||||
api_key = os.getenv("PARALLEL_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
logger.info(
|
||||
"Parallel extract (free MCP web_fetch): %d URL(s)", len(urls)
|
||||
)
|
||||
# _mcp_web_fetch is sync httpx; run off the event loop.
|
||||
return await asyncio.to_thread(_mcp_web_fetch, list(urls), None)
|
||||
|
||||
logger.info("Parallel extract (v1 REST): %d URL(s)", len(urls))
|
||||
# v1 Extract API (client.extract, /v1/extract); full_content is set
|
||||
# via advanced_settings.
|
||||
response = await _get_async_client().extract(
|
||||
urls=urls,
|
||||
full_content=True,
|
||||
advanced_settings={"full_content": True},
|
||||
session_id=_new_session_id(),
|
||||
)
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
|
@ -251,13 +645,20 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
)
|
||||
|
||||
for error in response.errors or []:
|
||||
err_url = getattr(error, "url", "") or ""
|
||||
err_msg = (
|
||||
getattr(error, "message", None)
|
||||
or getattr(error, "content", None)
|
||||
or getattr(error, "error_type", None)
|
||||
or "extraction failed"
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"url": error.url or "",
|
||||
"url": err_url,
|
||||
"title": "",
|
||||
"content": "",
|
||||
"error": error.content or error.error_type or "extraction failed",
|
||||
"metadata": {"sourceURL": error.url or ""},
|
||||
"error": err_msg,
|
||||
"metadata": {"sourceURL": err_url},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -279,12 +680,16 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Parallel",
|
||||
"badge": "paid",
|
||||
"tag": "Objective-tuned search + parallel page extraction.",
|
||||
"badge": "free",
|
||||
"tag": (
|
||||
"Free web search + extraction via Parallel's hosted Search MCP "
|
||||
"— no key needed. Add PARALLEL_API_KEY for the v1 REST Search "
|
||||
"API (richer modes, higher limits)."
|
||||
),
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "PARALLEL_API_KEY",
|
||||
"prompt": "Parallel API key",
|
||||
"prompt": "Parallel API key (optional — unlocks the v1 REST Search API)",
|
||||
"url": "https://parallel.ai",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue