Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions hermes_cli/web_server_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ def _select(description: str, *options: str, **extra: Any) -> Dict[str, Any]:
return {"type": "select", "description": description, "options": list(options), **extra}


# Built-in web backends, mirrored from ``tools/web_tools.py`` (_LEGACY_WEB_BACKENDS for search; the
# extract-capable subset the runtime names in its "search-only" error). Leading "" = fall back to the
# shared ``web.backend`` / auto-detect. Kept as module constants so the static override and the
# per-request current-value preservation in _schema_with_dynamic_provider_options share one source of
# truth. Web backends are plugin-extensible, so these are suggestions, not an exhaustive gate — a
# configured name outside the list is preserved.
_WEB_SEARCH_BACKEND_OPTIONS = [
"", "firecrawl", "searxng", "brave-free", "ddgs", "tavily", "exa", "parallel", "perplexity", "xai",
"keenable",
]
_WEB_EXTRACT_BACKEND_OPTIONS = ["", "firecrawl", "tavily", "exa", "parallel"]


# Manual overrides for fields that need select options or custom types.
_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"timezone": _select(
Expand All @@ -84,6 +97,18 @@ def _select(description: str, *options: str, **extra: Any) -> Dict[str, Any]:
# sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
"terminal.vercel_runtime": _select("Vercel Sandbox runtime", "node24", "node22", "python3.13"),
"terminal.modal_mode": _select("Modal sandbox mode", "sandbox", "function"),
"web.backend": _select(
"Shared web search + extract backend (blank = auto-detect from credentials)",
*_WEB_SEARCH_BACKEND_OPTIONS,
),
"web.search_backend": _select(
"Per-capability override for web_search (blank = fall back to web.backend)",
*_WEB_SEARCH_BACKEND_OPTIONS,
),
"web.extract_backend": _select(
"Per-capability override for web_extract (blank = fall back to web.backend)",
*_WEB_EXTRACT_BACKEND_OPTIONS,
),
"proxy.enabled": {
"type": "boolean",
"description": (
Expand Down Expand Up @@ -352,6 +377,63 @@ def _schema_select_options(key: str) -> Optional[List[str]]:
return options if isinstance(options, list) else None


def _registry_web_backend_names(capability: str) -> List[str]:
"""Names of registered web providers that support *capability*.

``capability`` is ``"search"``, ``"extract"``, or ``"any"`` (supports at least one). Reads
``agent.web_search_registry.list_providers()`` — the same registry the ``web_search``/
``web_extract`` tools resolve against — so an installed provider (built-in or plugin) is
enumerated per capability. Returns ``[]`` on any import/registry failure so the built-in base
list stays the floor; a single misbehaving provider is skipped, not fatal.
"""
try:
from agent.web_search_registry import list_providers

providers = list_providers()
except Exception: # pragma: no cover - registry must never break the schema
return []

names: List[str] = []
for provider in providers:
try:
if capability == "search" and not provider.supports_search():
continue
if capability == "extract" and not provider.supports_extract():
continue
if capability == "any" and not (provider.supports_search() or provider.supports_extract()):
continue
name = str(provider.name).strip()
except Exception: # noqa: BLE001 - skip a provider that raises on introspection
continue
if name:
names.append(name)
return names


def _web_backend_schema_options(base: List[str], configured: Any, capability: str) -> List[str]:
"""Options for a web-backend select: built-ins + every installed registry provider that
supports *capability*, plus a configured-but-undiscoverable fallback.

The dashboard renders the select as a closed gate, so an installed provider that isn't in the
static ``base`` list (a plugin-registered backend, or a built-in not enumerated in ``base``) has
to be added here or it can't be chosen. We therefore enumerate the registry per capability rather
than only preserving whatever is already configured. A configured value that is neither built-in
nor currently registered is still appended so switching away from it never drops it from the
dropdown. ``base`` is always the floor; order is base → newly-discovered registry names →
configured fallback.
"""
options = list(base)
seen = set(options)
for name in _registry_web_backend_names(capability):
if name not in seen:
options.append(name)
seen.add(name)
current = str(configured or "").strip()
if current and current not in seen:
options.append(current)
return options


def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]:
"""CONFIG_SCHEMA with per-request discovery-driven ``*.provider`` options merged.

Expand Down Expand Up @@ -390,6 +472,16 @@ def merge(key: str, options: List[str]) -> None:
if plugin_names:
merge("terminal.backend", [*tb_options, *plugin_names])

# Web backends are plugin-extensible, and the dashboard renders a select as a closed gate.
# Enumerate installed registry providers per capability and preserve a configured value outside
# the built-in list so a plugin/custom backend never silently vanishes from the dropdown (mirrors
# the tts/stt/memory current-value preservation above).
web = cfg.get("web")
web = web if isinstance(web, dict) else {}
merge("web.backend", _web_backend_schema_options(_WEB_SEARCH_BACKEND_OPTIONS, web.get("backend"), "any"))
merge("web.search_backend", _web_backend_schema_options(_WEB_SEARCH_BACKEND_OPTIONS, web.get("search_backend"), "search"))
merge("web.extract_backend", _web_backend_schema_options(_WEB_EXTRACT_BACKEND_OPTIONS, web.get("extract_backend"), "extract"))

return {**CONFIG_SCHEMA, **overlay} if overlay else CONFIG_SCHEMA


Expand Down
187 changes: 187 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2569,6 +2569,193 @@ def tracked_get_messages(self, session_id, *args, **kwargs):

class TestBuildSchemaFromConfig:

def test_web_backend_fields_render_as_selects(self):
"""web.backend / search_backend / extract_backend must be dropdowns.

Previously they rendered as bare text inputs, so a user had to know the
exact backend string (e.g. ``brave-free``, not ``brave``) with no hint
of valid values (#71929). Each is a leaf key in DEFAULT_CONFIG, so the
_SCHEMA_OVERRIDES entry attaches.
"""
from hermes_cli.web_server_config import CONFIG_SCHEMA

for key in ("web.backend", "web.search_backend", "web.extract_backend"):
entry = CONFIG_SCHEMA[key]
assert entry["type"] == "select", key
# Leading "" = shared-backend / auto-detect fallback stays selectable.
assert entry["options"][0] == "", key

def test_web_backend_options_match_runtime_backends(self):
"""Search-backend options must equal the runtime's built-in web set.

Guards against the schema drifting from ``tools/web_tools.py`` — a stale
option would silently offer a backend the runtime rejects.
"""
from hermes_cli.web_server_config import CONFIG_SCHEMA
from tools.web_tools import _LEGACY_WEB_BACKENDS

for key in ("web.backend", "web.search_backend"):
offered = {o for o in CONFIG_SCHEMA[key]["options"] if o}
assert offered == set(_LEGACY_WEB_BACKENDS), key

def test_web_extract_backend_omits_search_only(self):
"""Extract options are the extract-capable subset only.

Search-only providers (searxng, brave-free, ddgs, xai) cannot extract —
the runtime rejects them with a "search-only backend" error — so they
must not be offered for web.extract_backend.
"""
from hermes_cli.web_server_config import CONFIG_SCHEMA

offered = {o for o in CONFIG_SCHEMA["web.extract_backend"]["options"] if o}
assert offered == {"firecrawl", "tavily", "exa", "parallel"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid freezing this current provider enumeration. Test the relation to provider capability flags instead, including a registered custom provider; AGENTS.md prohibits change-detector snapshots for evolving provider lists.

assert offered.isdisjoint({"searxng", "brave-free", "ddgs", "xai"})

def test_dynamic_merge_preserves_configured_web_backend(self, monkeypatch):
"""A plugin/custom web backend outside the built-in list stays selectable.

Web backends are plugin-extensible and the dashboard renders a select as
a closed gate, so a configured value the built-in list doesn't contain
must be appended — otherwise it silently vanishes and the next save
clobbers it.
"""
monkeypatch.setattr(
_cfg_mod,
"load_config",
lambda: {"web": {"backend": "my_plugin_backend", "extract_backend": "custom_extract"}},
)

fields = _web_server_config._schema_with_dynamic_provider_options()

assert "my_plugin_backend" in fields["web.backend"]["options"]
assert "custom_extract" in fields["web.extract_backend"]["options"]
# The module-level schema is copied, not mutated in place.
assert _web_server_config.CONFIG_SCHEMA["web.backend"] is not fields["web.backend"]
assert _web_server_config.CONFIG_SCHEMA["web.backend"]["type"] == "select"

def test_dynamic_merge_no_overlay_for_builtin_web_backend(self, monkeypatch):
"""A built-in (or blank) web backend with nothing new registered needs no
overlay — options unchanged.

The merge fires only when a configured value falls outside the list OR
the registry surfaces a provider the base list doesn't already contain.
Here the configured value is a built-in and the registry adds nothing, so
the common case returns the frozen import-time entry untouched. The
registry enumeration is stubbed empty to isolate that invariant from
whatever providers happen to be registered in the test process.
"""
monkeypatch.setattr(
_cfg_mod, "load_config", lambda: {"web": {"backend": "searxng", "search_backend": ""}}
)
monkeypatch.setattr(_web_server_config, "_registry_web_backend_names", lambda capability: [])

fields = _web_server_config._schema_with_dynamic_provider_options()

# No web override was added → identity with the module-level schema.
assert fields["web.backend"] is _web_server_config.CONFIG_SCHEMA["web.backend"]
assert fields["web.search_backend"] is _web_server_config.CONFIG_SCHEMA["web.search_backend"]

def test_dynamic_merge_enumerates_registry_web_backends_by_capability(self, monkeypatch):
"""An INSTALLED-but-unconfigured registry provider becomes selectable in
the capability-appropriate web-backend dropdowns.

This tests the relation to the provider capability flags rather than
freezing a provider list: a search-only provider must reach the shared
``web.backend`` and ``web.search_backend`` selects but be kept out of
``web.extract_backend``; an extract-capable provider must reach
``web.extract_backend``. A closed select can't choose a provider that
isn't in its options, so enumeration is what makes an installed plugin
backend pickable at all.
"""
from agent.web_search_provider import WebSearchProvider

class _SearchOnly(WebSearchProvider):
@property
def name(self) -> str:
return "fake_search_only"

def is_available(self) -> bool:
return True

def supports_search(self) -> bool:
return True

def supports_extract(self) -> bool:
return False

class _ExtractCapable(WebSearchProvider):
@property
def name(self) -> str:
return "fake_extract_only"

def is_available(self) -> bool:
return True

def supports_search(self) -> bool:
return False

def supports_extract(self) -> bool:
return True

registered = [_SearchOnly(), _ExtractCapable()]
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {"web": {}})
# Drive enumeration off the two fakes, independent of ambient registry state.
monkeypatch.setattr(
_web_server_config,
"_registry_web_backend_names",
lambda capability: [p.name for p in registered
if (capability == "search" and p.supports_search())
or (capability == "extract" and p.supports_extract())
or (capability == "any" and (p.supports_search() or p.supports_extract()))],
)

fields = _web_server_config._schema_with_dynamic_provider_options()
backend = fields["web.backend"]["options"]
search = fields["web.search_backend"]["options"]
extract = fields["web.extract_backend"]["options"]

# Search-only reaches the shared + search selects, never the extract one.
assert "fake_search_only" in backend
assert "fake_search_only" in search
assert "fake_search_only" not in extract
# Extract-capable reaches the shared + extract selects.
assert "fake_extract_only" in backend
assert "fake_extract_only" in extract

def test_registry_web_backend_names_filters_by_capability(self):
"""``_registry_web_backend_names`` returns provider names filtered by the
requested capability, mirroring the registry's own capability flags.

Exercises the real registry helper against a temporarily-registered
provider so the enumeration is validated end-to-end (not stubbed).
"""
import agent.web_search_registry as registry
from agent.web_search_provider import WebSearchProvider

class _SearchOnly(WebSearchProvider):
@property
def name(self) -> str:
return "capflag_search_only"

def is_available(self) -> bool:
return True

def supports_search(self) -> bool:
return True

def supports_extract(self) -> bool:
return False

saved = dict(registry._providers)
try:
registry.register_provider(_SearchOnly())
assert "capflag_search_only" in _web_server_config._registry_web_backend_names("search")
assert "capflag_search_only" in _web_server_config._registry_web_backend_names("any")
assert "capflag_search_only" not in _web_server_config._registry_web_backend_names("extract")
finally:
registry._providers.clear()
registry._providers.update(saved)


def test_timezone_field_is_searchable_select(self):
"""timezone must ship as a searchable, clearable select of IANA ids.
Expand Down