Merge remote-tracking branch 'origin/main' into bb/skills-renovate
This commit is contained in:
commit
65415b1a12
119 changed files with 13920 additions and 336 deletions
|
|
@ -653,6 +653,10 @@ def camofox_click(ref: str, task_id: Optional[str] = None) -> str:
|
|||
if not session["tab_id"]:
|
||||
return tool_error("No browser session. Call browser_navigate first.", success=False)
|
||||
|
||||
blocked = _camofox_private_page_block(session, task_id, "click")
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# Strip @ prefix if present (our tool convention)
|
||||
clean_ref = ref.lstrip("@")
|
||||
|
||||
|
|
@ -676,6 +680,10 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
|||
if not session["tab_id"]:
|
||||
return tool_error("No browser session. Call browser_navigate first.", success=False)
|
||||
|
||||
blocked = _camofox_private_page_block(session, task_id, "type")
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
clean_ref = ref.lstrip("@")
|
||||
|
||||
_post(
|
||||
|
|
@ -745,6 +753,10 @@ def camofox_press(key: str, task_id: Optional[str] = None) -> str:
|
|||
if not session["tab_id"]:
|
||||
return tool_error("No browser session. Call browser_navigate first.", success=False)
|
||||
|
||||
blocked = _camofox_private_page_block(session, task_id, "press")
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/press",
|
||||
{"userId": session["user_id"], "key": key},
|
||||
|
|
|
|||
|
|
@ -428,6 +428,15 @@ def browser_cdp(
|
|||
|
||||
# --- Route iframe-scoped calls through the supervisor ---------------
|
||||
if frame_id:
|
||||
# Same private-page/SSRF boundary as the stateless path below —
|
||||
# frame_id routing must not become the sibling bypass for it.
|
||||
blocked = _browser_cdp_private_guard(
|
||||
task_id=effective_task_id,
|
||||
method=method,
|
||||
params=params or {},
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
return _browser_cdp_via_supervisor(
|
||||
task_id=effective_task_id,
|
||||
frame_id=frame_id,
|
||||
|
|
|
|||
|
|
@ -1055,7 +1055,7 @@ def _build_child_agent(
|
|||
override_base_url: Optional[str] = None,
|
||||
override_api_key: Optional[str] = None,
|
||||
override_api_mode: Optional[str] = None,
|
||||
# ACP transport overrides — lets a non-ACP parent spawn ACP child agents
|
||||
# ACP transport overrides from trusted delegation config.
|
||||
override_acp_command: Optional[str] = None,
|
||||
override_acp_args: Optional[List[str]] = None,
|
||||
# Per-call role controlling whether the child can further delegate.
|
||||
|
|
@ -1212,11 +1212,9 @@ def _build_child_agent(
|
|||
effective_api_mode = None # force re-derivation from provider's defaults
|
||||
else:
|
||||
effective_api_mode = getattr(parent_agent, "api_mode", None)
|
||||
# Defensive: validate override_acp_command exists on PATH before honoring
|
||||
# it. Models occasionally pass acp_command="copilot" / "claude" / etc. in
|
||||
# delegate_task tool calls despite the schema saying not to, which forces
|
||||
# the subagent onto the copilot-acp transport below and crashes the
|
||||
# gateway when the binary is missing (e.g. headless container deploys).
|
||||
# Defensive: validate trusted delegation.command exists on PATH before
|
||||
# honoring it. Stale config should not force a child onto the ACP transport
|
||||
# and then fail at subprocess startup.
|
||||
if override_acp_command:
|
||||
import shutil as _shutil
|
||||
|
||||
|
|
@ -2346,8 +2344,6 @@ def delegate_task(
|
|||
context: Optional[str] = None,
|
||||
tasks: Optional[List[Dict[str, Any]]] = None,
|
||||
max_iterations: Optional[int] = None,
|
||||
acp_command: Optional[str] = None,
|
||||
acp_args: Optional[List[str]] = None,
|
||||
role: Optional[str] = None,
|
||||
background: Optional[bool] = None,
|
||||
parent_agent=None,
|
||||
|
|
@ -2486,7 +2482,6 @@ def delegate_task(
|
|||
children = []
|
||||
try:
|
||||
for i, t in enumerate(task_list):
|
||||
task_acp_args = t.get("acp_args") if "acp_args" in t else None
|
||||
# Per-task role beats top-level; normalise again so unknown
|
||||
# per-task values warn and degrade to leaf uniformly.
|
||||
effective_role = _normalize_role(t.get("role") or top_role)
|
||||
|
|
@ -2505,14 +2500,8 @@ def delegate_task(
|
|||
override_base_url=creds["base_url"],
|
||||
override_api_key=creds["api_key"],
|
||||
override_api_mode=creds["api_mode"],
|
||||
override_acp_command=t.get("acp_command")
|
||||
or acp_command
|
||||
or creds.get("command"),
|
||||
override_acp_args=(
|
||||
task_acp_args
|
||||
if task_acp_args is not None
|
||||
else (acp_args if acp_args is not None else creds.get("args"))
|
||||
),
|
||||
override_acp_command=creds.get("command"),
|
||||
override_acp_args=creds.get("args"),
|
||||
role=effective_role,
|
||||
)
|
||||
# Override with correct parent tool names (before child construction mutated global)
|
||||
|
|
@ -3292,30 +3281,6 @@ def _build_role_param_description() -> str:
|
|||
)
|
||||
|
||||
|
||||
# Known ACP-compatible CLIs that delegate_task can shell out to. Kept
|
||||
# narrow on purpose: only the ones agent/copilot_acp_client.py and friends
|
||||
# actually understand. Add new entries here when a new ACP CLI ships.
|
||||
_KNOWN_ACP_BINARIES: tuple[str, ...] = ("copilot", "claude", "codex")
|
||||
|
||||
|
||||
def _acp_binary_available() -> bool:
|
||||
"""True iff at least one known ACP CLI is on PATH.
|
||||
|
||||
Used to gate inclusion of ``acp_command`` / ``acp_args`` in the
|
||||
delegate_task schema. On headless hosts (Railway / Fly / Docker /
|
||||
fresh VPS) without any of these binaries, exposing the fields invites
|
||||
the model to hallucinate ``acp_command="copilot"`` from the schema's
|
||||
description, which used to crash subagent runs and take the gateway
|
||||
down. Pruning the fields from the schema removes the temptation.
|
||||
|
||||
Not cached: ``shutil.which`` is cheap and we want the schema to react
|
||||
to mid-session installs without forcing a process restart.
|
||||
"""
|
||||
import shutil as _shutil
|
||||
|
||||
return any(_shutil.which(name) for name in _KNOWN_ACP_BINARIES)
|
||||
|
||||
|
||||
def _build_dynamic_schema_overrides() -> dict:
|
||||
"""Return per-call schema overrides reflecting current config.
|
||||
|
||||
|
|
@ -3333,24 +3298,6 @@ def _build_dynamic_schema_overrides() -> dict:
|
|||
overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description()
|
||||
overrides_params["properties"]["role"]["description"] = _build_role_param_description()
|
||||
|
||||
# Prune ACP overrides from the schema when no known ACP CLI is on PATH.
|
||||
# The runtime guard in _build_child_agent remains as defense-in-depth for
|
||||
# internal callers / tests / future code paths that skip the schema layer.
|
||||
if not _acp_binary_available():
|
||||
overrides_params["properties"].pop("acp_command", None)
|
||||
overrides_params["properties"].pop("acp_args", None)
|
||||
tasks_schema = dict(overrides_params["properties"].get("tasks", {}))
|
||||
if "items" in tasks_schema:
|
||||
items = dict(tasks_schema["items"])
|
||||
if "properties" in items:
|
||||
items["properties"] = {
|
||||
k: v
|
||||
for k, v in items["properties"].items()
|
||||
if k not in ("acp_command", "acp_args")
|
||||
}
|
||||
tasks_schema["items"] = items
|
||||
overrides_params["properties"]["tasks"] = tasks_schema
|
||||
|
||||
return {
|
||||
"description": _build_top_level_description(),
|
||||
"parameters": overrides_params,
|
||||
|
|
@ -3401,19 +3348,6 @@ DELEGATE_TASK_SCHEMA = {
|
|||
"type": "string",
|
||||
"description": "Task-specific context",
|
||||
},
|
||||
"acp_command": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Per-task ACP command override (e.g. 'copilot'). "
|
||||
"Overrides the top-level acp_command for this task only. "
|
||||
"Do NOT set unless the user explicitly told you an ACP CLI is installed."
|
||||
),
|
||||
},
|
||||
"acp_args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Per-task ACP args override. Leave empty unless acp_command is set.",
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["leaf", "orchestrator"],
|
||||
|
|
@ -3444,28 +3378,6 @@ DELEGATE_TASK_SCHEMA = {
|
|||
"compatibility."
|
||||
),
|
||||
},
|
||||
"acp_command": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Override ACP command for child agents (e.g. 'copilot'). "
|
||||
"When set, children use ACP subprocess transport instead of inheriting "
|
||||
"the parent's transport. Requires an ACP-compatible CLI "
|
||||
"(currently GitHub Copilot CLI via 'copilot --acp --stdio'). "
|
||||
"See agent/copilot_acp_client.py for the implementation. "
|
||||
"IMPORTANT: Do NOT set this unless the user has explicitly told you "
|
||||
"a specific ACP-compatible CLI is installed and configured. "
|
||||
"Leave empty to use the parent's default transport (Hermes subagents)."
|
||||
),
|
||||
},
|
||||
"acp_args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Arguments for the ACP command (default: ['--acp', '--stdio']). "
|
||||
"Only used when acp_command is set. "
|
||||
"Leave empty unless acp_command is explicitly provided."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
|
|
@ -3492,6 +3404,28 @@ def _model_background_value(args: dict, parent_agent=None) -> bool:
|
|||
return not is_subagent
|
||||
|
||||
|
||||
_MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"}
|
||||
|
||||
|
||||
def _strip_model_hidden_task_fields(tasks: Any) -> Any:
|
||||
if not isinstance(tasks, list):
|
||||
return tasks
|
||||
stripped_tasks = []
|
||||
changed = False
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
stripped_tasks.append(task)
|
||||
continue
|
||||
stripped = {
|
||||
key: value
|
||||
for key, value in task.items()
|
||||
if key not in _MODEL_HIDDEN_TASK_FIELDS
|
||||
}
|
||||
changed = changed or len(stripped) != len(task)
|
||||
stripped_tasks.append(stripped)
|
||||
return stripped_tasks if changed else tasks
|
||||
|
||||
|
||||
registry.register(
|
||||
name="delegate_task",
|
||||
toolset="delegation",
|
||||
|
|
@ -3499,10 +3433,8 @@ registry.register(
|
|||
handler=lambda args, **kw: delegate_task(
|
||||
goal=args.get("goal"),
|
||||
context=args.get("context"),
|
||||
tasks=args.get("tasks"),
|
||||
tasks=_strip_model_hidden_task_fields(args.get("tasks")),
|
||||
max_iterations=args.get("max_iterations"),
|
||||
acp_command=args.get("acp_command"),
|
||||
acp_args=args.get("acp_args"),
|
||||
role=args.get("role"),
|
||||
background=_model_background_value(args, kw.get("parent_agent")),
|
||||
parent_agent=kw.get("parent_agent"),
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ DEFAULT_ELEVENLABS_VOICE_ID = "pNInz6obpgDQGcFmaJgB" # Adam
|
|||
DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"
|
||||
DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5"
|
||||
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
|
||||
# The managed OpenAI audio gateway (Nous portal proxy) only proxies these speech
|
||||
# models. A user's tts.openai.model set for *direct* OpenAI (e.g. "tts-1-hd")
|
||||
# is rejected with a 400 "Unsupported managed OpenAI speech model", so it must be
|
||||
# coerced to a supported model when routing through the gateway.
|
||||
MANAGED_OPENAI_TTS_MODELS = frozenset({"gpt-4o-mini-tts"})
|
||||
DEFAULT_KITTENTTS_MODEL = "KittenML/kitten-tts-nano-0.8-int8" # 25MB
|
||||
DEFAULT_KITTENTTS_VOICE = "Jasper"
|
||||
DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
|
||||
|
|
@ -1019,14 +1024,29 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
|||
Returns:
|
||||
Path to the saved audio file.
|
||||
"""
|
||||
api_key, base_url = _resolve_openai_audio_client_config()
|
||||
api_key, base_url, is_managed = _resolve_openai_audio_client_config()
|
||||
|
||||
oai_config = tts_config.get("openai", {})
|
||||
model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
|
||||
voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE)
|
||||
base_url = oai_config.get("base_url", base_url)
|
||||
custom_base_url = oai_config.get("base_url")
|
||||
if custom_base_url:
|
||||
base_url = custom_base_url
|
||||
speed = float(oai_config.get("speed", tts_config.get("speed", 1.0)))
|
||||
|
||||
# The managed OpenAI audio gateway only proxies MANAGED_OPENAI_TTS_MODELS.
|
||||
# A model set for direct OpenAI (e.g. "tts-1-hd") 400s there with
|
||||
# "Unsupported managed OpenAI speech model", so coerce it — unless the user
|
||||
# redirected base_url to their own endpoint, in which case respect it.
|
||||
if is_managed and not custom_base_url and model not in MANAGED_OPENAI_TTS_MODELS:
|
||||
logger.warning(
|
||||
"TTS: managed OpenAI audio gateway does not support model %r; "
|
||||
"falling back to %s. Set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY "
|
||||
"to use %r directly.",
|
||||
model, DEFAULT_OPENAI_MODEL, model,
|
||||
)
|
||||
model = DEFAULT_OPENAI_MODEL
|
||||
|
||||
# Determine response format from extension
|
||||
if output_path.endswith(".ogg"):
|
||||
response_format = "opus"
|
||||
|
|
@ -2502,15 +2522,17 @@ def check_tts_requirements() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
||||
"""Return direct OpenAI audio config or a managed gateway fallback.
|
||||
def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
|
||||
"""Return ``(api_key, base_url, is_managed)`` for the OpenAI audio client.
|
||||
|
||||
When ``tts.use_gateway`` is set in config, the Tool Gateway is preferred
|
||||
``is_managed`` is True when the config resolves to the Nous managed audio
|
||||
gateway (a restricted proxy), so callers can coerce the request to what the
|
||||
gateway supports. When ``tts.use_gateway`` is set the gateway is preferred
|
||||
even if direct OpenAI credentials are present.
|
||||
"""
|
||||
direct_api_key = resolve_openai_audio_api_key()
|
||||
if direct_api_key and not prefers_gateway("tts"):
|
||||
return direct_api_key, DEFAULT_OPENAI_BASE_URL
|
||||
return direct_api_key, DEFAULT_OPENAI_BASE_URL, False
|
||||
|
||||
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
||||
if managed_gateway is None:
|
||||
|
|
@ -2524,8 +2546,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
|||
)
|
||||
raise ValueError(message)
|
||||
|
||||
return managed_gateway.nous_user_token, urljoin(
|
||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
||||
return (
|
||||
managed_gateway.nous_user_token,
|
||||
urljoin(f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1356,7 +1356,18 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:
|
|||
"Fully describe and explain everything about this image, then answer the "
|
||||
f"following question:\n\n{question}"
|
||||
)
|
||||
model = os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
||||
# Prefer config.yaml auxiliary.vision.model; env var is a legacy override.
|
||||
model = None
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
_cfg = load_config()
|
||||
_vmodel = cfg_get(_cfg, "auxiliary", "vision", "model")
|
||||
if _vmodel:
|
||||
model = str(_vmodel).strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
if not model:
|
||||
model = os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
||||
return await vision_analyze_tool(image_url, full_prompt, model)
|
||||
|
||||
|
||||
|
|
@ -1718,7 +1729,19 @@ def _handle_video_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]:
|
|||
"including visual content, motion, audio cues, text overlays, and scene "
|
||||
f"transitions. Then answer the following question:\n\n{question}"
|
||||
)
|
||||
model = os.getenv("AUXILIARY_VIDEO_MODEL", "").strip() or os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
||||
# Prefer config.yaml auxiliary.video.model (falling back to vision);
|
||||
# env vars are a legacy override.
|
||||
model = None
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
_cfg = load_config()
|
||||
_vmodel = cfg_get(_cfg, "auxiliary", "video", "model") or cfg_get(_cfg, "auxiliary", "vision", "model")
|
||||
if _vmodel:
|
||||
model = str(_vmodel).strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
if not model:
|
||||
model = os.getenv("AUXILIARY_VIDEO_MODEL", "").strip() or os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
||||
return video_analyze_tool(video_url, full_prompt, model)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,71 @@ def _load_web_config() -> dict:
|
|||
except (ImportError, Exception):
|
||||
return {}
|
||||
|
||||
|
||||
# The built-in web backends whose availability is driven by hardcoded
|
||||
# env-var / package / OAuth probes below. Any name NOT in this set is a
|
||||
# candidate plugin-registered provider and must be resolved through the
|
||||
# web_search_registry (``is_available()``) instead. Kept as a single named
|
||||
# constant so the whitelist early-returns and the availability chokepoint
|
||||
# stay in sync.
|
||||
#
|
||||
# NOTE: this intentionally includes ``xai``, which the registry's
|
||||
# ``_LEGACY_PREFERENCE`` does NOT — xai availability is probed via
|
||||
# ``has_xai_credentials()`` (env var OR auth.json OAuth), not a registered
|
||||
# WebSearchProvider. Keep the two sets aligned by hand: if xai ever ships as
|
||||
# a registered provider, drop it here so the registry path takes over.
|
||||
_LEGACY_WEB_BACKENDS = frozenset(
|
||||
{"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}
|
||||
)
|
||||
|
||||
|
||||
def _registered_web_provider(backend: str):
|
||||
"""Return a plugin-registered web provider by name, or ``None``.
|
||||
|
||||
Consults ``agent.web_search_registry`` so backends contributed by the
|
||||
plugin system (which are absent from :data:`_LEGACY_WEB_BACKENDS`) are
|
||||
discoverable during availability/selection resolution. Returns ``None``
|
||||
on any lookup failure so callers can fall through to legacy checks.
|
||||
"""
|
||||
if not backend:
|
||||
return None
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
return get_provider(backend)
|
||||
except Exception as exc: # noqa: BLE001 — registry optional; never fatal
|
||||
logger.debug("web provider registry lookup failed for %r: %s", backend, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _registered_web_provider_available(backend: str):
|
||||
"""Availability of a *registered* web provider, or ``None`` if unregistered.
|
||||
|
||||
Returns ``True``/``False`` when *backend* names a registered provider
|
||||
(calling its ``is_available()``), or ``None`` when it isn't registered —
|
||||
letting the caller fall through to the legacy built-in probes.
|
||||
"""
|
||||
provider = _registered_web_provider(backend)
|
||||
if provider is None:
|
||||
return None
|
||||
try:
|
||||
return bool(provider.is_available())
|
||||
except Exception as exc: # noqa: BLE001 — a broken provider is "unavailable"
|
||||
logger.debug("web provider %r.is_available() raised: %s", backend, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _list_registered_web_providers():
|
||||
"""Return all plugin-registered web providers (empty list on failure)."""
|
||||
try:
|
||||
from agent.web_search_registry import list_providers
|
||||
|
||||
return list_providers()
|
||||
except Exception as exc: # noqa: BLE001 — registry optional; never fatal
|
||||
logger.debug("web provider registry list failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _get_backend() -> str:
|
||||
"""Determine which web backend to use (shared fallback).
|
||||
|
||||
|
|
@ -144,7 +209,7 @@ def _get_backend() -> str:
|
|||
keys manually without running setup.
|
||||
"""
|
||||
configured = (_load_web_config().get("backend") or "").lower().strip()
|
||||
if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}:
|
||||
if configured in _LEGACY_WEB_BACKENDS or _registered_web_provider(configured) is not None:
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
|
|
@ -168,6 +233,21 @@ def _get_backend() -> str:
|
|||
if available:
|
||||
return backend
|
||||
|
||||
# Final fallback: walk plugin-registered providers so a custom backend
|
||||
# (with no built-in creds present) still resolves. Built-in names are
|
||||
# already covered above, so this only surfaces plugin-contributed
|
||||
# providers via their own is_available() gate. We hold the provider
|
||||
# object already, so probe it directly rather than round-tripping through
|
||||
# _is_backend_available() (which would re-do the registry lookup).
|
||||
for provider in _list_registered_web_providers():
|
||||
if provider.name in _LEGACY_WEB_BACKENDS:
|
||||
continue
|
||||
try:
|
||||
if provider.is_available():
|
||||
return provider.name
|
||||
except Exception as exc: # noqa: BLE001 — a broken provider is skipped
|
||||
logger.debug("web provider %r.is_available() raised: %s", provider.name, exc)
|
||||
|
||||
return "firecrawl" # default (backward compat)
|
||||
|
||||
|
||||
|
|
@ -210,7 +290,22 @@ def _get_capability_backend(capability: str) -> str:
|
|||
|
||||
|
||||
def _is_backend_available(backend: str) -> bool:
|
||||
"""Return True when the selected backend is currently usable."""
|
||||
"""Return True when the selected backend is currently usable.
|
||||
|
||||
For plugin-registered backends (any name outside
|
||||
:data:`_LEGACY_WEB_BACKENDS`), availability is delegated to the
|
||||
provider's ``is_available()`` via the web_search_registry. This is the
|
||||
single chokepoint through which ``_get_backend``,
|
||||
``_get_capability_backend``, and ``check_web_api_key`` all resolve
|
||||
availability — fixing custom-provider discovery for every caller at once
|
||||
(issues #28651, #31873, #32698). Built-in backends keep their cheap
|
||||
hardcoded probes below.
|
||||
"""
|
||||
backend = (backend or "").lower().strip()
|
||||
if backend not in _LEGACY_WEB_BACKENDS:
|
||||
registered = _registered_web_provider_available(backend)
|
||||
if registered is not None:
|
||||
return registered
|
||||
if backend == "exa":
|
||||
return _has_env("EXA_API_KEY")
|
||||
if backend == "parallel":
|
||||
|
|
@ -861,14 +956,39 @@ async def web_extract_tool(
|
|||
|
||||
# Convenience function to check Firecrawl credentials
|
||||
def check_web_api_key() -> bool:
|
||||
"""Check whether the configured web backend is available."""
|
||||
"""Check whether the configured web backend is available.
|
||||
|
||||
Used as the ``check_fn`` gate for the ``web_search`` and ``web_extract``
|
||||
tool registry entries — so a plugin-registered provider that reports
|
||||
``is_available()`` must light the tools up even when no built-in backend
|
||||
has credentials (issues #28651, #31873). Resolution funnels through
|
||||
:func:`_is_backend_available`, which delegates non-legacy names to the
|
||||
registry.
|
||||
"""
|
||||
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")
|
||||
)
|
||||
if configured and _is_backend_available(configured):
|
||||
return True
|
||||
# Any built-in backend with credentials present. This is a boolean OR, so
|
||||
# unlike _get_backend() the probe order is irrelevant.
|
||||
if any(_is_backend_available(backend) for backend in _LEGACY_WEB_BACKENDS):
|
||||
return True
|
||||
# Any plugin-registered provider the registry considers active for either
|
||||
# capability. Delegating to the registry's own availability-filtered
|
||||
# resolvers keeps a single authority for "is a custom provider usable"
|
||||
# rather than re-implementing the walk here.
|
||||
try:
|
||||
from agent.web_search_registry import (
|
||||
get_active_search_provider,
|
||||
get_active_extract_provider,
|
||||
)
|
||||
|
||||
return (
|
||||
get_active_search_provider() is not None
|
||||
or get_active_extract_provider() is not None
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — registry optional; never fatal
|
||||
logger.debug("web provider registry availability check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue