mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-08 08:11:38 +00:00
Merge branch 'main' into docker_s6
This commit is contained in:
commit
59da190512
417 changed files with 26434 additions and 3321 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
163
tools/fal_common.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
133
tools/skills_ast_audit.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue