Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -496,12 +496,12 @@ class CDPSupervisor:
|
||||
if not session_id:
|
||||
return {"ok": False, "error": "supervisor has no attached page session"}
|
||||
|
||||
async def _do_eval() -> Dict[str, Any]:
|
||||
async def _do_eval(by_value: bool) -> Dict[str, Any]:
|
||||
return await self._cdp(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": expression,
|
||||
"returnByValue": return_by_value,
|
||||
"returnByValue": by_value,
|
||||
"awaitPromise": await_promise,
|
||||
# userGesture matters for things like clipboard / fullscreen
|
||||
# APIs that require a user-activation context.
|
||||
@@ -511,14 +511,32 @@ class CDPSupervisor:
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
fut = safe_schedule_threadsafe(_do_eval(), loop)
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
def _run_eval(by_value: bool) -> Dict[str, Any]:
|
||||
fut = safe_schedule_threadsafe(_do_eval(by_value), loop)
|
||||
if fut is None:
|
||||
return {"ok": False, "error": "Browser supervisor loop unavailable"}
|
||||
response = fut.result(timeout=timeout + 1)
|
||||
raise RuntimeError("Browser supervisor loop unavailable")
|
||||
return fut.result(timeout=timeout + 1)
|
||||
|
||||
try:
|
||||
response = _run_eval(return_by_value)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
# ``returnByValue=True`` asks Chrome to deep-serialize the result.
|
||||
# For live DOM nodes / NodeLists / Window that serialization can
|
||||
# blow past CDP's recursion guard and fail the whole call with
|
||||
# ``Object reference chain is too long`` (a protocol-level error,
|
||||
# not a JS exception). Retry once with ``returnByValue=False`` so
|
||||
# Chrome returns the object's description string instead — the same
|
||||
# graceful degradation path used for ``document.querySelector(...)``
|
||||
# results — rather than crashing the eval.
|
||||
if return_by_value and "reference chain is too long" in str(exc).lower():
|
||||
try:
|
||||
response = _run_eval(False)
|
||||
except Exception as exc2:
|
||||
return {"ok": False, "error": f"{type(exc2).__name__}: {exc2}"}
|
||||
else:
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
# Runtime.evaluate response shape:
|
||||
# {"id": N, "result": {"result": {"type": "...", "value": ..., ...},
|
||||
|
||||
@@ -2874,6 +2874,22 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
"error": f"JavaScript evaluation is not supported by this browser backend. {err}",
|
||||
}
|
||||
return json.dumps(_copy_fallback_warning(response, result))
|
||||
# A live DOM node / NodeList / Window can't be JSON-serialized by CDP
|
||||
# and fails the eval with "Object reference chain is too long". The
|
||||
# supervisor fast path retries with returnByValue=false, but the CLI
|
||||
# subprocess can't, so turn the cryptic protocol error into actionable
|
||||
# guidance instead of surfacing it raw.
|
||||
if "reference chain is too long" in err.lower():
|
||||
response = {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Expression returned a live DOM node / NodeList / Window, "
|
||||
"which can't be serialized. Extract a primitive value "
|
||||
"(e.g. .innerText, .href, .src, .value) or use "
|
||||
"JSON.stringify() / a snapshot tool instead."
|
||||
),
|
||||
}
|
||||
return json.dumps(_copy_fallback_warning(response, result))
|
||||
response = {
|
||||
"success": False,
|
||||
"error": err,
|
||||
|
||||
+173
-10
@@ -113,6 +113,36 @@ def _normalize_line_endings(text: str, target: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
# UTF-8 byte order mark. Some Windows editors (Notepad, older Visual Studio,
|
||||
# some PowerShell redirects) prepend this invisible 3-byte marker
|
||||
# (EF BB BF == U+FEFF) to UTF-8 text files. It renders as nothing but is a
|
||||
# real character at the start of the decoded string, so without handling it:
|
||||
# - read_file would surface a stray U+FEFF as the first character (the
|
||||
# model sees a phantom char before `import ...`), and
|
||||
# - patch matches against the true first line would miss, and write_file
|
||||
# would silently drop or double the marker on rewrite.
|
||||
# We strip it on read so the model sees clean content, and restore it on
|
||||
# write when the original file had one — exactly mirroring the line-ending
|
||||
# preservation above (detect on disk, preserve across the edit).
|
||||
_UTF8_BOM = "\ufeff"
|
||||
|
||||
|
||||
def _strip_bom(text: str) -> tuple[str, bool]:
|
||||
"""Return (text-without-leading-BOM, had_bom).
|
||||
|
||||
Only a single leading BOM is stripped; a BOM appearing mid-content is
|
||||
left alone (it's legitimate data there, not a file marker).
|
||||
"""
|
||||
if text and text.startswith(_UTF8_BOM):
|
||||
return text[len(_UTF8_BOM):], True
|
||||
return text, False
|
||||
|
||||
|
||||
def _has_bom(text: Optional[str]) -> bool:
|
||||
"""True if ``text`` begins with a UTF-8 BOM."""
|
||||
return bool(text) and text.startswith(_UTF8_BOM)
|
||||
|
||||
|
||||
def _is_write_denied(path: str) -> bool:
|
||||
"""Return True if path is on the write deny list."""
|
||||
return _shared_is_write_denied(path)
|
||||
@@ -672,8 +702,25 @@ class ShellFileOperations(FileOperations):
|
||||
return ext in IMAGE_EXTENSIONS
|
||||
|
||||
def _add_line_numbers(self, content: str, start_line: int = 1) -> str:
|
||||
"""Add line numbers to content in LINE_NUM|CONTENT format."""
|
||||
"""Add line numbers to content in ``LINE_NUM|CONTENT`` format.
|
||||
|
||||
The gutter uses a compact ``<n>|`` prefix (e.g. ``34|foo``) rather
|
||||
than a fixed-width zero/space-padded one (`` 34|foo``). The
|
||||
padding was pure token overhead: on dense source the padded gutter
|
||||
cost ~48% more tokens than the bare content and ~16% more than the
|
||||
compact form, because the leading spaces + zero-padding tokenize
|
||||
into extra tokens on every single line. An A/B (Sonnet 4.6, 2
|
||||
passes) showed the compact gutter matches the padded gutter on
|
||||
line-reference / patch / value-lookup / structure tasks (4/4 both),
|
||||
while dropping line numbers entirely regressed line-referencing
|
||||
(the model hand-counted and was off-by-one, 3/4) — so we keep the
|
||||
numbers, just not the padding. ``HERMES_READ_GUTTER=padded``
|
||||
restores the legacy fixed-width format for anyone who relied on
|
||||
column alignment.
|
||||
"""
|
||||
import os as _os
|
||||
from tools.tool_output_limits import get_max_line_length
|
||||
padded = (_os.environ.get("HERMES_READ_GUTTER") or "").lower() == "padded"
|
||||
max_line_length = get_max_line_length()
|
||||
lines = content.split('\n')
|
||||
numbered = []
|
||||
@@ -681,7 +728,7 @@ class ShellFileOperations(FileOperations):
|
||||
# Truncate long lines
|
||||
if len(line) > max_line_length:
|
||||
line = line[:max_line_length] + "... [truncated]"
|
||||
numbered.append(f"{i:6d}|{line}")
|
||||
numbered.append(f"{i:6d}|{line}" if padded else f"{i}|{line}")
|
||||
return '\n'.join(numbered)
|
||||
|
||||
def _expand_path(self, path: str) -> str:
|
||||
@@ -726,6 +773,60 @@ class ShellFileOperations(FileOperations):
|
||||
# Use single quotes and escape any single quotes in the string
|
||||
return "'" + arg.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
def _atomic_write(self, path: str, content: str) -> "ExecuteResult":
|
||||
"""Write ``content`` to ``path`` atomically via temp-file + rename.
|
||||
|
||||
Streams ``content`` over stdin into a temp file in the SAME
|
||||
directory as ``path`` (so the final ``mv`` is a real rename on the
|
||||
same filesystem, not a non-atomic cross-device copy), preserves the
|
||||
existing file's mode if it exists, then renames over the target.
|
||||
On any failure the temp file is removed so we never leak a partial
|
||||
``.hermes-tmp`` file next to the user's data, and the original file
|
||||
is left untouched. Content rides stdin so there is no ARG_MAX limit.
|
||||
|
||||
Returns an :class:`ExecuteResult`; ``exit_code == 0`` means the file
|
||||
was swapped into place atomically. A non-zero exit means nothing was
|
||||
renamed and the original (if any) is intact.
|
||||
"""
|
||||
q_path = self._escape_shell_arg(path)
|
||||
parent = os.path.dirname(path) or "."
|
||||
q_parent = self._escape_shell_arg(parent)
|
||||
# template basename: hidden so it doesn't show up in casual `ls`,
|
||||
# carries a marker so an orphaned temp (only possible on a hard
|
||||
# crash *between* cat and mv) is identifiable.
|
||||
tmpl = self._escape_shell_arg(".hermes-tmp.XXXXXX")
|
||||
|
||||
# One shell script, fully quoted. Notes:
|
||||
# - `mktemp` lands the temp in the target's own dir (-p) so `mv` is
|
||||
# same-FS atomic; we fall back to a PID-stamped name if the
|
||||
# backend lacks mktemp (rare; busybox/macOS/Linux all ship it).
|
||||
# - `chmod --reference` is GNU-only, so we read the octal mode with
|
||||
# `stat` (GNU `-c%a` or BSD `-f%Lp`) and `chmod` it explicitly;
|
||||
# silent best-effort — a perms-copy failure must not abort the
|
||||
# write, the file still lands with default umask perms.
|
||||
# - `trap ... EXIT` guarantees the temp is removed on every error
|
||||
# path (cat failure, mv failure, signal) but NOT after a
|
||||
# successful mv (the temp no longer exists by then).
|
||||
# - we `cat >` the temp, then `mv -f` it over the target.
|
||||
script = (
|
||||
"set -e; "
|
||||
f"d={q_parent}; t={q_path}; "
|
||||
'tmp="$(mktemp -p "$d" ' + tmpl + ' 2>/dev/null '
|
||||
'|| mktemp "$d/.hermes-tmp.$$.XXXXXX" 2>/dev/null '
|
||||
'|| { tmp="$d/.hermes-tmp.$$"; : > "$tmp" && echo "$tmp"; })"; '
|
||||
'[ -n "$tmp" ] || { echo "atomic write: could not create temp file" >&2; exit 1; }; '
|
||||
"trap 'rm -f \"$tmp\"' EXIT; "
|
||||
# preserve mode of an existing target (best-effort, never fatal)
|
||||
'if [ -e "$t" ]; then '
|
||||
'm="$(stat -c%a "$t" 2>/dev/null || stat -f%Lp "$t" 2>/dev/null || true)"; '
|
||||
'[ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true; '
|
||||
"fi; "
|
||||
'cat > "$tmp"; '
|
||||
'mv -f "$tmp" "$t"; '
|
||||
"trap - EXIT"
|
||||
)
|
||||
return self._exec(script, stdin_data=content)
|
||||
|
||||
def _detect_file_line_ending(self, path: str, pre_content: Optional[str] = None) -> Optional[str]:
|
||||
"""Detect the dominant line ending of a file on disk.
|
||||
|
||||
@@ -747,6 +848,22 @@ class ShellFileOperations(FileOperations):
|
||||
return None
|
||||
return _detect_line_ending(head_result.stdout)
|
||||
|
||||
def _file_has_bom(self, path: str, pre_content: Optional[str] = None) -> bool:
|
||||
"""Whether the file on disk starts with a UTF-8 BOM.
|
||||
|
||||
Uses ``pre_content`` if we already read the file (zero extra exec
|
||||
calls); otherwise issues a tiny ``head -c 3`` to sample just the
|
||||
marker. A missing/empty file returns False (new writes get no BOM
|
||||
unless the caller explicitly includes one).
|
||||
"""
|
||||
if pre_content is not None:
|
||||
return _has_bom(pre_content)
|
||||
head_cmd = f"head -c 3 {self._escape_shell_arg(path)} 2>/dev/null"
|
||||
head_result = self._exec(head_cmd)
|
||||
if head_result.exit_code != 0 or not head_result.stdout:
|
||||
return False
|
||||
return _has_bom(head_result.stdout)
|
||||
|
||||
|
||||
def _unified_diff(self, old_content: str, new_content: str, filename: str) -> str:
|
||||
"""Generate unified diff between old and new content."""
|
||||
@@ -831,6 +948,11 @@ class ShellFileOperations(FileOperations):
|
||||
if read_result.exit_code != 0:
|
||||
return ReadResult(error=f"Failed to read file: {read_result.stdout}")
|
||||
read_output = _strip_terminal_fence_leaks(read_result.stdout)
|
||||
# Strip a leading UTF-8 BOM so the model never sees a phantom U+FEFF
|
||||
# before the first real character. Only meaningful on the first
|
||||
# chunk (the marker lives at byte 0); later pages can't carry it.
|
||||
if offset == 1:
|
||||
read_output, _ = _strip_bom(read_output)
|
||||
|
||||
# Get total line count
|
||||
wc_cmd = f"wc -l < {self._escape_shell_arg(path)}"
|
||||
@@ -935,8 +1057,14 @@ class ShellFileOperations(FileOperations):
|
||||
cat_result = self._exec(f"cat {self._escape_shell_arg(path)}")
|
||||
if cat_result.exit_code != 0:
|
||||
return ReadResult(error=f"Failed to read file: {cat_result.stdout}")
|
||||
# Strip a leading UTF-8 BOM so patch's fuzzy matcher operates on
|
||||
# clean content (a phantom U+FEFF before line 1 would defeat an
|
||||
# exact first-line match). write_file restores the BOM on the way
|
||||
# back out — it re-probes the on-disk file, which still has the
|
||||
# marker — so the round-trip preserves it.
|
||||
raw_content, _ = _strip_bom(_strip_terminal_fence_leaks(cat_result.stdout))
|
||||
return ReadResult(
|
||||
content=_strip_terminal_fence_leaks(cat_result.stdout),
|
||||
content=raw_content,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
@@ -1036,6 +1164,18 @@ class ShellFileOperations(FileOperations):
|
||||
if original_ending == "\r\n":
|
||||
content = _normalize_line_endings(content, "\r\n")
|
||||
|
||||
# ── BOM preservation ──────────────────────────────────────────
|
||||
# If the file on disk started with a UTF-8 BOM, keep it. read_file
|
||||
# strips the BOM so the agent never sees it, which means the
|
||||
# content it hands back to write_file / patch has no BOM either —
|
||||
# without restoring it here a round-trip would silently strip the
|
||||
# marker and change the file's byte signature (some Windows
|
||||
# toolchains key on it). Only prepend when the original had a BOM
|
||||
# and the new content doesn't already carry one (guards against
|
||||
# double-BOM if a caller passed raw bytes).
|
||||
if self._file_has_bom(path, pre_content) and not _has_bom(content):
|
||||
content = _UTF8_BOM + content
|
||||
|
||||
# Snapshot LSP diagnostics for this file (best-effort) so the
|
||||
# post-write LSP layer can return only diagnostics introduced
|
||||
# by this specific edit. Mirrors claude-code's
|
||||
@@ -1053,10 +1193,22 @@ class ShellFileOperations(FileOperations):
|
||||
if mkdir_result.exit_code == 0:
|
||||
dirs_created = True
|
||||
|
||||
# Write via stdin pipe — content bypasses shell arg parsing entirely,
|
||||
# so there's no ARG_MAX limit regardless of file size.
|
||||
write_cmd = f"cat > {self._escape_shell_arg(path)}"
|
||||
write_result = self._exec(write_cmd, stdin_data=content)
|
||||
# Write atomically: stream into a temp file in the SAME directory,
|
||||
# then ``mv`` it over the target. The rename is atomic on POSIX
|
||||
# (and on every backend FS we run on), so a crash / power loss /
|
||||
# truncated pipe mid-write leaves the original file intact instead
|
||||
# of a half-written corrupt file. Same-directory is load-bearing —
|
||||
# ``mv`` across filesystems degrades to copy+unlink, which is NOT
|
||||
# atomic; keeping the temp beside the target guarantees a real
|
||||
# rename. Content still rides stdin so there's no ARG_MAX limit.
|
||||
#
|
||||
# The temp file is created with ``mktemp`` (collision-safe) when the
|
||||
# backend has it, falling back to a PID-stamped name otherwise. We
|
||||
# then chmod the temp to match the existing file's mode (if any) so
|
||||
# the atomic swap doesn't silently widen or narrow permissions, and
|
||||
# clean the temp up on any failure so we never leak a ``.hermes-tmp``
|
||||
# turd next to the user's file.
|
||||
write_result = self._atomic_write(path, content)
|
||||
|
||||
if write_result.exit_code != 0:
|
||||
return WriteResult(error=f"Failed to write file: {write_result.stdout}")
|
||||
@@ -1127,7 +1279,13 @@ class ShellFileOperations(FileOperations):
|
||||
return PatchResult(error=f"Failed to read file: {path}")
|
||||
|
||||
content = read_result.stdout
|
||||
|
||||
# Strip a leading UTF-8 BOM before matching so the fuzzy matcher and
|
||||
# the diff operate on clean content (a phantom U+FEFF before line 1
|
||||
# defeats an exact first-line match). write_file restores the BOM on
|
||||
# the way back out by re-probing the on-disk file, so the round-trip
|
||||
# preserves the marker.
|
||||
content, _ = _strip_bom(content)
|
||||
|
||||
# Import and use fuzzy matching
|
||||
from tools.fuzzy_match import fuzzy_find_and_replace
|
||||
|
||||
@@ -1176,8 +1334,13 @@ class ShellFileOperations(FileOperations):
|
||||
# ``new_content`` string has bare LFs. Without this normalization
|
||||
# every patch on Windows returns a bogus "wrote 39, read 42"
|
||||
# false-negative even though the edit landed correctly. POSIX
|
||||
# backends don't translate, so this is a no-op there.
|
||||
_verify_stdout_normalized = verify_result.stdout.replace("\r\n", "\n").replace("\r", "\n")
|
||||
# backends don't translate, so this is a no-op there. We also
|
||||
# strip a leading BOM from the re-read: write_file restored the
|
||||
# marker on disk but ``new_content`` is the BOM-less string we
|
||||
# matched against, so the comparison must drop it to stay
|
||||
# apples-to-apples.
|
||||
_verify_bomless, _ = _strip_bom(verify_result.stdout)
|
||||
_verify_stdout_normalized = _verify_bomless.replace("\r\n", "\n").replace("\r", "\n")
|
||||
_new_content_normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
if _verify_stdout_normalized != _new_content_normalized:
|
||||
return PatchResult(error=(
|
||||
|
||||
+103
-10
@@ -116,15 +116,80 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_base_dir(task_id: str = "default") -> Path:
|
||||
"""Return the ABSOLUTE base directory for resolving relative paths.
|
||||
|
||||
Resolution order:
|
||||
1. The task's live terminal cwd (the directory the agent is actually
|
||||
working in — e.g. a git worktree). Authoritative when known.
|
||||
2. ``$TERMINAL_CWD`` from config/env.
|
||||
3. The process cwd.
|
||||
|
||||
The returned base is ALWAYS absolute. This is the core invariant that
|
||||
prevents the worktree-cwd divergence bug: a relative ``TERMINAL_CWD``
|
||||
(commonly the literal ``"."`` from a stale config) is meaningless as a
|
||||
resolution anchor — left to ``Path.resolve()`` it silently resolves
|
||||
against whatever the agent PROCESS cwd happens to be (e.g. the main repo
|
||||
while the terminal is in a worktree), routing edits to the wrong checkout.
|
||||
Anchoring a relative base against the process cwd here makes the resolution
|
||||
deterministic and inspectable rather than dependent on resolve()-time cwd.
|
||||
"""
|
||||
live = _get_live_tracking_cwd(task_id)
|
||||
if live:
|
||||
base = Path(live).expanduser()
|
||||
else:
|
||||
raw = os.environ.get("TERMINAL_CWD")
|
||||
base = Path(raw).expanduser() if raw else Path(os.getcwd())
|
||||
if not base.is_absolute():
|
||||
# A relative base (".", "./sub", "..") is anchored to the process cwd
|
||||
# once, here, so the result no longer depends on cwd at resolve() time.
|
||||
base = Path(os.getcwd()) / base
|
||||
return base.resolve()
|
||||
|
||||
|
||||
def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path:
|
||||
"""Resolve *filepath* against the task's live terminal cwd when possible."""
|
||||
"""Resolve *filepath* against the task's absolute base directory.
|
||||
|
||||
See :func:`_resolve_base_dir` for how the base is chosen. Absolute input
|
||||
paths are returned resolved-but-unanchored.
|
||||
"""
|
||||
p = Path(filepath).expanduser()
|
||||
if not p.is_absolute():
|
||||
base = _get_live_tracking_cwd(task_id) or os.environ.get(
|
||||
"TERMINAL_CWD", os.getcwd()
|
||||
)
|
||||
p = Path(base) / p
|
||||
return p.resolve()
|
||||
if p.is_absolute():
|
||||
return p.resolve()
|
||||
return (_resolve_base_dir(task_id) / p).resolve()
|
||||
|
||||
|
||||
def _path_resolution_warning(filepath: str, resolved: Path, task_id: str = "default") -> str | None:
|
||||
"""Warn when a relative path resolved OUTSIDE the task's workspace root.
|
||||
|
||||
Surfaces the worktree-cwd divergence the moment it would matter: if the
|
||||
agent passes a relative path but it resolves under a directory that is not
|
||||
the live terminal cwd (i.e. the edit is about to land in a different
|
||||
checkout than the one the agent is working in), return a message naming the
|
||||
absolute target. ``None`` when the path is absolute, the base is unknown,
|
||||
or the resolved path is correctly under the workspace root.
|
||||
"""
|
||||
try:
|
||||
if Path(filepath).expanduser().is_absolute():
|
||||
return None
|
||||
live = _get_live_tracking_cwd(task_id)
|
||||
if not live:
|
||||
return None # No authoritative workspace root to compare against.
|
||||
root = Path(live).expanduser().resolve()
|
||||
# Is `resolved` inside `root`?
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
return None # Inside the workspace — expected.
|
||||
except ValueError:
|
||||
return (
|
||||
f"Relative path {filepath!r} resolved to {str(resolved)!r}, which is "
|
||||
f"OUTSIDE the active workspace ({str(root)!r}). The edit will land in "
|
||||
f"a different directory than the terminal's cwd. If this is not "
|
||||
f"intended (e.g. a git-worktree session writing into the main "
|
||||
f"checkout), pass an absolute path under the workspace instead."
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_blocked_device_path(path: str) -> bool:
|
||||
@@ -930,12 +995,21 @@ def write_file_tool(path: str, content: str, task_id: str = "default",
|
||||
# fire — its message names the sibling subagent.
|
||||
cross_warning = file_state.check_stale(task_id, _resolved)
|
||||
stale_warning = _check_file_staleness(path, task_id)
|
||||
# Workspace-divergence warning: relative path resolving outside the
|
||||
# terminal's cwd (the worktree-cwd bug). Lowest priority of the three.
|
||||
cwd_warning = _path_resolution_warning(path, Path(_resolved), task_id)
|
||||
file_ops = _get_file_ops(task_id)
|
||||
result = file_ops.write_file(path, content)
|
||||
result = file_ops.write_file(_resolved, content)
|
||||
result_dict = result.to_dict()
|
||||
effective_warning = cross_warning or stale_warning
|
||||
effective_warning = cross_warning or stale_warning or cwd_warning
|
||||
if effective_warning:
|
||||
result_dict["_warning"] = effective_warning
|
||||
# Always report the ABSOLUTE path actually written, so a wrong-cwd
|
||||
# mismatch is visible in the response instead of silently routing
|
||||
# the edit to the wrong checkout.
|
||||
result_dict["resolved_path"] = _resolved
|
||||
if not result_dict.get("error"):
|
||||
result_dict["files_modified"] = [_resolved]
|
||||
# Refresh stamps after the successful write so consecutive
|
||||
# writes by this task don't trigger false staleness warnings.
|
||||
_update_read_timestamp(path, task_id)
|
||||
@@ -1027,6 +1101,10 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
|
||||
_path_to_resolved[_p] = _r
|
||||
_cross = file_state.check_stale(task_id, _r) if _r else None
|
||||
_sw = _cross or _check_file_staleness(_p, task_id)
|
||||
if not _sw and _r:
|
||||
# Workspace-divergence warning (worktree-cwd bug): relative
|
||||
# path resolving outside the terminal's cwd.
|
||||
_sw = _path_resolution_warning(_p, Path(_r), task_id)
|
||||
if _sw:
|
||||
stale_warnings.append(_sw)
|
||||
|
||||
@@ -1037,7 +1115,13 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
|
||||
return tool_error("path required")
|
||||
if old_string is None or new_string is None:
|
||||
return tool_error("old_string and new_string required")
|
||||
result = file_ops.patch_replace(path, old_string, new_string, replace_all)
|
||||
# Pass the resolved ABSOLUTE path to the shell layer so it
|
||||
# operates on the exact file the tool layer resolved — the
|
||||
# shell's own cwd may differ (worktree-cwd bug), and a relative
|
||||
# path would let the two layers disagree about which file is
|
||||
# being edited.
|
||||
_replace_target = _path_to_resolved.get(path) or path
|
||||
result = file_ops.patch_replace(_replace_target, old_string, new_string, replace_all)
|
||||
elif mode == "patch":
|
||||
if not patch:
|
||||
return tool_error("patch content required")
|
||||
@@ -1048,9 +1132,18 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
|
||||
result_dict = result.to_dict()
|
||||
if stale_warnings:
|
||||
result_dict["_warning"] = stale_warnings[0] if len(stale_warnings) == 1 else " | ".join(stale_warnings)
|
||||
# Report the ABSOLUTE path(s) actually patched so a wrong-cwd
|
||||
# mismatch (e.g. a worktree session editing the main checkout) is
|
||||
# visible in the response instead of silently landing elsewhere.
|
||||
_resolved_modified = [
|
||||
_path_to_resolved.get(_p) or _p for _p in _paths_to_check
|
||||
]
|
||||
# Refresh stored timestamps for all successfully-patched paths so
|
||||
# consecutive edits by this task don't trigger false warnings.
|
||||
if not result_dict.get("error"):
|
||||
result_dict["files_modified"] = _resolved_modified
|
||||
if len(_resolved_modified) == 1:
|
||||
result_dict["resolved_path"] = _resolved_modified[0]
|
||||
for _p in _paths_to_check:
|
||||
_update_read_timestamp(_p, task_id)
|
||||
_r = _path_to_resolved.get(_p)
|
||||
|
||||
@@ -173,6 +173,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
"tool.dashboard": (
|
||||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -72,15 +72,34 @@ def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool:
|
||||
return remaining <= max(0, int(skew_seconds))
|
||||
|
||||
|
||||
def read_nous_access_token() -> Optional[str]:
|
||||
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
|
||||
def peek_nous_access_token() -> Optional[str]:
|
||||
"""Cheap probe for a Nous gateway token without triggering refresh.
|
||||
|
||||
Availability scans (`hermes tools`, banner/status paint, provider
|
||||
`is_available()` checks) must stay off the synchronous OAuth refresh path.
|
||||
This helper therefore only inspects the explicit env override and the
|
||||
cached auth-store token, without checking expiry and without making any
|
||||
network calls. Truthful refresh handling stays in request/session paths
|
||||
that call :func:`read_nous_access_token`.
|
||||
"""
|
||||
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
|
||||
if isinstance(explicit, str) and explicit.strip():
|
||||
return explicit.strip()
|
||||
|
||||
nous_provider = _read_nous_provider_state() or {}
|
||||
access_token = nous_provider.get("access_token")
|
||||
cached_token = access_token.strip() if isinstance(access_token, str) and access_token.strip() else None
|
||||
if isinstance(access_token, str) and access_token.strip():
|
||||
return access_token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def read_nous_access_token() -> Optional[str]:
|
||||
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
|
||||
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
|
||||
if isinstance(explicit, str) and explicit.strip():
|
||||
return explicit.strip()
|
||||
nous_provider = _read_nous_provider_state() or {}
|
||||
cached_token = peek_nous_access_token()
|
||||
|
||||
if cached_token and not _access_token_is_expiring(
|
||||
nous_provider.get("expires_at"),
|
||||
@@ -159,9 +178,15 @@ def is_managed_tool_gateway_ready(
|
||||
gateway_builder: Optional[Callable[[str], str]] = None,
|
||||
token_reader: Optional[Callable[[], Optional[str]]] = None,
|
||||
) -> bool:
|
||||
"""Return True when gateway URL and Nous access token are available."""
|
||||
"""Return True when gateway URL and a likely-usable Nous token are present.
|
||||
|
||||
Defaults to :func:`peek_nous_access_token` so read-only availability scans
|
||||
avoid synchronous OAuth refresh. Callers that are about to make a real
|
||||
gateway request should use :func:`resolve_managed_tool_gateway` (which
|
||||
still defaults to the refresh-aware :func:`read_nous_access_token`).
|
||||
"""
|
||||
return resolve_managed_tool_gateway(
|
||||
vendor,
|
||||
gateway_builder=gateway_builder,
|
||||
token_reader=token_reader,
|
||||
token_reader=token_reader or peek_nous_access_token,
|
||||
) is not None
|
||||
|
||||
+84
-16
@@ -1395,9 +1395,22 @@ class MCPServerTask:
|
||||
# Capture the newly spawned subprocess PID for force-kill cleanup.
|
||||
new_pids = _snapshot_child_pids() - pids_before
|
||||
if new_pids:
|
||||
# Capture pgid while the child is alive — once it exits we
|
||||
# can no longer call ``os.getpgid`` on it, and the cleanup
|
||||
# sweep needs the pgid to reach any reparented descendants
|
||||
# (e.g. ``claude mcp serve`` spawned by a stdio wrapper).
|
||||
new_pgids: Dict[int, int] = {}
|
||||
for _pid in new_pids:
|
||||
try:
|
||||
new_pgids[_pid] = os.getpgid(_pid)
|
||||
except (AttributeError, ProcessLookupError, OSError):
|
||||
# AttributeError: Windows (os.getpgid is POSIX-only)
|
||||
# ProcessLookupError: child raced and already exited
|
||||
pass
|
||||
with _lock:
|
||||
for _pid in new_pids:
|
||||
_stdio_pids[_pid] = self.name
|
||||
_stdio_pgids.update(new_pgids)
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, **sampling_kwargs
|
||||
) as session:
|
||||
@@ -1416,16 +1429,33 @@ class MCPServerTask:
|
||||
# on Linux, where setsid() children escape the parent cgroup).
|
||||
# Mark them as orphans so the next cleanup sweep can reap them.
|
||||
if new_pids:
|
||||
from gateway.status import _pid_exists
|
||||
_killpg = getattr(os, "killpg", None)
|
||||
with _lock:
|
||||
for _pid in new_pids:
|
||||
_stdio_pids.pop(_pid, None)
|
||||
for pid in new_pids:
|
||||
# ``os.kill(pid, 0)`` is NOT a no-op on Windows
|
||||
# (bpo-14484). Use the cross-platform check.
|
||||
from gateway.status import _pid_exists
|
||||
if not _pid_exists(pid):
|
||||
continue # process already exited — nothing to do
|
||||
_orphan_stdio_pids.add(pid)
|
||||
pid_alive = _pid_exists(pid)
|
||||
pgroup_alive = False
|
||||
pgid = _stdio_pgids.get(pid)
|
||||
if not pid_alive and pgid is not None and _killpg is not None:
|
||||
# Direct child exited but descendants may still be
|
||||
# in its pgroup (e.g. ``claude mcp serve`` spawned
|
||||
# by an MCP wrapper that exited first). Probe with
|
||||
# signal 0 — succeeds iff any pgroup member is alive.
|
||||
try:
|
||||
_killpg(pgid, 0)
|
||||
pgroup_alive = True
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pgroup_alive = False
|
||||
if pid_alive or pgroup_alive:
|
||||
_orphan_stdio_pids.add(pid)
|
||||
else:
|
||||
# Nothing left to reap — drop the pgid entry so
|
||||
# PID-reuse can't surface stale pgroup state later.
|
||||
_stdio_pgids.pop(pid, None)
|
||||
|
||||
async def _run_http(self, config: dict):
|
||||
"""Run the server using HTTP/StreamableHTTP transport."""
|
||||
@@ -2224,6 +2254,19 @@ _stdio_pids: Dict[int, str] = {} # pid -> server_name
|
||||
# sessions (e.g. concurrent cron jobs or live user chats).
|
||||
_orphan_stdio_pids: set = set()
|
||||
|
||||
# Process-group IDs of stdio MCP subprocesses, captured at spawn time.
|
||||
# The MCP SDK spawns stdio children with ``start_new_session=True`` so each
|
||||
# direct child becomes its own session/pgroup leader (PGID == its own PID).
|
||||
# Grandchildren spawned by that child (e.g. a wrapper MCP server that itself
|
||||
# launches helper subprocesses like ``claude mcp serve``) inherit that PGID
|
||||
# unless they call ``setsid`` themselves. When the direct child exits, those
|
||||
# grandchildren reparent to init/systemd-user but keep the original PGID, so
|
||||
# ``killpg(pgid, sig)`` still reaches them. Tracked separately from
|
||||
# ``_stdio_pids`` so we retain the PGID even after the direct child has
|
||||
# exited and been removed from the active map. Empty on Windows
|
||||
# (``os.getpgid`` is POSIX-only).
|
||||
_stdio_pgids: Dict[int, int] = {} # pid -> pgid
|
||||
|
||||
|
||||
def _snapshot_child_pids() -> set:
|
||||
"""Return a set of current child process PIDs.
|
||||
@@ -3640,6 +3683,12 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
|
||||
survivors, avoiding shared-resource collisions when multiple hermes
|
||||
processes run on the same host (each has its own ``_stdio_pids`` dict).
|
||||
|
||||
On POSIX, signals are sent via ``os.killpg`` to the spawn-time pgid when
|
||||
one is tracked, so reparented grandchildren in the same process group
|
||||
(e.g. ``claude mcp serve`` spawned by a stdio MCP wrapper that exited
|
||||
first) are reaped alongside the direct child. Falls back to ``os.kill``
|
||||
on Windows and when no pgid is recorded.
|
||||
|
||||
With ``include_active=True`` also kills every PID in ``_stdio_pids`` —
|
||||
used only at final shutdown, after the MCP event loop has stopped and no
|
||||
sessions can still be in flight.
|
||||
@@ -3654,20 +3703,42 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
|
||||
if include_active:
|
||||
pids.update(dict(_stdio_pids))
|
||||
_stdio_pids.clear()
|
||||
# Snapshot pgids for the pids we're about to kill, then drop the
|
||||
# entries so a future spawn can't collide with stale state.
|
||||
pgids: Dict[int, int] = {pid: _stdio_pgids[pid] for pid in pids if pid in _stdio_pgids}
|
||||
for pid in pgids:
|
||||
_stdio_pgids.pop(pid, None)
|
||||
|
||||
# Fast path: no tracked stdio PIDs to reap. Skip the SIGTERM/sleep/SIGKILL
|
||||
# dance entirely — otherwise every MCP-free shutdown pays a 2s sleep tax.
|
||||
if not pids:
|
||||
return
|
||||
|
||||
# Phase 1: SIGTERM (graceful)
|
||||
for pid, server_name in pids.items():
|
||||
def _send_signal(pid: int, sig: int, server_name: str) -> None:
|
||||
"""SIGTERM/SIGKILL via pgroup on POSIX, fall back to pid signal."""
|
||||
pgid = pgids.get(pid)
|
||||
killpg = getattr(os, "killpg", None)
|
||||
if pgid is not None and killpg is not None:
|
||||
try:
|
||||
killpg(pgid, sig)
|
||||
return
|
||||
except (ProcessLookupError, PermissionError, OSError) as exc:
|
||||
# Pgroup gone (all members exited) or refused — fall back to
|
||||
# the per-pid path so we still try the direct child if alive.
|
||||
logger.debug(
|
||||
"killpg(%d, %d) failed for MCP server '%s': %s; falling back to kill(pid)",
|
||||
pgid, sig, server_name, exc,
|
||||
)
|
||||
try:
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
logger.debug("Sent SIGTERM to orphaned MCP process %d (%s)", pid, server_name)
|
||||
os.kill(pid, sig)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
# Phase 1: SIGTERM (graceful)
|
||||
for pid, server_name in pids.items():
|
||||
_send_signal(pid, _signal.SIGTERM, server_name)
|
||||
logger.debug("Sent SIGTERM to orphaned MCP process %d (%s)", pid, server_name)
|
||||
|
||||
# Phase 2: Wait for graceful exit
|
||||
time.sleep(2)
|
||||
|
||||
@@ -3679,14 +3750,11 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
|
||||
for pid, server_name in pids.items():
|
||||
if not _pid_exists(pid):
|
||||
continue # Good — exited after SIGTERM
|
||||
try:
|
||||
os.kill(pid, _sigkill)
|
||||
logger.warning(
|
||||
"Force-killed MCP process %d (%s) after SIGTERM timeout",
|
||||
pid, server_name,
|
||||
)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
_send_signal(pid, _sigkill, server_name)
|
||||
logger.warning(
|
||||
"Force-killed MCP process %d (%s) after SIGTERM timeout",
|
||||
pid, server_name,
|
||||
)
|
||||
|
||||
|
||||
def _stop_mcp_loop():
|
||||
|
||||
@@ -40,6 +40,15 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
|
||||
# downstream adapters (signal, etc.) expect.
|
||||
_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"})
|
||||
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
|
||||
# Email addresses — a valid email like "user@domain.com" should be treated as
|
||||
# an explicit target for the email platform, not fall through to channel-name
|
||||
# resolution which has no way to resolve a raw address.
|
||||
_EMAIL_TARGET_RE = re.compile(r"^\s*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\s*$")
|
||||
# Most platforms read their home channel from "<PLATFORM>_HOME_CHANNEL", but a
|
||||
# few diverge. Email reads EMAIL_HOME_ADDRESS (see gateway/config.py), so the
|
||||
# generic "<PLATFORM>_HOME_CHANNEL" hint would point users at a variable that is
|
||||
# never read. Map the exceptions so the error guidance is actually actionable.
|
||||
_HOME_CHANNEL_ENV_OVERRIDES = {"email": "EMAIL_HOME_ADDRESS"}
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||
@@ -265,10 +274,13 @@ def _handle_send(args):
|
||||
chat_id = home.chat_id
|
||||
used_home_channel = True
|
||||
else:
|
||||
home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(
|
||||
platform_name, f"{platform_name.upper()}_HOME_CHANNEL"
|
||||
)
|
||||
return json.dumps({
|
||||
"error": f"No home channel set for {platform_name} to determine where to send the message. "
|
||||
f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', "
|
||||
f"or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL <channel_id>"
|
||||
f"or set a home channel via: hermes config set {home_env} <channel_id>"
|
||||
})
|
||||
|
||||
duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)
|
||||
@@ -383,6 +395,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
|
||||
if target_ref.strip().isdigit():
|
||||
return f"group:{target_ref.strip()}", None, True
|
||||
return None, None, False
|
||||
if platform_name == "email":
|
||||
match = _EMAIL_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return target_ref.strip(), None, True
|
||||
if platform_name in _PHONE_PLATFORMS:
|
||||
match = _E164_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
|
||||
+41
-13
@@ -517,7 +517,10 @@ def sync_skills(quiet: bool = False) -> dict:
|
||||
if not quiet:
|
||||
print(f" ↑ {skill_name} (updated)")
|
||||
# Remove backup after successful copy
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
try:
|
||||
_rmtree_writable(backup)
|
||||
except (OSError, IOError):
|
||||
logger.debug("Could not remove backup %s", backup, exc_info=True)
|
||||
except (OSError, IOError):
|
||||
# Restore from backup
|
||||
if backup.exists() and not dest.exists():
|
||||
@@ -563,6 +566,30 @@ def sync_skills(quiet: bool = False) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _rmtree_writable(path: Path) -> None:
|
||||
"""Remove a directory tree, making read-only entries writable first.
|
||||
|
||||
Handles immutable package sources (Nix store, deb/rpm installs) that
|
||||
preserve read-only permissions on copied files *and* directories
|
||||
(``r-xr-xr-x``). Removing a child requires write permission on its
|
||||
parent directory, so the retry handler makes the failing path **and its
|
||||
parent** writable before re-attempting. See #34860, #34972.
|
||||
"""
|
||||
import stat
|
||||
|
||||
def _on_error(func, fpath, exc_info):
|
||||
# Unlinking a child requires the parent dir to be writable, so chmod
|
||||
# the parent as well as the failing path, then retry.
|
||||
for target in (os.path.dirname(fpath), fpath):
|
||||
try:
|
||||
os.chmod(target, stat.S_IRWXU)
|
||||
except OSError:
|
||||
pass
|
||||
func(fpath)
|
||||
|
||||
shutil.rmtree(path, onerror=_on_error)
|
||||
|
||||
|
||||
def reset_bundled_skill(name: str, restore: bool = False) -> dict:
|
||||
"""
|
||||
Reset a bundled skill's manifest tracking so future syncs work normally.
|
||||
@@ -606,12 +633,9 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
|
||||
"synced": None,
|
||||
}
|
||||
|
||||
# Step 1: drop the manifest entry so next sync treats it as new
|
||||
if in_manifest:
|
||||
del manifest[name]
|
||||
_write_manifest(manifest)
|
||||
|
||||
# Step 2 (optional): delete the user's copy so next sync re-copies bundled
|
||||
# Step 1 (optional): delete the user's copy so next sync re-copies bundled.
|
||||
# Must happen BEFORE manifest deletion so that a failed rmtree does not
|
||||
# leave the skill in a manifest-less limbo state (see #34972).
|
||||
deleted_user_copy = False
|
||||
if restore:
|
||||
if not is_bundled:
|
||||
@@ -619,28 +643,32 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
|
||||
"ok": False,
|
||||
"action": "bundled_missing",
|
||||
"message": (
|
||||
f"'{name}' has no bundled source — manifest entry cleared "
|
||||
f"'{name}' has no bundled source — manifest entry preserved "
|
||||
f"but cannot restore from bundled (skill was removed upstream)."
|
||||
),
|
||||
"synced": None,
|
||||
}
|
||||
# The destination mirrors the bundled path relative to bundled_dir.
|
||||
dest = _compute_relative_dest(bundled_by_name[name], bundled_dir)
|
||||
if dest.exists():
|
||||
try:
|
||||
shutil.rmtree(dest)
|
||||
_rmtree_writable(dest)
|
||||
deleted_user_copy = True
|
||||
except (OSError, IOError) as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "manifest_cleared",
|
||||
"action": "not_reset",
|
||||
"message": (
|
||||
f"Cleared manifest entry for '{name}' but could not "
|
||||
f"delete user copy at {dest}: {e}"
|
||||
f"Could not delete user copy at {dest}: {e}. "
|
||||
f"Manifest entry preserved — nothing was changed."
|
||||
),
|
||||
"synced": None,
|
||||
}
|
||||
|
||||
# Step 2: drop the manifest entry so next sync treats it as new
|
||||
if in_manifest:
|
||||
del manifest[name]
|
||||
_write_manifest(manifest)
|
||||
|
||||
# Step 3: run sync to re-baseline (or re-copy if we deleted)
|
||||
synced = sync_skills(quiet=True)
|
||||
|
||||
|
||||
+46
-14
@@ -127,6 +127,30 @@ def _detect_image_mime_type(image_path: Path) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _is_retryable_download_error(error: Exception) -> bool:
|
||||
"""Return True only for transient image-download failures worth retrying.
|
||||
|
||||
Non-retryable (fail-fast):
|
||||
- httpx.HTTPStatusError with a 4xx status other than 429 (404/403/410/...):
|
||||
the resource is missing or forbidden; retrying can't change that.
|
||||
- PermissionError: blocked by website policy / SSRF guard.
|
||||
- ValueError: image too large or blocked redirect — deterministic.
|
||||
|
||||
Retryable (transient):
|
||||
- httpx 429 (rate limited) and 5xx (server-side) errors.
|
||||
- Connection/timeout/transport errors (httpx.TransportError) and any
|
||||
other unclassified exception, which may be a flaky network blip.
|
||||
"""
|
||||
if isinstance(error, (PermissionError, ValueError)):
|
||||
return False
|
||||
if isinstance(error, httpx.HTTPStatusError):
|
||||
status = error.response.status_code
|
||||
if 400 <= status < 500 and status != 429:
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
async def _download_image(image_url: str, destination: Path, max_retries: int = 3) -> Path:
|
||||
"""
|
||||
Download an image from a URL to a local destination (async) with retry logic.
|
||||
@@ -210,24 +234,32 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
|
||||
return destination
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2 ** (attempt + 1) # 2s, 4s, 8s
|
||||
logger.warning("Image download failed (attempt %s/%s): %s", attempt + 1, max_retries, str(e)[:50])
|
||||
logger.warning("Retrying in %ss...", wait_time)
|
||||
await asyncio.sleep(wait_time)
|
||||
else:
|
||||
# Error-class-aware retry: only retry transient failures. A 4xx
|
||||
# client error (404/403/410, etc.) will never succeed on retry —
|
||||
# the resource isn't there or we're not allowed — so burning 3
|
||||
# attempts with 2s/4s/8s backoff just inflates latency. 429 (rate
|
||||
# limit) and 5xx remain retryable. PermissionError (policy block)
|
||||
# and ValueError (too-large / SSRF redirect) are also terminal.
|
||||
if not _is_retryable_download_error(e) or attempt >= max_retries - 1:
|
||||
logger.error(
|
||||
"Image download failed after %s attempts: %s",
|
||||
max_retries,
|
||||
"Image download failed after %s attempt(s): %s",
|
||||
attempt + 1,
|
||||
str(e)[:100],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if last_error is None:
|
||||
raise RuntimeError(
|
||||
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
|
||||
)
|
||||
raise last_error
|
||||
raise
|
||||
wait_time = 2 ** (attempt + 1) # 2s, 4s, 8s
|
||||
logger.warning("Image download failed (attempt %s/%s): %s", attempt + 1, max_retries, str(e)[:50])
|
||||
logger.warning("Retrying in %ss...", wait_time)
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# The loop always returns on success or re-raises on the final/non-retryable
|
||||
# attempt, so reaching here means max_retries was non-positive.
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise RuntimeError(
|
||||
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
|
||||
)
|
||||
|
||||
|
||||
def _determine_mime_type(image_path: Path) -> str:
|
||||
|
||||
@@ -93,6 +93,7 @@ from tools.debug_helpers import DebugSession
|
||||
# tools.web_tools (the firecrawl plugin reads them via its own import chain).
|
||||
from tools.managed_tool_gateway import ( # noqa: F401 — backward-compat names for tests
|
||||
build_vendor_gateway_url,
|
||||
peek_nous_access_token as _peek_nous_access_token,
|
||||
read_nous_access_token as _read_nous_access_token,
|
||||
resolve_managed_tool_gateway,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user