Merge remote-tracking branch 'origin/main' into bb/skills-renovate

This commit is contained in:
Brooklyn Nicholson 2026-07-03 13:59:26 -05:00
commit 65415b1a12
119 changed files with 13920 additions and 336 deletions

View file

@ -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},

View file

@ -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,

View file

@ -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"),

View file

@ -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,
)

View file

@ -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)

View file

@ -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__":