opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
+26
-221
@@ -141,71 +141,36 @@ def _load_web_config() -> dict:
|
||||
except (ImportError, Exception):
|
||||
return {}
|
||||
|
||||
# Recognized web backend names (config values accepted in ``web.backend`` /
|
||||
# ``web.search_backend`` / ``web.extract_backend``). Kept as a single source of
|
||||
# truth for config validation across the selection helpers.
|
||||
_KNOWN_WEB_BACKENDS = frozenset(
|
||||
{"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}
|
||||
)
|
||||
|
||||
# Backends that only service web_search (their provider's ``supports_extract()``
|
||||
# is False). They are skipped during *extract* auto-detect so a search-only
|
||||
# credential (e.g. SEARXNG_URL) does not shadow the keyless Parallel free-MCP
|
||||
# fallback, which would otherwise leave web_extract broken on a no-key install.
|
||||
_SEARCH_ONLY_BACKENDS = frozenset({"searxng", "brave-free", "ddgs", "xai"})
|
||||
|
||||
|
||||
def _get_backend(capability: str = "search") -> str:
|
||||
def _get_backend() -> str:
|
||||
"""Determine which web backend to use (shared fallback).
|
||||
|
||||
Reads ``web.backend`` from config.yaml (set by ``hermes tools``).
|
||||
Falls back to whichever API key is present for users who configured
|
||||
keys manually without running setup.
|
||||
|
||||
``capability`` ("search" | "extract") only affects auto-detect: for
|
||||
``extract`` we skip search-only backends (``_SEARCH_ONLY_BACKENDS``) so a
|
||||
search-only credential never shadows the keyless Parallel free-MCP extract
|
||||
fallback. An explicit ``web.backend`` value is honored as-is (explicit wins,
|
||||
surfacing that backend's own search-only error rather than rerouting).
|
||||
"""
|
||||
configured = (_load_web_config().get("backend") or "").lower().strip()
|
||||
if configured in _KNOWN_WEB_BACKENDS:
|
||||
if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}:
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
# available backend. Explicit user credentials (TAVILY_API_KEY etc.)
|
||||
# beat the managed-tool-gateway probe so a deliberate setup is not
|
||||
# pre-empted by a Nous OAuth token whose subscription tier may not
|
||||
# actually grant web-search access (the gateway then fails at runtime
|
||||
# with "no subscription" and the tool returns an error to the agent
|
||||
# without falling back). Free-tier backends (searxng / brave-free /
|
||||
# keyless parallel / ddgs) trail the keyed ones.
|
||||
# available backend. Firecrawl also counts as available when the managed
|
||||
# tool gateway is configured for Nous subscribers.
|
||||
# Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so
|
||||
# existing paid setups are unaffected.
|
||||
backend_candidates = (
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("tavily", _has_env("TAVILY_API_KEY")),
|
||||
("exa", _has_env("EXA_API_KEY")),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")),
|
||||
("firecrawl", _is_tool_gateway_ready()),
|
||||
("searxng", _has_env("SEARXNG_URL")),
|
||||
("brave-free", _has_env("BRAVE_SEARCH_API_KEY")),
|
||||
# Keyless Parallel free MCP — always available, the intended no-key
|
||||
# default for both search and extract. Ahead of ddgs (search-only, so it
|
||||
# can't service web_extract); ddgs stays reachable via web.backend=ddgs.
|
||||
("parallel", True),
|
||||
("ddgs", _ddgs_package_importable()),
|
||||
)
|
||||
for backend, available in backend_candidates:
|
||||
if not available:
|
||||
continue
|
||||
# For extract, skip search-only backends so the keyless Parallel
|
||||
# free-MCP fallback (which can fetch URLs) is reached instead.
|
||||
if capability == "extract" and backend in _SEARCH_ONLY_BACKENDS:
|
||||
continue
|
||||
return backend
|
||||
if available:
|
||||
return backend
|
||||
|
||||
# Defensive terminal (the keyless ``parallel`` candidate above is always
|
||||
# available, so this is effectively unreachable).
|
||||
return "parallel"
|
||||
return "firecrawl" # default (backward compat)
|
||||
|
||||
|
||||
def _get_search_backend() -> str:
|
||||
@@ -236,19 +201,14 @@ def _get_extract_backend() -> str:
|
||||
def _get_capability_backend(capability: str) -> str:
|
||||
"""Shared helper for per-capability backend selection.
|
||||
|
||||
Reads ``web.{capability}_backend`` from config. Any explicit value is
|
||||
honored **regardless of availability** — including unrecognized typos like
|
||||
``parrallel`` — so the dispatcher surfaces that backend's own setup/config
|
||||
error rather than silently rerouting to the keyless Parallel default (which
|
||||
would send user queries to a different provider and hide the
|
||||
misconfiguration). This matches ``web_search_registry``'s "explicit config
|
||||
wins" rule. Only an *unset* value falls through to ``_get_backend()``.
|
||||
Reads ``web.{capability}_backend`` from config; if set and available,
|
||||
uses it. Otherwise falls through to the shared ``_get_backend()``.
|
||||
"""
|
||||
cfg = _load_web_config()
|
||||
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
|
||||
if specific:
|
||||
if specific and _is_backend_available(specific):
|
||||
return specific
|
||||
return _get_backend(capability)
|
||||
return _get_backend()
|
||||
|
||||
|
||||
def _is_backend_available(backend: str) -> bool:
|
||||
@@ -256,8 +216,6 @@ def _is_backend_available(backend: str) -> bool:
|
||||
if backend == "exa":
|
||||
return _has_env("EXA_API_KEY")
|
||||
if backend == "parallel":
|
||||
# Credential probe: True only with a real key. The keyless free-MCP
|
||||
# fallback is handled by _get_backend()'s terminal default, not here.
|
||||
return _has_env("PARALLEL_API_KEY")
|
||||
if backend == "firecrawl":
|
||||
return check_firecrawl_api_key()
|
||||
@@ -810,17 +768,6 @@ def _ensure_web_plugins_loaded() -> None:
|
||||
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
|
||||
the underlying discovery call is idempotent and cheap on subsequent
|
||||
invocations.
|
||||
|
||||
Triggering discovery is necessary but not *sufficient*: the sweep can
|
||||
finish without registering the bundled web providers (its exception
|
||||
swallowed below as a warning, a packaged layout where discovery ran before
|
||||
the bundled tree was importable, or a stale empty-discovery cache). When
|
||||
that happens the registry is empty and *both* web_search and web_extract
|
||||
dead-end on "No web {search,extract} provider configured" — even though the
|
||||
keyless Parallel default is supposed to work with zero setup. So after
|
||||
discovery we verify the keyless default landed and, if not, register the
|
||||
bundled providers directly (see
|
||||
:func:`_register_bundled_web_providers_directly`).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
@@ -833,87 +780,6 @@ def _ensure_web_plugins_loaded() -> None:
|
||||
# clue in normal logs about the real cause.
|
||||
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
|
||||
|
||||
# Belt-and-suspenders: guarantee the keyless Parallel default (the
|
||||
# documented zero-setup backend for both web_search and web_extract) is
|
||||
# actually registered. The lookup is a cheap dict hit on the healthy path
|
||||
# (discovery already registered it → no-op); only an empty registry pays
|
||||
# for the direct-registration sweep.
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
if get_provider("parallel") is None:
|
||||
_register_bundled_web_providers_directly()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Bundled web provider fallback check failed: %s", exc)
|
||||
|
||||
|
||||
def _register_bundled_web_providers_directly() -> None:
|
||||
"""Register the repo's bundled web providers without the plugin manager.
|
||||
|
||||
The normal path is the general plugin sweep
|
||||
(:func:`hermes_cli.plugins._ensure_plugins_discovered`), which auto-loads
|
||||
every ``plugins/web/<name>`` backend (they are ``kind: backend``). This
|
||||
fallback exists for the runtimes where that sweep does not leave the web
|
||||
registry populated — so the keyless Parallel default (and any bundled
|
||||
backend the user explicitly configured) keeps working instead of
|
||||
surfacing a misleading "No web provider configured" error.
|
||||
|
||||
Imports each bundled ``plugins/web/<name>`` package and calls its
|
||||
``register()`` directly against :mod:`agent.web_search_registry`. Idempotent
|
||||
(re-register overwrites) and honors an explicit ``plugins.disabled`` entry
|
||||
so a backend the user turned off stays off.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
_get_disabled_plugins,
|
||||
get_bundled_plugins_dir,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Bundled web provider fallback unavailable: %s", exc)
|
||||
return
|
||||
|
||||
web_dir = get_bundled_plugins_dir() / "web"
|
||||
if not web_dir.is_dir():
|
||||
return
|
||||
|
||||
disabled = _get_disabled_plugins()
|
||||
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from agent.web_search_registry import register_provider
|
||||
|
||||
class _DirectRegistrationCtx:
|
||||
"""Minimal plugin ctx exposing only web-provider registration."""
|
||||
|
||||
def register_web_search_provider(self, provider) -> None:
|
||||
if isinstance(provider, WebSearchProvider):
|
||||
register_provider(provider)
|
||||
|
||||
ctx = _DirectRegistrationCtx()
|
||||
import importlib
|
||||
|
||||
for child in sorted(web_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if not (child / "plugin.yaml").exists() and not (child / "plugin.yml").exists():
|
||||
continue
|
||||
# Respect an explicit disable — match discover_and_load's key/name
|
||||
# check (key ``web/<dir>``; manifest name ``web-<dir-with-dashes>``).
|
||||
if (
|
||||
f"web/{child.name}" in disabled
|
||||
or f"web-{child.name.replace('_', '-')}" in disabled
|
||||
):
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(f"plugins.web.{child.name}")
|
||||
register_fn = getattr(module, "register", None)
|
||||
if callable(register_fn):
|
||||
register_fn(ctx)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(
|
||||
"Direct registration of bundled web provider '%s' failed: %s",
|
||||
child.name, exc,
|
||||
)
|
||||
|
||||
|
||||
def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
"""
|
||||
@@ -1103,19 +969,11 @@ async def web_extract_tool(
|
||||
else:
|
||||
safe_urls.append(url)
|
||||
|
||||
# Tracks the free-tier Parallel extract path (no key → web_fetch via the
|
||||
# hosted Search MCP) so we can credit Parallel in the output/UI. Bound
|
||||
# here so empty/all-blocked inputs (which skip dispatch) stay defined.
|
||||
_free_parallel_extract = False
|
||||
|
||||
# Dispatch only safe URLs to the configured backend
|
||||
if not safe_urls:
|
||||
results = []
|
||||
else:
|
||||
backend = _get_extract_backend()
|
||||
_free_parallel_extract = (
|
||||
backend == "parallel" and not _has_env("PARALLEL_API_KEY")
|
||||
)
|
||||
|
||||
# All seven providers (brave-free, ddgs, searxng, exa, parallel,
|
||||
# tavily, firecrawl) now live as plugins. The dispatcher is a
|
||||
@@ -1289,14 +1147,6 @@ async def web_extract_tool(
|
||||
for r in response.get("results", [])
|
||||
]
|
||||
trimmed_response = {"results": trimmed_results}
|
||||
if _free_parallel_extract:
|
||||
# Credit Parallel's free Search MCP (drives the "[Parallel]" UI tag
|
||||
# + lets the model cite the source). Free tier only.
|
||||
trimmed_response["provider"] = "parallel"
|
||||
trimmed_response["attribution"] = (
|
||||
"Extraction powered by the free Parallel Web Search MCP "
|
||||
"(https://parallel.ai)."
|
||||
)
|
||||
|
||||
if trimmed_response.get("results") == []:
|
||||
result_json = tool_error("Content was inaccessible or not found")
|
||||
@@ -1328,61 +1178,16 @@ async def web_extract_tool(
|
||||
return tool_error(error_msg)
|
||||
|
||||
|
||||
def web_tools_registered() -> bool:
|
||||
"""Whether the web tools should be registered. Always True.
|
||||
|
||||
Registration is decoupled from credential readiness: with no credentials,
|
||||
search/extract fall back to Parallel's free hosted Search MCP, and an
|
||||
explicitly configured-but-unavailable backend must stay registered so
|
||||
dispatch surfaces that backend's own setup error rather than the tool
|
||||
silently vanishing. For "is web actually configured?" use
|
||||
:func:`check_web_api_key`.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def _parallel_provider_registered() -> bool:
|
||||
"""True when the bundled ``web-parallel`` provider is registered/enabled.
|
||||
|
||||
Plugin discovery skips disabled plugins, so a disabled (``plugins.disabled``)
|
||||
or otherwise-unregistered parallel provider yields ``None`` here.
|
||||
"""
|
||||
_ensure_web_plugins_loaded()
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
return get_provider("parallel") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _backend_usable(backend: str) -> bool:
|
||||
"""True when *backend* can service calls. Keyless Parallel counts (free MCP).
|
||||
|
||||
Unknown/typo'd backend names are not usable (so an explicit typo is reported
|
||||
as a config problem rather than masked by the keyless fallback).
|
||||
"""
|
||||
if backend == "parallel" and not _has_env("PARALLEL_API_KEY"):
|
||||
# Keyless Parallel is only genuinely usable when its provider is actually
|
||||
# registered/enabled. If web-parallel is disabled or discovery failed,
|
||||
# report unusable so setup is not skipped and the user is not left with
|
||||
# web tools that fail at runtime ("No web search provider configured").
|
||||
return _parallel_provider_registered()
|
||||
return _is_backend_available(backend)
|
||||
|
||||
|
||||
# Convenience function to check Firecrawl credentials
|
||||
def check_web_api_key() -> bool:
|
||||
"""Usability probe: True when the selected web backends can service calls.
|
||||
|
||||
Probes the backends that :func:`_get_search_backend` /
|
||||
:func:`_get_extract_backend` actually select (not just shared
|
||||
``web.backend``), so an explicit per-capability backend with missing
|
||||
credentials — or a typo'd name — reports unusable instead of being masked by
|
||||
the keyless Parallel fallback. Keyless Parallel itself genuinely services
|
||||
calls, so a zero-setup install reports usable. Distinct from
|
||||
:func:`web_tools_registered` (always True — whether the tool is offered).
|
||||
"""
|
||||
return _backend_usable(_get_search_backend()) and _backend_usable(_get_extract_backend())
|
||||
"""Check whether the configured web backend is available."""
|
||||
configured = _load_web_config().get("backend", "").lower().strip()
|
||||
if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai"}:
|
||||
return _is_backend_available(configured)
|
||||
return any(
|
||||
_is_backend_available(backend)
|
||||
for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai")
|
||||
)
|
||||
|
||||
|
||||
def check_auxiliary_model() -> bool:
|
||||
@@ -1550,7 +1355,7 @@ registry.register(
|
||||
toolset="web",
|
||||
schema=WEB_SEARCH_SCHEMA,
|
||||
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=args.get("limit", 5)),
|
||||
check_fn=web_tools_registered,
|
||||
check_fn=check_web_api_key,
|
||||
requires_env=_web_requires_env(),
|
||||
emoji="🔍",
|
||||
max_result_size_chars=100_000,
|
||||
@@ -1561,7 +1366,7 @@ registry.register(
|
||||
schema=WEB_EXTRACT_SCHEMA,
|
||||
handler=lambda args, **kw: web_extract_tool(
|
||||
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
||||
check_fn=web_tools_registered,
|
||||
check_fn=check_web_api_key,
|
||||
requires_env=_web_requires_env(),
|
||||
is_async=True,
|
||||
emoji="📄",
|
||||
|
||||
Reference in New Issue
Block a user