Merge branch 'main' into docker_s6

This commit is contained in:
Ben Barclay 2026-05-25 09:39:27 +10:00 committed by GitHub
commit 59da190512
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
417 changed files with 26434 additions and 3321 deletions

View file

@ -1299,12 +1299,34 @@ def check_all_command_guards(command: str, env_type: str,
)
if not resolved or choice is None or choice == "deny":
reason = "timed out" if not resolved else "denied by user"
# Consent contract: silence is NOT consent, and an explicit
# deny is also a hard halt — both produce a BLOCKED outcome
# that names the agent's most common evasion paths (retry,
# rephrase, achieve the same outcome via a different command).
# See issue #24912 for the original incident.
if not resolved:
reason = "timed out without user response"
timeout_addendum = " Silence is not consent."
outcome = "timeout"
else:
reason = "denied by user"
timeout_addendum = ""
outcome = "denied"
return {
"approved": False,
"message": f"BLOCKED: Command {reason}. Do NOT retry this command.",
"message": (
f"BLOCKED: Command {reason}. The user has NOT consented "
f"to this action. Do NOT retry this command, do NOT "
f"rephrase it, and do NOT attempt the same outcome via "
f"a different command. Stop the current workflow and "
f"wait for the user to respond before taking any "
f"further destructive or irreversible action."
f"{timeout_addendum}"
),
"pattern_key": primary_key,
"description": combined_desc,
"outcome": outcome,
"user_consent": False,
}
# User approved — persist based on scope (same logic as CLI)
@ -1369,9 +1391,18 @@ def check_all_command_guards(command: str, env_type: str,
if choice == "deny":
return {
"approved": False,
"message": "BLOCKED: User denied. Do NOT retry.",
"message": (
"BLOCKED: User denied this command. The user has NOT consented "
"to this action. Do NOT retry this command, do NOT rephrase "
"it, and do NOT attempt the same outcome via a different "
"command. Stop the current workflow and wait for the user "
"to respond before taking any further destructive or "
"irreversible action."
),
"pattern_key": primary_key,
"description": combined_desc,
"outcome": "denied",
"user_consent": False,
}
# Persist approval for each warning individually

View file

@ -102,7 +102,6 @@ from plugins.browser.firecrawl.provider import ( # noqa: F401
FirecrawlBrowserProvider as FirecrawlProvider,
)
from tools.tool_backend_helpers import normalize_browser_cloud_provider
# Camofox local anti-detection browser backend (optional).
# When CAMOFOX_URL is set, all browser operations route through the
# camofox REST API instead of the agent-browser CLI.
@ -1386,8 +1385,11 @@ def _reap_orphaned_browser_sessions():
continue
# Daemon is alive and its owner is dead (or legacy + untracked). Reap.
# Use the process-tree termination helper so Chromium children
# (renderer, GPU, etc.) are cleaned up, not just the daemon parent.
try:
os.kill(daemon_pid, signal.SIGTERM)
from tools.process_registry import ProcessRegistry
ProcessRegistry._terminate_host_pid(daemon_pid)
logger.info("Reaped orphaned browser daemon PID %d (session %s)",
daemon_pid, session_name)
reaped += 1
@ -3437,8 +3439,9 @@ def _cleanup_single_browser_session(task_id: str) -> None:
pid_file = os.path.join(socket_dir, f"{session_name}.pid")
if os.path.isfile(pid_file):
try:
from tools.process_registry import ProcessRegistry
daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip())
os.kill(daemon_pid, signal.SIGTERM)
ProcessRegistry._terminate_host_pid(daemon_pid)
logger.debug("Killed daemon pid %s for %s", daemon_pid, session_name)
except (ProcessLookupError, ValueError, PermissionError, OSError):
logger.debug("Could not kill daemon pid for %s (already dead or inaccessible)", session_name)
@ -3649,6 +3652,24 @@ def check_browser_requirements() -> bool:
return True
def check_browser_vision_requirements() -> bool:
"""Whether ``browser_vision`` should be advertised to the model.
Requires BOTH a working browser (``check_browser_requirements``) AND a
resolvable vision backend. Without the vision check, the tool stays in
the model's tool list even when no vision provider is configured, then
fails at call time with a cryptic provider-side error like
``unknown variant `image_url`, expected `text``` (issue #31179).
"""
if not check_browser_requirements():
return False
try:
from tools.vision_tools import check_vision_requirements
except ImportError:
return False
return check_vision_requirements()
# ============================================================================
# Module Test
# ============================================================================
@ -3783,7 +3804,7 @@ registry.register(
toolset="browser",
schema=_BROWSER_SCHEMA_MAP["browser_vision"],
handler=lambda args, **kw: browser_vision(question=args.get("question", ""), annotate=args.get("annotate", False), task_id=kw.get("task_id")),
check_fn=check_browser_requirements,
check_fn=check_browser_vision_requirements,
emoji="👁️",
)
registry.register(

View file

@ -202,9 +202,9 @@ _TOOL_STUBS = {
),
"write_file": (
"write_file",
"path: str, content: str",
'"""Write content to a file (always overwrites). Returns dict with status."""',
'{"path": path, "content": content}',
"path: str, content: str, cross_profile: bool = False",
'"""Write content to a file (always overwrites). Returns dict with status. cross_profile=True opts out of the cross-Hermes-profile soft guard."""',
'{"path": path, "content": content, "cross_profile": cross_profile}',
),
"search_files": (
"search_files",
@ -214,9 +214,9 @@ _TOOL_STUBS = {
),
"patch": (
"patch",
'path: str = None, old_string: str = None, new_string: str = None, replace_all: bool = False, mode: str = "replace", patch: str = None',
'"""Targeted find-and-replace (mode="replace") or V4A multi-file patches (mode="patch"). Returns dict with status."""',
'{"path": path, "old_string": old_string, "new_string": new_string, "replace_all": replace_all, "mode": mode, "patch": patch}',
'path: str = None, old_string: str = None, new_string: str = None, replace_all: bool = False, mode: str = "replace", patch: str = None, cross_profile: bool = False',
'"""Targeted find-and-replace (mode="replace") or V4A multi-file patches (mode="patch"). Returns dict with status. cross_profile=True opts out of the cross-Hermes-profile soft guard."""',
'{"path": path, "old_string": old_string, "new_string": new_string, "replace_all": replace_all, "mode": mode, "patch": patch, "cross_profile": cross_profile}',
),
"terminal": (
"terminal",

View file

@ -142,6 +142,14 @@ class ComputerUseBackend(ABC):
def focus_app(self, app: str, raise_window: bool = False) -> ActionResult:
"""Route input to `app` (by name or bundle ID). Default: focus without raise."""
# ── Native-value mutation ────────────────────────────────────────
@abstractmethod
def set_value(self, value: str, element: Optional[int] = None) -> ActionResult:
"""Set a native value on an element (e.g. AXPopUpButton selection).
`element` is the 1-based SOM index returned by a prior capture call.
"""
# ── Timing ──────────────────────────────────────────────────────
def wait(self, seconds: float) -> ActionResult:
"""Default implementation: time.sleep."""

View file

@ -75,6 +75,28 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = {
"frontmost app's window or the whole screen."
),
},
"max_elements": {
"type": "integer",
"description": (
"Optional cap on the AX `elements` array returned by "
"`action='capture'`. Default 100, hard maximum 1000. "
"Dense UIs (Electron apps such as Obsidian or VS Code, "
"JetBrains IDEs) can publish 500+ AX nodes — capping "
"prevents a single capture from blowing session "
"context. When the cap trims the response, "
"`total_elements` and `truncated_elements` are "
"surfaced in the result so you can re-call with "
"`app=` to narrow scope or raise `max_elements` when "
"the full tree is required. Has no effect on "
"`mode='som'` / `mode='vision'` when a screenshot is "
"included in the response; only the rare image-"
"missing fallback returns an `elements` array and is "
"subject to the cap."
),
"default": 100,
"minimum": 1,
"maximum": 1000,
},
# ── click / drag / scroll targeting ────────────────────
"element": {
"type": "integer",

View file

@ -200,6 +200,10 @@ class _NoopBackend(ComputerUseBackend): # pragma: no cover
self.calls.append(("focus_app", {"app": app, "raise": raise_window}))
return ActionResult(ok=True, action="focus_app")
def set_value(self, value: str, element: Optional[int] = None) -> ActionResult:
self.calls.append(("set_value", {"value": value, "element": element}))
return ActionResult(ok=True, action="set_value")
# ---------------------------------------------------------------------------
# Dispatch
@ -317,7 +321,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) ->
if mode not in {"som", "vision", "ax"}:
return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"})
cap = backend.capture(mode=mode, app=args.get("app"))
return _capture_response(cap)
return _capture_response(cap, max_elements=_coerce_max_elements(args.get("max_elements")))
if action == "wait":
seconds = float(args.get("seconds", 1.0))
@ -416,16 +420,62 @@ def _text_response(res: ActionResult) -> str:
return json.dumps(payload)
def _capture_response(cap: CaptureResult) -> Any:
element_index = _format_elements(cap.elements)
# Default cap for the AX `elements` array returned by capture. Dense UIs
# (Electron apps, Obsidian, JetBrains IDEs) can publish 500+ AX nodes, which
# can exhaust session context after a single capture. The model-facing
# `max_elements` argument lets callers raise this when they need the full tree.
_DEFAULT_MAX_ELEMENTS = 100
# Hard upper bound on caller-supplied `max_elements`. Without this, a tool
# call passing a very large integer would silently disable the safeguard and
# reintroduce the original unbounded behavior.
_MAX_ALLOWED_MAX_ELEMENTS = 1000
def _coerce_max_elements(value: Any) -> int:
"""Validate the caller-supplied ``max_elements``.
Falls back to :data:`_DEFAULT_MAX_ELEMENTS` for missing / non-integer /
sub-1 inputs so the cap can never be silently disabled by a malformed
tool-call argument. Clamps oversized values to
:data:`_MAX_ALLOWED_MAX_ELEMENTS` so a caller cannot bypass the
safeguard by passing a very large integer.
"""
if value is None:
return _DEFAULT_MAX_ELEMENTS
try:
n = int(value)
except (TypeError, ValueError):
return _DEFAULT_MAX_ELEMENTS
if n < 1:
return _DEFAULT_MAX_ELEMENTS
if n > _MAX_ALLOWED_MAX_ELEMENTS:
return _MAX_ALLOWED_MAX_ELEMENTS
return n
def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEMENTS) -> Any:
total_elements = len(cap.elements)
visible_elements = cap.elements[:max_elements]
truncated_elements = max(0, total_elements - len(visible_elements))
# Index only what's actually surfaced in the response — otherwise the
# human-readable summary references element indices the model cannot
# find in the JSON `elements` array (e.g. max_elements=10 vs the default
# 40-line index window).
element_index = _format_elements(visible_elements)
summary_lines = [
f"capture mode={cap.mode} {cap.width}x{cap.height}"
+ (f" app={cap.app}" if cap.app else "")
+ (f" window={cap.window_title!r}" if cap.window_title else ""),
f"{len(cap.elements)} interactable element(s):",
f"{total_elements} interactable element(s):",
]
if element_index:
summary_lines.extend(element_index)
# Multimodal and AX paths both reference `summary`; build it once up-front
# so the aux-vision routing branch (which fires before either path is
# selected) has a valid value to hand to _route_capture_through_aux_vision.
# The AX path appends the "truncated to N of M" note to summary_lines
# below and rebuilds; the multimodal path keeps this version untouched.
summary = "\n".join(summary_lines)
if cap.png_b64 and cap.mode != "ax":
@ -449,6 +499,9 @@ def _capture_response(cap: CaptureResult) -> Any:
# JPEG: base64 starts with /9j/ PNG: starts with iVBOR
_b64_prefix = cap.png_b64[:8]
_mime = "image/jpeg" if _b64_prefix.startswith("/9j/") else "image/png"
# The multimodal response carries the screenshot, not the AX
# elements array, so a "response truncated to N of M elements"
# note would be inaccurate — skip it on this branch.
return {
"_multimodal": True,
"content": [
@ -458,18 +511,29 @@ def _capture_response(cap: CaptureResult) -> Any:
],
"text_summary": summary,
"meta": {"mode": cap.mode, "width": cap.width, "height": cap.height,
"elements": len(cap.elements), "png_bytes": cap.png_bytes_len},
"elements": total_elements, "png_bytes": cap.png_bytes_len},
}
# AX-only (or image missing): text path.
return json.dumps({
# AX-only (or image-missing fallback): text path actually carries the
# `elements` array, so the truncation note applies here.
if truncated_elements:
summary_lines.append(
f" (response truncated to {len(visible_elements)} of {total_elements} elements; "
f"raise max_elements or pass app= to narrow)"
)
summary = "\n".join(summary_lines)
payload: Dict[str, Any] = {
"mode": cap.mode,
"width": cap.width,
"height": cap.height,
"app": cap.app,
"window_title": cap.window_title,
"elements": [_element_to_dict(e) for e in cap.elements],
"elements": [_element_to_dict(e) for e in visible_elements],
"total_elements": total_elements,
"summary": summary,
})
}
if truncated_elements:
payload["truncated_elements"] = truncated_elements
return json.dumps(payload)
# ---------------------------------------------------------------------------
@ -611,6 +675,11 @@ def _maybe_follow_capture(
) -> Any:
if not do_capture:
return _text_response(res)
# Skip the follow-up capture when the action itself failed: showing a
# normal-looking screenshot after a failure misleads the model into thinking
# the action succeeded. Return the error text instead.
if not res.ok:
return _text_response(res)
try:
# Preserve the app context established by the preceding capture/focus_app so
# that capture_after=True re-captures the same app rather than the frontmost

View file

@ -169,6 +169,7 @@ class SSHEnvironment(BaseEnvironment):
if not files:
return
base = f"{self._remote_home}/.hermes"
parents = unique_parent_dirs(files)
if parents:
cmd = self._build_ssh_command()
@ -180,7 +181,19 @@ class SSHEnvironment(BaseEnvironment):
# Symlink staging avoids fragile GNU tar --transform rules.
with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging:
for host_path, remote_path in files:
staged = os.path.join(staging, remote_path.lstrip("/"))
try:
rel_remote = os.path.relpath(remote_path, base)
except ValueError as exc:
raise RuntimeError(
f"remote path {remote_path!r} is not under sync base {base!r}"
) from exc
if rel_remote == "." or rel_remote.startswith("../"):
raise RuntimeError(
f"remote path {remote_path!r} escapes sync base {base!r}"
)
staged = os.path.join(staging, rel_remote)
os.makedirs(os.path.dirname(staged), exist_ok=True)
os.symlink(os.path.abspath(host_path), staged)
@ -190,7 +203,7 @@ class SSHEnvironment(BaseEnvironment):
# existing directories (e.g. /home/<user>) with the staging
# directory's mode. Without this, a umask 002 produces 0775
# dirs which breaks sshd StrictModes (refuses authorized_keys).
ssh_cmd.append("tar xf - --no-overwrite-dir -C /")
ssh_cmd.append(f"tar xf - --no-overwrite-dir -C {shlex.quote(base)}")
tar_proc = subprocess.Popen(
tar_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE

163
tools/fal_common.py Normal file
View file

@ -0,0 +1,163 @@
"""Shared FAL.ai SDK plumbing.
Holds the stateless atoms that every FAL-backed tool needs:
* :func:`import_fal_client` lazy import + ``lazy_deps`` integration so
``fal_client`` isn't pulled at cold start (it added ~64 ms per CLI
invocation when imported eagerly).
* :class:`_ManagedFalSyncClient` wrapper that drives a Nous-managed
fal-queue gateway through the standard ``fal_client.SyncClient``
primitives.
* :func:`_normalize_fal_queue_url_format`, :func:`_extract_http_status`
small helpers used by both the managed client wrapper and
``_submit_fal_request``.
Stateful pieces (cache globals, ``_managed_fal_client*`` selectors,
``_submit_fal_request``) intentionally stay on
:mod:`tools.image_generation_tool`. That module is the patch target for
existing test suites (``tests/tools/test_image_generation.py``,
``tests/tools/test_managed_media_gateways.py``) and for the
``plugins/image_gen/fal/`` plugin's ``_it`` indirection — moving the
caches here would silently defeat ``monkeypatch.setattr(image_tool,
"_managed_fal_client", None)`` because the lookups would go against
``fal_common``'s namespace instead. See the per-rule walkthrough at
issue #26241 for details.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Union
from urllib.parse import urlencode
def import_fal_client() -> Any:
"""Import ``fal_client`` (via ``lazy_deps`` when available) and return
the module reference.
Callers are responsible for caching the result on their own module
global keeping per-module globals lets tests monkey-patch the
target module's ``fal_client`` attribute and have the patched value
stick for that module's call sites.
Raises :class:`ImportError` if the package is genuinely unavailable.
"""
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("image.fal", prompt=False)
except ImportError:
pass
except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints
raise ImportError(str(exc))
import fal_client # type: ignore # noqa: WPS433 — intentionally lazy
return fal_client
def _normalize_fal_queue_url_format(queue_run_origin: str) -> str:
normalized_origin = str(queue_run_origin or "").strip().rstrip("/")
if not normalized_origin:
raise ValueError("Managed FAL queue origin is required")
return f"{normalized_origin}/"
def _extract_http_status(exc: BaseException) -> Optional[int]:
"""Return an HTTP status code from httpx/fal exceptions, else None.
Defensive across exception shapes httpx.HTTPStatusError exposes
``.response.status_code`` while fal_client wrappers may expose
``.status_code`` directly.
"""
response = getattr(exc, "response", None)
if response is not None:
status = getattr(response, "status_code", None)
if isinstance(status, int):
return status
status = getattr(exc, "status_code", None)
if isinstance(status, int):
return status
return None
class _ManagedFalSyncClient:
"""Small per-instance wrapper around ``fal_client.SyncClient`` for
managed queue hosts.
The wrapper carries its own ``fal_client`` module reference instead
of reaching into a module global, so callers stay in control of
which module's ``fal_client`` is in scope (matters for the test
patches that swap the legacy module's ``fal_client`` attribute).
"""
def __init__(self, fal_client: Any, *, key: str, queue_run_origin: str):
sync_client_class = getattr(fal_client, "SyncClient", None)
if sync_client_class is None:
raise RuntimeError("fal_client.SyncClient is required for managed FAL gateway mode")
client_module = getattr(fal_client, "client", None)
if client_module is None:
raise RuntimeError("fal_client.client is required for managed FAL gateway mode")
self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin)
self._sync_client = sync_client_class(key=key)
self._http_client = getattr(self._sync_client, "_client", None)
self._maybe_retry_request = getattr(client_module, "_maybe_retry_request", None)
self._raise_for_status = getattr(client_module, "_raise_for_status", None)
self._request_handle_class = getattr(client_module, "SyncRequestHandle", None)
self._add_hint_header = getattr(client_module, "add_hint_header", None)
self._add_priority_header = getattr(client_module, "add_priority_header", None)
self._add_timeout_header = getattr(client_module, "add_timeout_header", None)
if self._http_client is None:
raise RuntimeError("fal_client.SyncClient._client is required for managed FAL gateway mode")
if self._maybe_retry_request is None or self._raise_for_status is None:
raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode")
if self._request_handle_class is None:
raise RuntimeError("fal_client.client.SyncRequestHandle is required for managed FAL gateway mode")
def submit(
self,
application: str,
arguments: Dict[str, Any],
*,
path: str = "",
hint: Optional[str] = None,
webhook_url: Optional[str] = None,
priority: Any = None,
headers: Optional[Dict[str, str]] = None,
start_timeout: Optional[Union[int, float]] = None,
):
url = self._queue_url_format + application
if path:
url += "/" + path.lstrip("/")
if webhook_url is not None:
url += "?" + urlencode({"fal_webhook": webhook_url})
request_headers = dict(headers or {})
if hint is not None and self._add_hint_header is not None:
self._add_hint_header(hint, request_headers)
if priority is not None:
if self._add_priority_header is None:
raise RuntimeError("fal_client.client.add_priority_header is required for priority requests")
self._add_priority_header(priority, request_headers)
if start_timeout is not None:
if self._add_timeout_header is None:
raise RuntimeError("fal_client.client.add_timeout_header is required for timeout requests")
self._add_timeout_header(start_timeout, request_headers)
response = self._maybe_retry_request(
self._http_client,
"POST",
url,
json=arguments,
timeout=getattr(self._sync_client, "default_timeout", 120.0),
headers=request_headers,
)
self._raise_for_status(response)
data = response.json()
return self._request_handle_class(
request_id=data["request_id"],
response_url=data["response_url"],
status_url=data["status_url"],
cancel_url=data["cancel_url"],
client=self._http_client,
)

View file

@ -174,6 +174,37 @@ def _check_sensitive_path(filepath: str, task_id: str = "default") -> str | None
return None
def _check_cross_profile_path(filepath: str, task_id: str = "default") -> str | None:
"""Return a cross-profile warning string when ``filepath`` lands in
another Hermes profile's skills/plugins/cron/memories directory.
Returns ``None`` when the write is in-scope (same profile) or outside
Hermes scope entirely. Soft guard the agent can override by passing
``cross_profile=True`` to its write tool after explicit user direction.
Defense-in-depth, NOT a security boundary the terminal tool runs
as the same OS user and can write any of these paths directly.
See ``agent/file_safety.classify_cross_profile_target`` for the
detection rules.
"""
try:
from agent.file_safety import get_cross_profile_warning
except Exception:
# Fail open on import error — the existing sensitive-path guard
# plus the write_denied list still apply.
return None
# Resolve via the task's cwd so a relative ``skills/foo/SKILL.md``
# in a session that cd'd into ``~/.hermes/profiles/other/`` is
# classified against the right base.
try:
resolved = str(_resolve_path_for_task(filepath, task_id))
except (OSError, ValueError):
resolved = filepath
return get_cross_profile_warning(resolved)
def _is_expected_write_exception(exc: Exception) -> bool:
"""Return True for expected write denials that should not hit error logs."""
if isinstance(exc, PermissionError):
@ -474,8 +505,13 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
})
# ── Hermes internal path guard ────────────────────────────────
# Prevent prompt injection via catalog or hub metadata files.
block_error = get_read_block_error(path)
# Prevent prompt injection via catalog or hub metadata files,
# and block credential stores under HERMES_HOME. Pass the
# already-resolved path so a relative-path read against
# TERMINAL_CWD == HERMES_HOME (e.g. "auth.json") still hits the
# denylist — get_read_block_error's own resolve() runs against
# the Python process cwd, which can differ.
block_error = get_read_block_error(str(_resolved))
if block_error:
return json.dumps({"error": block_error})
@ -790,11 +826,23 @@ def _check_file_staleness(filepath: str, task_id: str) -> str | None:
return None
def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
"""Write content to a file."""
def write_file_tool(path: str, content: str, task_id: str = "default",
cross_profile: bool = False) -> str:
"""Write content to a file.
``cross_profile`` opts out of the soft cross-Hermes-profile guard. The
guard fires only on writes that land in another profile's
skills/plugins/cron/memories directory; everything else is unaffected.
Pass ``True`` after explicit user direction same shape as ``force``
on the terminal tool.
"""
sensitive_err = _check_sensitive_path(path, task_id)
if sensitive_err:
return tool_error(sensitive_err)
if not cross_profile:
cross_warning = _check_cross_profile_path(path, task_id)
if cross_warning:
return tool_error(cross_warning)
if _is_internal_file_status_text(content):
return tool_error(
"Refusing to write internal read_file status text as file content. "
@ -849,8 +897,13 @@ def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
new_string: str = None, replace_all: bool = False, patch: str = None,
task_id: str = "default") -> str:
"""Patch a file using replace mode or V4A patch format."""
task_id: str = "default", cross_profile: bool = False) -> str:
"""Patch a file using replace mode or V4A patch format.
``cross_profile`` opts out of the soft cross-Hermes-profile guard for
targets under another profile's skills/plugins/cron/memories
directory. Same shape as ``write_file``'s flag.
"""
# Check sensitive paths for both replace (explicit path) and V4A patch (extract paths)
_paths_to_check = []
if path:
@ -863,6 +916,10 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
sensitive_err = _check_sensitive_path(_p, task_id)
if sensitive_err:
return tool_error(sensitive_err)
if not cross_profile:
cross_warning = _check_cross_profile_path(_p, task_id)
if cross_warning:
return tool_error(cross_warning)
try:
# Resolve paths for locking. Ordered + deduplicated so concurrent
# callers lock in the same order — prevents deadlock on overlapping
@ -1047,7 +1104,12 @@ WRITE_FILE_SCHEMA = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to write (will be created if it doesn't exist, overwritten if it does)"},
"content": {"type": "string", "description": "Complete content to write to the file"}
"content": {"type": "string", "description": "Complete content to write to the file"},
"cross_profile": {
"type": "boolean",
"description": "Opt out of the cross-profile soft guard. Defaults to false. Set true ONLY after explicit user direction to edit another Hermes profile's skills/plugins/cron/memories — by default these writes are blocked with a warning because they affect a different profile than the one this session is running under.",
"default": False,
},
},
"required": ["path", "content"]
}
@ -1094,6 +1156,11 @@ PATCH_SCHEMA = {
"type": "string",
"description": "REQUIRED when mode='patch'. V4A format patch content. Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch",
},
"cross_profile": {
"type": "boolean",
"description": "Opt out of the cross-profile soft guard. Defaults to false. Set true ONLY after explicit user direction to edit another Hermes profile's skills/plugins/cron/memories.",
"default": False,
},
},
"required": ["mode"],
},
@ -1144,7 +1211,10 @@ def _handle_write_file(args, **kw):
f"write_file: 'content' must be a string, got "
f"{type(args['content']).__name__}."
)
return write_file_tool(path=args["path"], content=args["content"], task_id=tid)
return write_file_tool(
path=args["path"], content=args["content"], task_id=tid,
cross_profile=bool(args.get("cross_profile", False)),
)
def _handle_patch(args, **kw):
@ -1152,7 +1222,9 @@ def _handle_patch(args, **kw):
return patch_tool(
mode=args.get("mode", "replace"), path=args.get("path"),
old_string=args.get("old_string"), new_string=args.get("new_string"),
replace_all=args.get("replace_all", False), patch=args.get("patch"), task_id=tid)
replace_all=args.get("replace_all", False), patch=args.get("patch"), task_id=tid,
cross_profile=bool(args.get("cross_profile", False)),
)
def _handle_search_files(args, **kw):

View file

@ -26,8 +26,7 @@ import os
import datetime
import threading
import uuid
from typing import Any, Dict, Optional, Union
from urllib.parse import urlencode
from typing import Any, Dict, Optional
# fal_client is imported lazily — see _load_fal_client(). Pulling it
# eagerly added ~64 ms to every CLI cold start because
@ -52,19 +51,17 @@ def _load_fal_client() -> Any:
global fal_client
if fal_client is not None:
return fal_client
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("image.fal", prompt=False)
except ImportError:
pass
except Exception as e:
raise ImportError(str(e))
import fal_client as _fal_client # noqa: F811 — module-global rebind
fal_client = _fal_client
from tools.fal_common import import_fal_client
fal_client = import_fal_client()
return fal_client
from tools.debug_helpers import DebugSession
from tools.fal_common import (
_ManagedFalSyncClient,
_extract_http_status,
_normalize_fal_queue_url_format, # noqa: F401 — re-exported for tests
)
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import (
fal_key_is_configured,
@ -360,95 +357,6 @@ def _resolve_managed_fal_gateway():
return resolve_managed_tool_gateway("fal-queue")
def _normalize_fal_queue_url_format(queue_run_origin: str) -> str:
normalized_origin = str(queue_run_origin or "").strip().rstrip("/")
if not normalized_origin:
raise ValueError("Managed FAL queue origin is required")
return f"{normalized_origin}/"
class _ManagedFalSyncClient:
"""Small per-instance wrapper around fal_client.SyncClient for managed queue hosts."""
def __init__(self, *, key: str, queue_run_origin: str):
# Trigger the lazy import on first construction. Idempotent — the
# placeholder is overwritten with the real module on first call.
_load_fal_client()
sync_client_class = getattr(fal_client, "SyncClient", None)
if sync_client_class is None:
raise RuntimeError("fal_client.SyncClient is required for managed FAL gateway mode")
client_module = getattr(fal_client, "client", None)
if client_module is None:
raise RuntimeError("fal_client.client is required for managed FAL gateway mode")
self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin)
self._sync_client = sync_client_class(key=key)
self._http_client = getattr(self._sync_client, "_client", None)
self._maybe_retry_request = getattr(client_module, "_maybe_retry_request", None)
self._raise_for_status = getattr(client_module, "_raise_for_status", None)
self._request_handle_class = getattr(client_module, "SyncRequestHandle", None)
self._add_hint_header = getattr(client_module, "add_hint_header", None)
self._add_priority_header = getattr(client_module, "add_priority_header", None)
self._add_timeout_header = getattr(client_module, "add_timeout_header", None)
if self._http_client is None:
raise RuntimeError("fal_client.SyncClient._client is required for managed FAL gateway mode")
if self._maybe_retry_request is None or self._raise_for_status is None:
raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode")
if self._request_handle_class is None:
raise RuntimeError("fal_client.client.SyncRequestHandle is required for managed FAL gateway mode")
def submit(
self,
application: str,
arguments: Dict[str, Any],
*,
path: str = "",
hint: Optional[str] = None,
webhook_url: Optional[str] = None,
priority: Any = None,
headers: Optional[Dict[str, str]] = None,
start_timeout: Optional[Union[int, float]] = None,
):
url = self._queue_url_format + application
if path:
url += "/" + path.lstrip("/")
if webhook_url is not None:
url += "?" + urlencode({"fal_webhook": webhook_url})
request_headers = dict(headers or {})
if hint is not None and self._add_hint_header is not None:
self._add_hint_header(hint, request_headers)
if priority is not None:
if self._add_priority_header is None:
raise RuntimeError("fal_client.client.add_priority_header is required for priority requests")
self._add_priority_header(priority, request_headers)
if start_timeout is not None:
if self._add_timeout_header is None:
raise RuntimeError("fal_client.client.add_timeout_header is required for timeout requests")
self._add_timeout_header(start_timeout, request_headers)
response = self._maybe_retry_request(
self._http_client,
"POST",
url,
json=arguments,
timeout=getattr(self._sync_client, "default_timeout", 120.0),
headers=request_headers,
)
self._raise_for_status(response)
data = response.json()
return self._request_handle_class(
request_id=data["request_id"],
response_url=data["response_url"],
status_url=data["status_url"],
cancel_url=data["cancel_url"],
client=self._http_client,
)
def _get_managed_fal_client(managed_gateway):
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
global _managed_fal_client, _managed_fal_client_config
@ -461,7 +369,11 @@ def _get_managed_fal_client(managed_gateway):
if _managed_fal_client is not None and _managed_fal_client_config == client_config:
return _managed_fal_client
# Resolve fal_client on the legacy module — preserves the test
# pattern of monkey-patching ``image_generation_tool.fal_client``.
_load_fal_client()
_managed_fal_client = _ManagedFalSyncClient(
fal_client,
key=managed_gateway.nous_user_token,
queue_run_origin=managed_gateway.gateway_origin,
)
@ -502,24 +414,6 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]):
raise
def _extract_http_status(exc: BaseException) -> Optional[int]:
"""Return an HTTP status code from httpx/fal exceptions, else None.
Defensive across exception shapes httpx.HTTPStatusError exposes
``.response.status_code`` while fal_client wrappers may expose
``.status_code`` directly.
"""
response = getattr(exc, "response", None)
if response is not None:
status = getattr(response, "status_code", None)
if isinstance(status, int):
return status
status = getattr(exc, "status_code", None)
if isinstance(status, int):
return status
return None
# ---------------------------------------------------------------------------
# Model resolution + payload construction
# ---------------------------------------------------------------------------
@ -973,9 +867,12 @@ def _read_configured_image_provider():
"""Return the value of ``image_gen.provider`` from config.yaml, or None.
We only consult the plugin registry when this is explicitly set an
unset value keeps users on the legacy in-tree FAL path even when other
unset value keeps users on the in-tree FAL fallback even when other
providers happen to be registered (e.g. a user has OPENAI_API_KEY set
for other features but never asked for OpenAI image gen).
for other features but never asked for OpenAI image gen). ``"fal"``
explicitly routes through ``plugins/image_gen/fal/`` (which delegates
back into this module's pipeline via call-time indirection — see
issue #26241).
"""
try:
from hermes_cli.config import load_config
@ -994,15 +891,16 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
"""Route the call to a plugin-registered provider when one is selected.
Returns a JSON string on dispatch, or ``None`` to fall through to the
built-in FAL path.
in-tree FAL fallback in ``image_generate_tool``.
Dispatch only fires when ``image_gen.provider`` is explicitly set AND
it does not point to ``fal`` (FAL still lives in-tree in this PR;
a later PR ports it into ``plugins/image_gen/fal/``). Any other value
that matches a registered plugin provider wins.
Dispatch fires when ``image_gen.provider`` is explicitly set including
``"fal"`` itself, which now resolves to the
``plugins/image_gen/fal/`` plugin (the plugin re-enters this module's
pipeline via ``_it`` indirection so behavior is identical to the
direct call, just routed through the registry).
"""
configured = _read_configured_image_provider()
if not configured or configured == "fal":
if not configured:
return None
# Also read configured model so we can pass it to the plugin

View file

@ -1255,6 +1255,15 @@ class MCPServerTask:
async def _run_stdio(self, config: dict):
"""Run the server using stdio transport."""
if not _MCP_AVAILABLE:
raise ImportError(
f"MCP server '{self.name}' requires the 'mcp' Python SDK, but "
"it is not installed. Install with:\n"
" pip install 'hermes-agent[mcp]'\n"
"or (full install):\n"
" pip install 'hermes-agent[all]'"
)
command = config.get("command")
args = config.get("args", [])
user_env = config.get("env")

View file

@ -28,6 +28,7 @@ import logging
import os
import re
import tempfile
import time
from contextlib import contextmanager
from pathlib import Path
from hermes_constants import get_hermes_home
@ -104,6 +105,36 @@ def _scan_memory_content(content: str) -> Optional[str]:
return None
def _drift_error(path: "Path", bak_path: str) -> Dict[str, Any]:
"""Build the error dict returned when external drift is detected.
The on-disk memory file contains content that wouldn't round-trip
through the tool's parser/serializer — flushing would discard the
appended/edited content from a patch tool, shell append, manual edit,
or sister-session write. We refuse the mutation, point the operator at
the .bak.<ts> snapshot we took, and tell them what to do next.
"""
return {
"success": False,
"error": (
f"Refusing to write {path.name}: file on disk has content that "
f"wouldn't round-trip through the memory tool (likely added by "
f"the patch tool, a shell append, a manual edit, or a "
f"concurrent session). A snapshot was saved to {bak_path}. "
f"Resolve the drift first — either rewrite the file as a clean "
f"§-delimited list of entries, or move the extra content out — "
f"then retry. This guard exists to prevent silent data loss "
f"(issue #26045)."
),
"drift_backup": bak_path,
"remediation": (
"Open the .bak file, integrate the missing entries into the "
"memory tool one at a time via memory(action=add, content=...), "
"then remove or rewrite the original file to a clean state."
),
}
class MemoryStore:
"""
Bounded curated memory with file persistence. One instance per AIAgent.
@ -185,14 +216,23 @@ class MemoryStore:
return mem_dir / "USER.md"
return mem_dir / "MEMORY.md"
def _reload_target(self, target: str):
def _reload_target(self, target: str) -> Optional[str]:
"""Re-read entries from disk into in-memory state.
Called under file lock to get the latest state before mutating.
Returns the backup path if external drift was detected (the on-disk
file contains content that wouldn't round-trip through our
parser/serializer, OR an entry larger than the store's char limit).
When drift is detected the caller must abort the mutation
flushing would discard the un-roundtrippable content.
Returns None on clean reload.
"""
fresh = self._read_file(self._path_for(target))
path = self._path_for(target)
bak = self._detect_external_drift(target)
fresh = self._read_file(path)
fresh = list(dict.fromkeys(fresh)) # deduplicate
self._set_entries(target, fresh)
return bak
def save_to_disk(self, target: str):
"""Persist entries to the appropriate file. Called after every mutation."""
@ -233,8 +273,13 @@ class MemoryStore:
return {"success": False, "error": scan_error}
with self._file_lock(self._path_for(target)):
# Re-read from disk under lock to pick up writes from other sessions
self._reload_target(target)
# Re-read from disk under lock to pick up writes from other sessions.
# If external drift was detected, the file was backed up to .bak.<ts>
# — refuse the mutation so we don't clobber the un-roundtrippable
# content the patch tool / shell append / sister session wrote.
bak = self._reload_target(target)
if bak:
return _drift_error(self._path_for(target), bak)
entries = self._entries_for(target)
limit = self._char_limit(target)
@ -281,7 +326,9 @@ class MemoryStore:
return {"success": False, "error": scan_error}
with self._file_lock(self._path_for(target)):
self._reload_target(target)
bak = self._reload_target(target)
if bak:
return _drift_error(self._path_for(target), bak)
entries = self._entries_for(target)
matches = [(i, e) for i, e in enumerate(entries) if old_text in e]
@ -331,7 +378,9 @@ class MemoryStore:
return {"success": False, "error": "old_text cannot be empty."}
with self._file_lock(self._path_for(target)):
self._reload_target(target)
bak = self._reload_target(target)
if bak:
return _drift_error(self._path_for(target), bak)
entries = self._entries_for(target)
matches = [(i, e) for i, e in enumerate(entries) if old_text in e]
@ -430,6 +479,61 @@ class MemoryStore:
entries = [e.strip() for e in raw.split(ENTRY_DELIMITER)]
return [e for e in entries if e]
def _detect_external_drift(self, target: str) -> Optional[str]:
"""Return a backup-path string if on-disk content shows external drift.
The memory file is supposed to be a list of small entries the tool
wrote, joined by §. Detect drift via two signals:
1. Round-trip mismatch re-parsing and re-serializing the file
doesn't produce identical bytes (rare; would catch oddly-encoded
delimiters).
2. Entry-size overflow any single parsed entry exceeds the
store's whole-file char limit. The tool budgets the ENTIRE store
against that limit; no single tool-written entry can exceed it.
When we see one entry larger than the limit, an external writer
(patch tool, shell append, manual edit, sister session) appended
free-form content into what the tool will treat as one entry.
Flushing would then truncate that entry to the model's new
content, discarding the appended bytes issue #26045.
Returns the absolute path of the .bak file when drift was found and
backed up; returns None when the file looks tool-shaped.
Note: this is an INSTANCE method (not static) because we need the
per-target char_limit for signal #2.
"""
path = self._path_for(target)
if not path.exists():
return None
try:
raw = path.read_text(encoding="utf-8")
except (OSError, IOError):
return None
if not raw.strip():
return None
parsed = [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()]
roundtrip = ENTRY_DELIMITER.join(parsed)
char_limit = self._char_limit(target)
max_entry_len = max((len(e) for e in parsed), default=0)
drift_detected = (raw.strip() != roundtrip) or (max_entry_len > char_limit)
if not drift_detected:
return None
# Drift confirmed — snapshot the file so the operator can recover
# whatever the external writer added, then return the .bak path so
# the caller can refuse the mutation.
ts = int(time.time())
bak_path = path.with_suffix(path.suffix + f".bak.{ts}")
try:
bak_path.write_text(raw, encoding="utf-8")
except (OSError, IOError):
return str(bak_path) + " (BACKUP FAILED — file unchanged on disk)"
return str(bak_path)
@staticmethod
def _write_file(path: Path, entries: List[str]):
"""Write entries to a memory file using atomic temp-file + rename.

View file

@ -434,9 +434,50 @@ class ProcessRegistry:
@staticmethod
def _terminate_host_pid(pid: int) -> None:
"""Terminate a host-visible PID without requiring the original process handle."""
"""Terminate a host-visible PID and its descendants.
POSIX: walks the process tree with ``psutil`` and SIGTERMs
children before the parent so subprocess trees (e.g. Chromium
renderers/GPU helpers spawned by an ``agent-browser`` daemon)
don't get reparented to init and survive cleanup.
Windows: shells out to ``taskkill /PID <pid> /T /F``. This is
the documented Microsoft primitive for tree-kill and matches the
existing convention in ``gateway.status.terminate_pid``. We can't
reuse the POSIX psutil path on Windows because:
1. Windows doesn't maintain a Unix-style process tree —
``psutil.Process.children(recursive=True)`` walks PPID
links that go stale when intermediate processes exit, so
enumeration is best-effort and misses orphaned descendants.
2. ``psutil.Process.terminate()`` on Windows is
``TerminateProcess()`` which kills only the target handle
and is a hard kill there is no Windows equivalent of a
SIGTERM that cascades through a process group. (See the
warning in ``gateway/status.py::terminate_pid``: "os.kill
with SIGTERM is not equivalent to a tree-killing hard stop"
on Windows.) Headless Chromium has no GUI window, so the
softer ``taskkill /T`` without ``/F`` won't reach it either.
``psutil`` is a hard dependency (see ``pyproject.toml``); the
bare-``os.kill`` fallback covers OSError / PermissionError on
POSIX and a missing ``taskkill.exe`` on Windows (effectively
unreachable on real Windows installs, but cheap insurance).
"""
if _IS_WINDOWS:
os.kill(pid, signal.SIGTERM)
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True,
text=True,
timeout=10,
creationflags=windows_hide_flags(),
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
try:
os.kill(pid, signal.SIGTERM)
except (OSError, ProcessLookupError, PermissionError):
pass
return
import psutil

View file

@ -139,7 +139,7 @@ SEND_MESSAGE_SCHEMA = {
},
"message": {
"type": "string",
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> (e.g. 'MEDIA:/tmp/hermes/cache/img_xxx.jpg') in the message — the platform will deliver it as a native media attachment."
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> for a file under a Hermes media cache or HERMES_MEDIA_ALLOW_DIRS — the platform will deliver it as a native media attachment."
}
},
"required": []
@ -251,6 +251,7 @@ def _handle_send(args):
force_document_attachments = "[[as_document]]" in message
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
used_home_channel = False
@ -563,7 +564,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
"""
from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter, utf16_len
from gateway.platforms.discord import DiscordAdapter
from gateway.platforms.slack import SlackAdapter
# Telegram adapter import is optional (requires python-telegram-bot)
@ -589,10 +589,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
except Exception:
logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
# Platform message length limits (from adapter class attributes)
# Platform message length limits (from adapter class attributes for
# built-in platforms; from PlatformEntry.max_message_length for plugins).
_MAX_LENGTHS = {
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
}
if _feishu_available:
@ -642,17 +642,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
if platform == Platform.WEIXIN:
return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
# --- Discord: special handling for media attachments ---
# --- Discord: chunked delivery via the registry's standalone_sender_fn.
# The plugin's ``_standalone_send`` (registered in
# plugins/platforms/discord/adapter.py) handles forum channels, threads,
# and multipart media uploads. ``_send_via_adapter`` tries the live
# in-process adapter first via ``adapter.send()``, but Discord's elif
# historically went straight to the HTTP path; we preserve that by
# explicitly invoking the registry hook here so behavior is unchanged.
if platform == Platform.DISCORD:
from gateway.platform_registry import platform_registry
entry = platform_registry.get("discord")
if entry is None or entry.standalone_sender_fn is None:
return {"error": "Discord plugin not registered or missing standalone_sender_fn"}
last_result = None
for i, chunk in enumerate(chunks):
is_last = (i == len(chunks) - 1)
result = await _send_discord(
pconfig.token,
result = await entry.standalone_sender_fn(
pconfig,
chat_id,
chunk,
media_files=media_files if is_last else [],
thread_id=thread_id,
media_files=media_files if is_last else [],
)
if isinstance(result, dict) and result.get("error"):
return result
@ -1026,227 +1036,6 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
return _error(f"Telegram send failed: {e}")
def _derive_forum_thread_name(message: str) -> str:
"""Derive a thread name from the first line of the message, capped at 100 chars."""
first_line = message.strip().split("\n", 1)[0].strip()
# Strip common markdown heading prefixes
first_line = first_line.lstrip("#").strip()
if not first_line:
first_line = "New Post"
return first_line[:100]
# Process-local cache for Discord channel-type probes. Avoids re-probing the
# same channel on every send when the directory cache has no entry (e.g. fresh
# install, or channel created after the last directory build).
_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
_DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
async def _send_discord(token, chat_id, message, thread_id=None, media_files=None):
"""Send a single message via Discord REST API (no websocket client needed).
Chunking is handled by _send_to_platform() before this is called.
When thread_id is provided, the message is sent directly to that thread
via the /channels/{thread_id}/messages endpoint.
Media files are uploaded one-by-one via multipart/form-data after the
text message is sent (same pattern as Telegram).
Forum channels (type 15) reject POST /messages a thread post is created
automatically via POST /channels/{id}/threads. Media files are uploaded
as multipart attachments on the starter message of the new thread.
Channel type is resolved from the channel directory first, then a
process-local probe cache, and only as a last resort with a live
GET /channels/{id} probe (whose result is memoized).
"""
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
auth_headers = {"Authorization": f"Bot {token}"}
json_headers = {**auth_headers, "Content-Type": "application/json"}
media_files = media_files or []
last_data = None
warnings = []
# Thread endpoint: Discord threads are channels; send directly to the thread ID.
if thread_id:
url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
else:
# Check if the target channel is a forum channel (type 15).
# Forum channels reject POST /messages — create a thread post instead.
# Three-layer detection: directory cache → process-local probe
# cache → GET /channels/{id} probe (with result memoized).
_channel_type = None
try:
from gateway.channel_directory import lookup_channel_type
_channel_type = lookup_channel_type("discord", chat_id)
except Exception:
pass
if _channel_type == "forum":
is_forum = True
elif _channel_type is not None:
is_forum = False
else:
cached = _probe_is_forum_cached(chat_id)
if cached is not None:
is_forum = cached
else:
is_forum = False
try:
info_url = f"https://discord.com/api/v10/channels/{chat_id}"
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
if info_resp.status == 200:
info = await info_resp.json()
is_forum = info.get("type") == 15
_remember_channel_is_forum(chat_id, is_forum)
except Exception:
logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
if is_forum:
thread_name = _derive_forum_thread_name(message)
thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
# Filter to readable media files up front so we can pick the
# right code path (JSON vs multipart) before opening a session.
valid_media = []
for media_path, _is_voice in media_files:
if not os.path.exists(media_path):
warning = f"Media file not found, skipping: {media_path}"
logger.warning(warning)
warnings.append(warning)
continue
valid_media.append(media_path)
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
if valid_media:
# Multipart: payload_json + files[N] creates a forum
# thread with the starter message plus attachments in
# a single API call.
attachments_meta = [
{"id": str(idx), "filename": os.path.basename(path)}
for idx, path in enumerate(valid_media)
]
starter_message = {"content": message, "attachments": attachments_meta}
payload_json = json.dumps({"name": thread_name, "message": starter_message})
form = aiohttp.FormData()
form.add_field("payload_json", payload_json, content_type="application/json")
# Buffer file bytes up front — aiohttp's FormData can
# read lazily and we don't want handles closing under
# it on retry.
try:
for idx, media_path in enumerate(valid_media):
with open(media_path, "rb") as fh:
form.add_field(
f"files[{idx}]",
fh.read(),
filename=os.path.basename(media_path),
)
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
data = await resp.json()
except Exception as e:
return _error(_sanitize_error_text(f"Discord forum thread upload failed: {e}"))
else:
# No media — simple JSON POST creates the thread with
# just the text starter.
async with session.post(
thread_url,
headers=json_headers,
json={
"name": thread_name,
"message": {"content": message},
},
**_req_kw,
) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
data = await resp.json()
thread_id_created = data.get("id")
starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
result = {
"success": True,
"platform": "discord",
"chat_id": chat_id,
"thread_id": thread_id_created,
"message_id": starter_msg_id,
}
if warnings:
result["warnings"] = warnings
return result
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
# Send text message (skip if empty and media is present)
if message.strip() or not media_files:
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Discord API error ({resp.status}): {body}")
last_data = await resp.json()
# Send each media file as a separate multipart upload
for media_path, _is_voice in media_files:
if not os.path.exists(media_path):
warning = f"Media file not found, skipping: {media_path}"
logger.warning(warning)
warnings.append(warning)
continue
try:
form = aiohttp.FormData()
filename = os.path.basename(media_path)
with open(media_path, "rb") as f:
form.add_field("files[0]", f, filename=filename)
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
logger.error(warning)
warnings.append(warning)
continue
last_data = await resp.json()
except Exception as e:
warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
logger.error(warning)
warnings.append(warning)
if last_data is None:
error = "No deliverable text or media remained after processing"
if warnings:
return {"error": error, "warnings": warnings}
return {"error": error}
result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
if warnings:
result["warnings"] = warnings
return result
except Exception as e:
return _error(f"Discord send failed: {e}")
async def _send_slack(token, chat_id, message):
"""Send via Slack Web API."""
try:

View file

@ -40,7 +40,7 @@ import shutil
import tempfile
from pathlib import Path
from hermes_constants import get_hermes_home, display_hermes_home
from typing import Dict, Any, Optional, Tuple
from typing import Dict, Any, List, Optional, Tuple
from utils import atomic_replace, is_truthy_value
from hermes_cli.config import cfg_get
@ -295,6 +295,109 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]:
return None
def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]:
"""Look for ``name`` under SKILL.md across OTHER Hermes profiles.
Returns a list of ``(profile_name, skill_dir)`` pairs. Used to make
the "Skill X not found" error explain when the user is editing the
wrong profile. Empty list when no other profile has the skill (or
when profile discovery fails fail-quiet, the caller falls back to
the plain "not found" error).
"""
matches: List[Tuple[str, Path]] = []
try:
from hermes_constants import get_default_hermes_root
from agent.skill_utils import is_excluded_skill_path
except Exception:
return matches
try:
root = get_default_hermes_root()
except Exception:
return matches
# Collect (profile_name, skills_dir) for every profile EXCEPT the
# one whose SKILLS_DIR we already searched in _find_skill().
active_dir = SKILLS_DIR.resolve() if SKILLS_DIR.exists() else SKILLS_DIR
candidates: List[Tuple[str, Path]] = []
# Default profile (~/.hermes/skills) — only consider when active is non-default.
default_skills = root / "skills"
try:
if default_skills.resolve() != active_dir:
candidates.append(("default", default_skills))
except (OSError, RuntimeError):
pass
# All named profiles (~/.hermes/profiles/*/skills)
profiles_root = root / "profiles"
if profiles_root.is_dir():
try:
for entry in profiles_root.iterdir():
if not entry.is_dir():
continue
pskills = entry / "skills"
try:
if pskills.resolve() == active_dir:
continue
except (OSError, RuntimeError):
continue
candidates.append((entry.name, pskills))
except OSError:
pass
for profile_name, skills_dir in candidates:
if not skills_dir.is_dir():
continue
try:
for skill_md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
if skill_md.parent.name == name:
matches.append((profile_name, skill_md.parent))
break # one match per profile is enough
except OSError:
continue
return matches
def _skill_not_found_error(name: str, suffix: str = "") -> str:
"""Build a "skill not found" error that names other profiles holding
the same skill, so the agent can recognize a profile-scoping mistake.
``suffix`` is appended after the cross-profile hint if present
(e.g. ``" Create it first with action='create'."``).
"""
from agent.file_safety import _resolve_active_profile_name
active = _resolve_active_profile_name()
base = f"Skill '{name}' not found in active profile '{active}'."
others = _find_skill_in_other_profiles(name)
if others:
if len(others) == 1:
other_profile, other_path = others[0]
base += (
f" A skill by that name exists in profile "
f"'{other_profile}' ({other_path}). To edit a skill in "
f"another profile, switch profiles (`hermes -p "
f"{other_profile}`) or operate via explicit file tools "
f"with ``cross_profile=True``."
)
else:
names = ", ".join(f"'{p}'" for p, _ in others)
base += (
f" Skills by that name exist in other profiles: {names}. "
f"Switch profiles (`hermes -p <name>`) to edit there, or "
f"operate via explicit file tools with ``cross_profile=True``."
)
else:
base += " Use skills_list() to see available skills."
if suffix:
base += suffix
return base
def _validate_file_path(file_path: str) -> Optional[str]:
"""
Validate a file path for write_file/remove_file.
@ -439,7 +542,7 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
return {"success": False, "error": _skill_not_found_error(name)}
skill_md = existing["path"] / "SKILL.md"
# Back up original content for rollback
@ -479,7 +582,7 @@ def _patch_skill(
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
return {"success": False, "error": _skill_not_found_error(name)}
skill_dir = existing["path"]
@ -568,7 +671,7 @@ def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, A
"""
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
return {"success": False, "error": _skill_not_found_error(name)}
pinned_err = _pinned_guard(name)
if pinned_err:
@ -637,7 +740,7 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
return {"success": False, "error": _skill_not_found_error(name, " Create it first with action='create'.")}
target, err = _resolve_skill_target(existing["path"], file_path)
if err:
@ -671,7 +774,7 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
return {"success": False, "error": _skill_not_found_error(name)}
skill_dir = existing["path"]

133
tools/skills_ast_audit.py Normal file
View file

@ -0,0 +1,133 @@
"""
AST-level deep audit for skill Python files opt-in diagnostic, not a security gate.
Per SECURITY.md §2.4, Skills Guard is in-process heuristics ("useful — not
boundaries"). This module is a separate opt-in diagnostic that flags dynamic
import / dynamic attribute access patterns operators may want to eyeball when
reviewing third-party skill code. Every pattern flagged here has legitimate
uses; findings are hints for human review, not verdicts.
CLI: ``hermes skills audit --deep``
"""
from __future__ import annotations
import ast
from pathlib import Path
from typing import List, Tuple
# (file, line, pattern_id, description)
Finding = Tuple[str, int, str, str]
_IGNORED_DIRS = {"__pycache__", ".venv", "venv", "node_modules"}
def _scan_source(content: str, rel_path: str) -> List[Finding]:
try:
tree = ast.parse(content)
except (SyntaxError, ValueError, RecursionError):
return []
findings: List[Finding] = []
class V(ast.NodeVisitor):
def visit_Call(self, node):
f = node.func
# importlib.import_module(...)
if isinstance(f, ast.Attribute) and f.attr == "import_module":
findings.append((rel_path, node.lineno, "dynamic_import",
"importlib.import_module() — loads arbitrary modules at runtime"))
# __import__(<computed>)
elif isinstance(f, ast.Name) and f.id == "__import__":
if node.args and not isinstance(node.args[0], ast.Constant):
findings.append((rel_path, node.lineno, "dynamic_import_computed",
"__import__ with non-literal module name"))
# getattr(obj, <computed>)
elif isinstance(f, ast.Name) and f.id == "getattr":
if len(node.args) >= 2 and not isinstance(node.args[1], ast.Constant):
findings.append((rel_path, node.lineno, "dynamic_getattr",
"getattr with non-literal attribute name"))
self.generic_visit(node)
def visit_Subscript(self, node):
# obj.__dict__[<computed>]
if (isinstance(node.value, ast.Attribute)
and node.value.attr == "__dict__"
and not isinstance(node.slice, ast.Constant)):
findings.append((rel_path, node.lineno, "dict_access",
"__dict__[<computed>] — dynamic attribute access"))
self.generic_visit(node)
def visit_Import(self, node):
for a in node.names:
if a.name == "importlib" or a.name.startswith("importlib."):
findings.append((rel_path, node.lineno, "importlib_import",
f"import {a.name} — enables dynamic module loading"))
self.generic_visit(node)
def visit_ImportFrom(self, node):
m = node.module or ""
if m == "importlib" or m.startswith("importlib."):
findings.append((rel_path, node.lineno, "importlib_import",
f"from {m} import ... — enables dynamic module loading"))
self.generic_visit(node)
try:
V().visit(tree)
except (RecursionError, ValueError, RuntimeError):
# Hostile/pathological input: return what we collected so far.
pass
return findings
def ast_scan_path(path: Path) -> List[Finding]:
"""Scan a single .py file or recursively scan all .py under a directory.
Returns a list of (file, line, pattern_id, description) tuples. Empty for
non-Python paths, missing paths, or paths with no matching patterns.
"""
if path.is_file():
if path.suffix.lower() != ".py":
return []
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
return _scan_source(content, path.name)
if not path.is_dir():
return []
out: List[Finding] = []
for py in sorted(path.rglob("*.py")):
if set(py.parent.parts) & _IGNORED_DIRS:
continue
try:
content = py.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
try:
rel = py.relative_to(path).as_posix()
except ValueError:
rel = py.name
out.extend(_scan_source(content, rel))
return out
def format_ast_report(findings: List[Finding], skill_name: str = "") -> str:
"""Plain-text report (Rich-markup-free) grouped by file."""
header = f"AST deep scan: {skill_name}" if skill_name else "AST deep scan"
if not findings:
return f"{header}\n No dynamic import/access patterns detected."
lines = [header, f" {len(findings)} finding(s):"]
current = None
for f, line, pid, desc in sorted(findings):
if f != current:
current = f
lines.append(f" {f}")
lines.append(f" L{line} {pid}{desc}")
lines.append("")
lines.append(" Note: diagnostic hints for human review, not security verdicts.")
return "\n".join(lines)

View file

@ -661,7 +661,7 @@ def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool,
if decision == "allow":
return True, f"Allowed ({result.trust_level} source, {result.verdict} verdict)"
if force:
if force and not (result.verdict == "dangerous" and result.trust_level in ("community", "trusted")):
return True, (
f"Force-installed despite {result.verdict} verdict "
f"({len(result.findings)} findings)"
@ -674,6 +674,13 @@ def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool,
f"{len(result.findings)} findings)"
)
# Dangerous verdicts cannot be overridden by --force (community/trusted);
# other blocks can.
if result.verdict == "dangerous" and result.trust_level in ("community", "trusted"):
return False, (
f"Blocked ({result.trust_level} source + dangerous verdict, "
f"{len(result.findings)} findings). --force does not override a dangerous verdict."
)
return False, (
f"Blocked ({result.trust_level} source + {result.verdict} verdict, "
f"{len(result.findings)} findings). Use --force to override."
@ -717,12 +724,24 @@ def format_scan_report(result: ScanResult) -> str:
def content_hash(skill_path: Path) -> str:
"""Compute a SHA-256 hash of all files in a skill directory for integrity tracking."""
"""Compute a SHA-256 hash of all files in a skill directory for integrity tracking.
File paths (relative to ``skill_path``) are mixed into the hash alongside
file contents so that swapping the contents of two files in a skill
changes the hash. This must stay symmetric with
``tools.skills_hub.bundle_content_hash`` both functions need to
produce the same digest for the same skill (one operates on disk,
one on an in-memory bundle), so any change to the hash shape MUST
land in both places at once.
"""
h = hashlib.sha256()
if skill_path.is_dir():
for f in sorted(skill_path.rglob("*")):
if f.is_file():
try:
rel = f.relative_to(skill_path).as_posix()
h.update(rel.encode("utf-8"))
h.update(b"\x00")
h.update(f.read_bytes())
except OSError:
continue
@ -920,7 +939,8 @@ def _determine_verdict(findings: List[Finding]) -> str:
return "dangerous"
if has_high:
return "caution"
return "caution"
# medium/low findings alone are informational, not blocking
return "safe"
def _build_summary(name: str, source: str, trust: str, verdict: str, findings: List[Finding]) -> str:

View file

@ -3000,6 +3000,13 @@ def uninstall_skill(skill_name: str) -> Tuple[bool, str]:
return False, f"'{skill_name}' is not a hub-installed skill (may be a builtin)"
install_path = SKILLS_DIR / entry["install_path"]
# Prevent path traversal from poisoned lock.json entries
try:
resolved = install_path.resolve()
if not resolved.is_relative_to(SKILLS_DIR.resolve()):
return False, f"Refusing to remove '{entry['install_path']}': resolves outside skills directory"
except (ValueError, OSError):
return False, f"Refusing to remove '{entry['install_path']}': path resolution failed"
if install_path.exists():
shutil.rmtree(install_path)
@ -3013,6 +3020,10 @@ def bundle_content_hash(bundle: SkillBundle) -> str:
"""Compute a deterministic hash for an in-memory skill bundle."""
h = hashlib.sha256()
for rel_path in sorted(bundle.files):
# Include the path so swapping file contents between two paths
# changes the hash (avoids filename-swap evading update detection).
h.update(rel_path.encode("utf-8"))
h.update(b"\x00")
content = bundle.files[rel_path]
if isinstance(content, bytes):
h.update(content)

View file

@ -904,9 +904,9 @@ Do NOT use echo/cat heredoc to create files — use write_file instead.
Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.
Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts you'll still get the result in seconds if it's fast. Prefer foreground for short commands.
Background: Set background=true to get a session_id. Two patterns:
(1) Long-lived processes that never exit (servers, watchers).
(2) Long-running tasks with notify_on_complete=true you can keep working on other things and the system auto-notifies you when the task finishes. Great for test suites, builds, deployments, or anything that takes more than a minute.
Background: Set background=true to get a session_id. Almost always pair with notify_on_complete=true bg without notify runs SILENTLY and you have no way to learn it finished short of calling process(action='poll') yourself. Two legitimate uses:
(1) Long-lived processes that never exit (servers, watchers, daemons) silent is correct, there's no exit to notify on.
(2) Long-running bounded tasks (tests, builds, deploys, CI pollers, batch jobs) MUST set notify_on_complete=true. Without it you'll either forget to poll or sit blocked waiting for the user to surface the result.
For servers/watchers, do NOT use shell-level background wrappers (nohup/disown/setsid/trailing '&') in foreground mode. Use background=true so Hermes can track lifecycle and output.
After starting a server, verify readiness with a health check or log signal, then run tests in a separate terminal() call. Avoid blind sleep loops.
Use process(action="poll") for progress checks, process(action="wait") to block until done.
@ -1959,6 +1959,32 @@ def terminal_tool(
if pty_disabled_reason:
result_data["pty_note"] = pty_disabled_reason
# Nudge: background=True without notify_on_complete=True OR
# watch_patterns is a silent process. The agent has NO way to
# learn it finished short of calling process(action="poll"/"wait")
# explicitly. That's correct only for genuine long-lived
# processes that never exit (servers, watchers). For every
# bounded task (tests, builds, CI pollers, deploys, batch
# jobs) the agent almost certainly wanted notification and
# forgot the flag. May 2026 PR #31231 incident: bg CI poller
# ran fine, exited green, agent never noticed — user had to
# surface the result. Cheap nudge here costs ~one read for
# server cases (false positive) and prevents silent
# blindness for bounded-task cases (false negative).
if background and not notify_on_complete and not watch_patterns:
result_data["hint"] = (
"background=true without notify_on_complete=true means "
"this process runs SILENTLY — you will not be told when "
"it exits. If this is a bounded task (test suite, build, "
"CI poller, deploy, anything with a defined end), you "
"almost certainly wanted notify_on_complete=true so the "
"system pings you on exit. Re-launch with "
"notify_on_complete=true, or call process(action='poll') "
"/ process(action='wait') yourself to learn the outcome. "
"Only ignore this hint for genuine long-lived processes "
"that never exit (servers, watchers, daemons)."
)
# Populate routing metadata on the session so that
# watch-pattern and completion notifications can be
# routed back to the correct chat/thread.
@ -2322,7 +2348,7 @@ TERMINAL_SCHEMA = {
},
"background": {
"type": "boolean",
"description": "Run the command in the background. Two patterns: (1) Long-lived processes that never exit (servers, watchers). (2) Long-running tasks paired with notify_on_complete=true — you can keep working and get notified when the task finishes. For short commands, prefer foreground with a generous timeout instead.",
"description": "Run the command in the background. Almost always pair with notify_on_complete=true — without it, the process runs silently and you'll have no way to learn it finished short of calling process(action='poll') yourself (easy to forget, leading to silent blindness on long jobs). Two legitimate patterns: (1) Long-lived processes that never exit (servers, watchers, daemons) — these stay silent because there's no exit to notify on. (2) Long-running bounded tasks (tests, builds, deploys, CI pollers, batch jobs) — these MUST set notify_on_complete=true. For short commands, prefer foreground with a generous timeout instead.",
"default": False
},
"timeout": {

View file

@ -197,6 +197,26 @@ def _normalize_local_command_model(model_name: Optional[str]) -> str:
return _normalize_local_model(model_name)
def _try_lazy_install_stt() -> bool:
"""Attempt to lazy-install faster-whisper and return True on success.
The module-level ``_HAS_FASTER_WHISPER`` flag is set at import time and
cached. If the package wasn't installed at startup, calling ``ensure()``
installs it. This function re-checks dynamically after installation so
the provider can use it immediately without a process restart.
"""
try:
from tools.lazy_deps import ensure
ensure("stt.faster_whisper")
# Re-check dynamically after install
import importlib.util as _iu
if _iu.find_spec("faster_whisper"):
return True
except Exception as exc:
logger.debug("Lazy install of faster-whisper failed: %s", exc)
return False
def _get_provider(stt_config: dict) -> str:
"""Determine which STT provider to use.
@ -218,6 +238,9 @@ def _get_provider(stt_config: dict) -> str:
return "local"
if _has_local_command():
return "local_command"
# Try lazy-install before giving up
if _try_lazy_install_stt():
return "local"
logger.warning(
"STT provider 'local' configured but unavailable "
"(install faster-whisper or set HERMES_LOCAL_STT_COMMAND)"
@ -285,6 +308,9 @@ def _get_provider(stt_config: dict) -> str:
return "local"
if _has_local_command():
return "local_command"
# Try lazy-install before falling through to cloud providers
if _try_lazy_install_stt():
return "local"
if _HAS_OPENAI and get_env_value("GROQ_API_KEY"):
logger.info("No local STT available, using Groq Whisper API")
return "groq"
@ -403,7 +429,8 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]:
global _local_model, _local_model_name
if not _HAS_FASTER_WHISPER:
return {"success": False, "transcript": "", "error": "faster-whisper not installed"}
if not _try_lazy_install_stt():
return {"success": False, "transcript": "", "error": "faster-whisper not installed"}
try:
# Lazy-load the model (downloads on first use, ~150 MB for 'base')

View file

@ -914,11 +914,26 @@ async def vision_analyze_tool(
def check_vision_requirements() -> bool:
"""Check if the configured runtime vision path can resolve a client."""
"""Check if the configured runtime vision path can resolve a client.
Mirrors the fallback chain that ``call_llm(task="vision")`` actually uses
at runtime: first the explicit ``auxiliary.vision.provider`` (if any),
and if that fails, the auto chain (main provider openrouter nous).
Without the auto-fallback step the tool would disappear from the model's
tool list whenever the explicit provider name was unresolvable, even
when the auto chain would have served the request (issue #31179).
"""
try:
from agent.auxiliary_client import resolve_vision_provider_client
except ImportError:
return False
try:
_provider, client, _model = resolve_vision_provider_client()
if client is not None:
return True
# Same fallback to "auto" that call_llm performs when the configured
# provider can't be resolved.
_provider, client, _model = resolve_vision_provider_client(provider="auto")
return client is not None
except Exception:
return False

View file

@ -472,6 +472,7 @@ async def _handle_yb_send_dm(args, **kw):
embedded_media, message = BasePlatformAdapter.extract_media(message)
if embedded_media:
media_files.extend(embedded_media)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
return tool_result(await send_dm(
group_code=group_code, name=args.get("name", ""),