refactor(tools): trim small tool modules (docstrings, dead constant, prefix table)
- audio_container: CONTAINER_TO_EXT built from one tuple; fixed-prefix sniffs table-driven. - apply_layout_tool: dead _TIMEOUT_NOTE indirection inlined. - Module/function docstrings compacted by hand; regexes, byte patterns, schemas and registry.register kwargs byte-identical (SCHEMA_SAME verified).
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tools package namespace. Kept side-effect free: importing ``tools`` must not
|
||||
load the tool stack, since some subsystems import tools while
|
||||
``hermes_cli.config`` is still initializing. Import concrete submodules directly."""
|
||||
load the tool stack (some subsystems import it while ``hermes_cli.config`` is
|
||||
still initializing). Import concrete submodules directly."""
|
||||
|
||||
|
||||
def check_file_requirements():
|
||||
"""File tools only require terminal backend availability."""
|
||||
from .terminal_tool import check_terminal_requirements
|
||||
|
||||
return check_terminal_requirements()
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent element annotations in the Hermes desktop GUI's in-app browser.
|
||||
|
||||
``drive_preview`` draws transient marks (one per action, self-retiring). An
|
||||
annotation outlines an element — or, with ``hold``, the whole visible field —
|
||||
and stays until the agent removes it. Annotations bind to elements, not
|
||||
coordinates: they ride scrolls/reflows and vanish with their element, so a
|
||||
navigation clears them. Rides the same ``preview.act`` bridge as
|
||||
``drive_preview`` (the renderer resolves ``@e`` refs and owns the overlay).
|
||||
Lives in the ``desktop_ui`` toolset, enabled only for desktop-sourced sessions.
|
||||
Unlike ``drive_preview``'s self-retiring marks, an annotation outlines an element
|
||||
(or, with ``hold``, the whole visible field) until removed. Annotations bind to
|
||||
elements, not coordinates: they ride scrolls and vanish with their element, so
|
||||
navigation clears them. Same ``preview.act`` bridge as ``drive_preview`` (the
|
||||
renderer resolves refs and owns the overlay). ``desktop_ui`` toolset only.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -17,54 +14,32 @@ from tools.registry import registry, tool_error
|
||||
|
||||
ACTIONS = ("add", "hold", "remove", "clear")
|
||||
|
||||
# Verbs the renderer knows, keyed by ours. `clear` is `unpin` with nothing to
|
||||
# aim at, which the overlay reads as "all of them".
|
||||
# Renderer verbs keyed by ours; `clear` is `unpin` with no target = "all of them".
|
||||
WIRE = {"add": "pin", "hold": "hold", "remove": "unpin", "clear": "unpin"}
|
||||
|
||||
|
||||
def annotate_preview_tool(
|
||||
action: str = "add",
|
||||
ref: Optional[str] = None,
|
||||
selector: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
callback: Optional[Callable] = None,
|
||||
action: str = "add", ref: Optional[str] = None, selector: Optional[str] = None,
|
||||
label: Optional[str] = None, callback: Optional[Callable] = None,
|
||||
) -> str:
|
||||
"""Put one annotation up, take one down, or clear them all."""
|
||||
if callback is None:
|
||||
return tool_error("annotate_preview is only available in the Hermes desktop app.")
|
||||
|
||||
verb = (action or "add").strip().lower()
|
||||
if verb not in ACTIONS:
|
||||
return tool_error(f"action must be one of: {', '.join(ACTIONS)}.")
|
||||
|
||||
if verb in ("add", "remove") and not (ref or selector):
|
||||
return tool_error(
|
||||
f"{verb} needs a ref from drive_preview action='elements' "
|
||||
"(e.g. 'btn-sign-in') or a CSS selector."
|
||||
)
|
||||
|
||||
payload = {
|
||||
name: val
|
||||
for name, val in (
|
||||
("action", WIRE[verb]),
|
||||
("ref", None if verb in ("clear", "hold") else ref),
|
||||
("selector", None if verb in ("clear", "hold") else selector),
|
||||
("text", label),
|
||||
)
|
||||
if val is not None
|
||||
}
|
||||
return tool_error(f"{verb} needs a ref from drive_preview action='elements' (e.g. 'btn-sign-in') or a CSS selector.")
|
||||
|
||||
targeted = verb not in ("clear", "hold")
|
||||
fields = (("action", WIRE[verb]), ("ref", ref if targeted else None), ("selector", selector if targeted else None), ("text", label))
|
||||
payload = {name: val for name, val in fields if val is not None}
|
||||
try:
|
||||
raw = callback(payload)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Failed to annotate the in-app browser: {exc}")
|
||||
|
||||
if not raw:
|
||||
return tool_error(
|
||||
"The annotation timed out, or no GUI window answered. "
|
||||
"Open a page with open_preview first."
|
||||
)
|
||||
|
||||
return tool_error("The annotation timed out, or no GUI window answered. Open a page with open_preview first.")
|
||||
try:
|
||||
return json.dumps(json.loads(raw), ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"""Strip ANSI escape sequences from subprocess output.
|
||||
|
||||
Used by terminal_tool, code_execution_tool, and process_registry so ANSI codes
|
||||
never enter the model's context (the root cause of models copying escape
|
||||
sequences into file writes).
|
||||
|
||||
Covers the full ECMA-48 spec: CSI (including private-mode ``?`` prefix,
|
||||
colon-separated params, intermediate bytes), OSC (BEL and ST terminators),
|
||||
DCS/SOS/PM/APC string sequences, nF multi-byte escapes, Fp/Fe/Fs
|
||||
single-byte escapes, and 8-bit C1 control characters.
|
||||
"""
|
||||
"""Strip ANSI escape sequences from subprocess output so they never reach the
|
||||
model's context (models otherwise copy them into file writes). Covers full
|
||||
ECMA-48: CSI, OSC (BEL/ST), DCS/SOS/PM/APC, nF, Fp/Fe/Fs and 8-bit C1 controls."""
|
||||
|
||||
import re
|
||||
|
||||
@@ -26,33 +18,23 @@ _ANSI_ESCAPE_RE = re.compile(
|
||||
r"|[\x80-\x9f]", # Other 8-bit C1 controls
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Fast-path check — skip full regex when no escape-like bytes are present.
|
||||
# Fast-path checks: skip the full regex when no candidate bytes are present.
|
||||
_HAS_ESCAPE = re.compile(r"[\x1b\x80-\x9f]")
|
||||
_HAS_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
||||
_HAS_UNICODE_TAG = re.compile(r"[\U000E0000-\U000E007F]")
|
||||
|
||||
# C0 controls (minus tab/newline/CR, handled separately) plus DEL. They survive
|
||||
# strip_ansi() — it only removes well-formed *sequences* — but are dangerous when
|
||||
# echoed to a terminal (BEL rings, backspace/DEL overwrite, NUL truncates).
|
||||
# C0 controls (minus tab/newline/CR) plus DEL: they survive strip_ansi() (which only
|
||||
# removes *sequences*) but are dangerous echoed to a terminal (BEL, backspace, NUL).
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
|
||||
# Fast-path check for sanitize_display_text — any C0 control (except
|
||||
# tab/newline), CR, DEL, ESC, or C1 byte triggers the slow path.
|
||||
_HAS_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
||||
|
||||
# Unicode TAG characters (U+E0000–U+E007F) render as nothing in terminals and
|
||||
# chat UIs but are visible to LLM tokenizers — the "ASCII smuggling" injection
|
||||
# channel. The only legitimate modern use is emoji tag sequences (TR51: U+1F3F4
|
||||
# base + tag spec + U+E007F CANCEL TAG, e.g. the Scotland/Wales flags); those
|
||||
# are preserved, same rationale as keeping ZWJ inside emoji sequences.
|
||||
# Ported from block/goose#10746 (which strips flags too).
|
||||
# Unicode TAG chars (U+E0000–U+E007F) render as nothing but LLM tokenizers see them:
|
||||
# the "ASCII smuggling" injection channel. Emoji tag sequences (TR51: U+1F3F4 base +
|
||||
# tag spec + U+E007F CANCEL TAG, e.g. Scotland/Wales flags) are the only legit use.
|
||||
_UNICODE_TAG_SUB_RE = re.compile(
|
||||
r"(\U0001F3F4[\U000E0020-\U000E007E]+\U000E007F)" # valid emoji tag seq (kept)
|
||||
r"|[\U000E0000-\U000E007F]" # any other tag char (stripped)
|
||||
)
|
||||
|
||||
# Fast-path check — plane-14 tag chars only.
|
||||
_HAS_UNICODE_TAG = re.compile(r"[\U000E0000-\U000E007F]")
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape sequences; clean text passes through unchanged (fast path)."""
|
||||
@@ -62,15 +44,11 @@ def strip_ansi(text: str) -> str:
|
||||
|
||||
|
||||
def sanitize_display_text(text: str) -> str:
|
||||
"""Sanitize stored/untrusted text before echoing it to a terminal.
|
||||
|
||||
Removes ANSI/ECMA-48 sequences AND bare control characters, keeping only
|
||||
newlines and tabs (CRs become newlines so ``\\r``-overwrite spoofing can't
|
||||
hide content). Use when re-rendering persisted text (e.g. the ``/resume``
|
||||
recap): Rich's ``Text()`` does NOT neutralize raw escape bytes, so a replayed
|
||||
message must not be able to clear the screen, retitle the window, or restyle UI.
|
||||
Mirrors openai/codex#31494 (``sanitize_user_text``).
|
||||
"""
|
||||
"""Sanitize stored/untrusted text before echoing it to a terminal: strips ANSI
|
||||
sequences AND bare control chars, keeping only newlines/tabs (CRs become newlines
|
||||
so ``\\r``-overwrite spoofing can't hide content). Rich's ``Text()`` does NOT
|
||||
neutralize raw escape bytes, so a replayed ``/resume`` message must not be able
|
||||
to clear the screen, retitle the window, or restyle UI."""
|
||||
if not text or not _HAS_CONTROL.search(text):
|
||||
return text
|
||||
text = strip_ansi(text)
|
||||
@@ -80,12 +58,8 @@ def sanitize_display_text(text: str) -> str:
|
||||
|
||||
|
||||
def strip_unicode_tags(text: str) -> str:
|
||||
"""Remove invisible Unicode TAG characters (U+E0000–U+E007F) from text.
|
||||
|
||||
A prompt-injection smuggling channel for untrusted tool output (MCP servers,
|
||||
web content). Valid emoji tag sequences (regional flags) are preserved;
|
||||
tag-free input is returned unchanged (fast path).
|
||||
"""
|
||||
"""Remove invisible Unicode TAG chars (a prompt-injection smuggling channel in
|
||||
untrusted tool output); valid emoji tag sequences are preserved."""
|
||||
if not text or not _HAS_UNICODE_TAG.search(text):
|
||||
return text
|
||||
return _UNICODE_TAG_SUB_RE.sub(lambda m: m.group(1) or "", text)
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply a layout preset in the Hermes desktop GUI (``layout.apply`` via ``desktop_ui``).
|
||||
|
||||
The renderer resolves the id against its layouts registry (core, plugin, and user
|
||||
presets are one list) and applies it through the layout picker's own code path; only
|
||||
the active window's session acts, so a background turn never rearranges the desktop.
|
||||
Preset ids are free-form on purpose: plugins and users mint their own. The renderer
|
||||
answers with the applied id/title, or the list of available ids when unknown, so the
|
||||
model can self-correct without a registry-listing tool.
|
||||
The renderer resolves the id against its layouts registry (core, plugin and user
|
||||
presets are one list); only the active window's session acts, so a background turn
|
||||
never rearranges the desktop. Preset ids are free-form on purpose. The renderer
|
||||
answers with the applied id/title, or the available ids when unknown, so the model
|
||||
can self-correct without a registry-listing tool.
|
||||
"""
|
||||
|
||||
from tools import desktop_ui
|
||||
from tools.registry import registry, tool_error
|
||||
|
||||
# Renderer answers via the blocking-prompt bridge; layout apply is synchronous
|
||||
# there, so the bridge timeout is generous.
|
||||
_TIMEOUT_NOTE = "Layout apply is only available in the Hermes desktop app."
|
||||
|
||||
|
||||
def apply_layout_tool(preset: str) -> str:
|
||||
"""Ask the desktop GUI to apply layout preset ``preset``."""
|
||||
@@ -23,11 +17,8 @@ def apply_layout_tool(preset: str) -> str:
|
||||
if not name:
|
||||
return tool_error("preset is required — a layout preset id, e.g. 'default' or 'focus'.")
|
||||
return desktop_ui.emit_or_error(
|
||||
"layout.apply",
|
||||
{"preset": name},
|
||||
f"Failed to apply layout '{name}': ",
|
||||
_TIMEOUT_NOTE,
|
||||
{"success": True, "preset": name},
|
||||
"layout.apply", {"preset": name}, f"Failed to apply layout '{name}': ",
|
||||
"Layout apply is only available in the Hermes desktop app.", {"success": True, "preset": name},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
"""Shared magic-byte audio/AV container detection.
|
||||
"""Shared magic-byte audio/AV container detection — the ONE sniffer for the codebase.
|
||||
|
||||
ONE sniffer owns container detection for the whole codebase:
|
||||
|
||||
- **Outbound** (``tools/tts_tool.py``): TTS backends silently ignore the
|
||||
requested opus format (Edge emits MP3, Piper writes WAV, ...), so the
|
||||
synthesized file is sniffed and repaired when the bytes don't match the
|
||||
``.ogg`` extension (PR #73072).
|
||||
- **Inbound** (``gateway/platforms/base.py`` ``cache_audio_from_bytes`` /
|
||||
``cache_audio_from_url``): platform adapters frequently pass a wrong or
|
||||
guessed extension for voice notes (Telegram ``.oga``, iOS Signal M4A-branded
|
||||
MP4, RIFF/WAVE attachments). The cache sniffs the real container so STT and
|
||||
downstream players get an honest extension — the inbound mirror of the
|
||||
outbound repair.
|
||||
- ``gateway/platforms/signal.py`` ``_guess_extension`` delegates its audio/AV
|
||||
branches here instead of duplicating the byte patterns.
|
||||
|
||||
Detection notes:
|
||||
|
||||
- RIFF needs the form-type at bytes 8-11 to split ``WAVE`` (wav) from ``WEBP``
|
||||
(image — deliberately NOT handled here; this module only claims audio/AV
|
||||
containers, callers check images first).
|
||||
- ``ftyp`` needs the brand at bytes 8-11 to split audio brands (``M4A ``,
|
||||
``M4B ``) from video brands (isom/mp42/avc1/qt).
|
||||
- The ``0xFF 0xFx`` sync word is shared by MP3 and ADTS AAC; bits 3-1 of
|
||||
byte 1 disambiguate (ADTS: ``ID=0``, ``layer=00``).
|
||||
Outbound (``tools/tts_tool.py``): TTS backends silently ignore the requested
|
||||
format (Edge emits MP3, Piper WAV), so the file is sniffed and its ``.ogg``
|
||||
extension repaired. Inbound (``gateway/platforms/base.py`` cache_audio_*,
|
||||
``gateway/platforms/signal.py``): adapters pass wrong/guessed voice-note
|
||||
extensions (Telegram ``.oga``, iOS M4A-branded MP4), so the cache sniffs the
|
||||
real container for STT and players. Only audio/AV containers are claimed:
|
||||
RIFF/WEBP and other images return ``None`` so callers check images first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,50 +14,32 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
# Container id -> canonical file extension.
|
||||
CONTAINER_TO_EXT = {
|
||||
"m4a": ".m4a",
|
||||
"mp4": ".mp4",
|
||||
"ogg": ".ogg",
|
||||
"flac": ".flac",
|
||||
"wav": ".wav",
|
||||
"mp3": ".mp3",
|
||||
"aac": ".aac",
|
||||
"webm": ".webm",
|
||||
}
|
||||
CONTAINER_TO_EXT = {c: f".{c}" for c in ("m4a", "mp4", "ogg", "flac", "wav", "mp3", "aac", "webm")}
|
||||
|
||||
# MP4 ftyp brands that mean "this is audio" (iOS voice notes use M4A ).
|
||||
_MP4_AUDIO_BRANDS = (b"m4a ", b"m4b ")
|
||||
|
||||
# Unambiguous fixed prefixes (checked after ftyp/RIFF, which need bytes 8-11).
|
||||
_PREFIX_CONTAINERS = ((b"OggS", "ogg"), (b"fLaC", "flac"), (b"ID3", "mp3"))
|
||||
|
||||
|
||||
def sniff_container(data: bytes) -> Optional[str]:
|
||||
"""Return a container id from magic bytes, or ``None`` when unknown.
|
||||
|
||||
Possible ids: ``m4a``, ``mp4``, ``ogg``, ``flac``, ``wav``, ``mp3``,
|
||||
``aac``, ``webm``. Only audio/AV containers are claimed — images
|
||||
(including RIFF/WEBP) return ``None`` so callers can layer their own
|
||||
image detection first.
|
||||
"""
|
||||
"""Return a CONTAINER_TO_EXT key from magic bytes, or ``None`` when unknown."""
|
||||
if len(data) >= 8 and data[4:8] == b"ftyp":
|
||||
# Brand at bytes 8-11: audio brands ("M4A ", "M4B ") are voice
|
||||
# notes / audiobooks; everything else (isom/mp42/avc1/qt) is video.
|
||||
# Brand at bytes 8-11: "M4A "/"M4B " are voice notes/audiobooks;
|
||||
# everything else (isom/mp42/avc1/qt) is video.
|
||||
if len(data) >= 12 and data[8:12].lower() in _MP4_AUDIO_BRANDS:
|
||||
return "m4a"
|
||||
return "mp4"
|
||||
if data.startswith(b"OggS"):
|
||||
return "ogg"
|
||||
if data.startswith(b"fLaC"):
|
||||
return "flac"
|
||||
for prefix, container in _PREFIX_CONTAINERS:
|
||||
if data.startswith(prefix):
|
||||
return container
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WAVE":
|
||||
return "wav"
|
||||
if data.startswith(b"ID3"):
|
||||
return "mp3"
|
||||
if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
|
||||
# ``0xFF 0xFx`` is shared by MP3 and ADTS AAC. Bits 3-1 of byte 1
|
||||
# disambiguate: ADTS has ``ID=0`` and ``layer=00`` (mask 0xF6,
|
||||
# target 0xF0); MP3 has ``ID=1`` and ``layer`` in {01,10,11}.
|
||||
if (data[1] & 0xF6) == 0xF0:
|
||||
return "aac"
|
||||
return "mp3"
|
||||
# ``0xFF 0xFx`` sync word is shared by MP3 and ADTS AAC; bits 3-1 of
|
||||
# byte 1 disambiguate: ADTS has ID=0, layer=00 (mask 0xF6 -> 0xF0).
|
||||
return "aac" if (data[1] & 0xF6) == 0xF0 else "mp3"
|
||||
if data.startswith(b"\x1a\x45\xdf\xa3"):
|
||||
return "webm"
|
||||
return None
|
||||
@@ -82,16 +47,10 @@ def sniff_container(data: bytes) -> Optional[str]:
|
||||
|
||||
def sniff_audio_ext(data: bytes, fallback_ext: str = ".ogg") -> str:
|
||||
"""Return a container-matching extension, or ``fallback_ext`` when unknown.
|
||||
|
||||
Used on inbound audio paths where the caller *claims* the bytes are audio:
|
||||
generic MP4 containers are mapped to ``.m4a`` (audio-in-MP4) because in an
|
||||
audio context the payload is AAC audio regardless of brand — STT accepts
|
||||
``.m4a``/``.mp4`` but voice-bubble routing keys off audio extensions.
|
||||
"""
|
||||
Callers *claim* audio, so generic MP4 maps to ``.m4a`` (payload is AAC regardless
|
||||
of brand; STT accepts both but voice-bubble routing keys off audio extensions)."""
|
||||
fallback = fallback_ext if fallback_ext.startswith(".") else f".{fallback_ext}"
|
||||
container = sniff_container(data)
|
||||
if container is None:
|
||||
return fallback
|
||||
if container == "mp4":
|
||||
return ".m4a"
|
||||
return CONTAINER_TO_EXT[container]
|
||||
return ".m4a" if container == "mp4" else CONTAINER_TO_EXT[container]
|
||||
|
||||
@@ -1,69 +1,48 @@
|
||||
"""Binary file extensions to skip for text-based operations.
|
||||
|
||||
These files can't be meaningfully compared as text and are often large.
|
||||
Ported from free-code src/constants/files.ts.
|
||||
"""
|
||||
"""Binary file extensions to skip for text-based operations (ported from
|
||||
free-code src/constants/files.ts)."""
|
||||
|
||||
# Images, video, audio, archives, executables, documents (.pdf deliberately
|
||||
# excluded — text-based, agents may want to inspect), fonts, bytecode/VM,
|
||||
# databases, design/3D, Flash, lock/profiling data.
|
||||
BINARY_EXTENSIONS = frozenset({
|
||||
# Images
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff", ".tif",
|
||||
# Videos
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv", ".m4v", ".mpeg", ".mpg",
|
||||
# Audio
|
||||
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".aiff", ".opus",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz", ".z", ".tgz", ".iso",
|
||||
# Executables/binaries
|
||||
".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".obj", ".lib",
|
||||
".app", ".msi", ".deb", ".rpm",
|
||||
# Documents (exclude .pdf — text-based, agents may want to inspect)
|
||||
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".odt", ".ods", ".odp",
|
||||
# Fonts
|
||||
".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".obj", ".lib", ".app", ".msi", ".deb", ".rpm",
|
||||
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
|
||||
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
||||
# Bytecode / VM artifacts
|
||||
".pyc", ".pyo", ".class", ".jar", ".war", ".ear", ".node", ".wasm", ".rlib",
|
||||
# Database files
|
||||
".sqlite", ".sqlite3", ".db", ".mdb", ".idx",
|
||||
# Design / 3D
|
||||
".psd", ".ai", ".eps", ".sketch", ".fig", ".xd", ".blend", ".3ds", ".max",
|
||||
# Flash
|
||||
".swf", ".fla",
|
||||
# Lock/profiling data
|
||||
".lockb", ".dat", ".data",
|
||||
".swf", ".fla", ".lockb", ".dat", ".data",
|
||||
})
|
||||
|
||||
# Container document formats (OOXML/ODF/EPUB zips, OLE compound, RTF) that a
|
||||
# plain-text write can NEVER produce validly. read_file auto-extracts these to
|
||||
# text, so a model that "read" report.docx and writes the text back via
|
||||
# write_file/patch silently destroys the document. PDF is deliberately absent:
|
||||
# raw PDF syntax is text-authorable, so only overwrites are dangerous (handled
|
||||
# by the write guard via is_pdf_path).
|
||||
# Container documents (OOXML/ODF/EPUB zips, OLE, RTF) a plain-text write can
|
||||
# NEVER produce validly: read_file auto-extracts them, so writing the text back
|
||||
# via write_file/patch silently destroys the document. PDF is deliberately
|
||||
# absent — raw PDF syntax is text-authorable, so only overwrites are dangerous
|
||||
# (the write guard handles that via is_pdf_path).
|
||||
OPAQUE_DOCUMENT_EXTENSIONS = frozenset({
|
||||
".doc", ".docx", ".docm",
|
||||
".xls", ".xlsx", ".xlsm", ".xlsb",
|
||||
".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm", ".xlsb",
|
||||
".ppt", ".pps", ".pot", ".pptx", ".pptm", ".ppsx", ".ppsm",
|
||||
".odt", ".ods", ".odp",
|
||||
".rtf", ".epub",
|
||||
".odt", ".ods", ".odp", ".rtf", ".epub",
|
||||
})
|
||||
|
||||
|
||||
def _has_extension_in(path: str, extensions: frozenset) -> bool:
|
||||
"""Pure string check on the final ``.suffix`` (case-insensitive), no I/O."""
|
||||
"""Case-insensitive check on the final ``.suffix``; pure string, no I/O."""
|
||||
dot = path.rfind(".")
|
||||
return dot != -1 and path[dot:].lower() in extensions
|
||||
|
||||
|
||||
def has_binary_extension(path: str) -> bool:
|
||||
"""True when the path has a binary extension. Pure string check, no I/O."""
|
||||
return _has_extension_in(path, BINARY_EXTENSIONS)
|
||||
|
||||
|
||||
def has_opaque_document_extension(path: str) -> bool:
|
||||
"""True when the path names an opaque container document (.docx etc.)."""
|
||||
return _has_extension_in(path, OPAQUE_DOCUMENT_EXTENSIONS)
|
||||
|
||||
|
||||
def is_pdf_path(path: str) -> bool:
|
||||
"""True when the path has a .pdf extension. Pure string check, no I/O."""
|
||||
return path.lower().endswith(".pdf")
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
"""Configurable budget constants for tool result persistence.
|
||||
|
||||
Per-tool resolution: pinned > config overrides > registry > default.
|
||||
"""
|
||||
Per-tool resolution: pinned > config overrides > registry > default."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict
|
||||
|
||||
# Tools whose thresholds must never be overridden.
|
||||
# read_file=inf prevents infinite persist->read->persist loops.
|
||||
PINNED_THRESHOLDS: Dict[str, float] = {
|
||||
"read_file": float("inf"),
|
||||
}
|
||||
# Never overridden; read_file=inf prevents infinite persist->read->persist loops.
|
||||
PINNED_THRESHOLDS: Dict[str, float] = {"read_file": float("inf")}
|
||||
|
||||
# Single source of truth for the defaults; tool_result_storage.py imports these.
|
||||
DEFAULT_RESULT_SIZE_CHARS: int = 100_000
|
||||
DEFAULT_TURN_BUDGET_CHARS: int = 200_000
|
||||
DEFAULT_PREVIEW_SIZE_CHARS: int = 1_500
|
||||
|
||||
# Tighter per-result default for MCP tools (``mcp_`` prefix): MCP servers
|
||||
# routinely return un-paginated 20-50K-char payloads that sail under the
|
||||
# generic 100K threshold and bloat context. 50K matches the strictest general
|
||||
# competitor caps while spillover (unlike truncation) keeps the full payload on
|
||||
# disk. Overridable via ``tool_budget.mcp_result_size_chars`` in config.yaml.
|
||||
# Tighter per-result default for ``mcp_`` tools: MCP servers routinely return
|
||||
# un-paginated 20-50K payloads that sail under the generic 100K threshold; spillover
|
||||
# keeps the full payload on disk. Config: ``tool_budget.mcp_result_size_chars``.
|
||||
DEFAULT_MCP_RESULT_SIZE_CHARS: int = 50_000
|
||||
|
||||
# Same prefix the untrusted-content wrapper keys on (agent/tool_dispatch_helpers.py).
|
||||
MCP_TOOL_PREFIX: str = "mcp_"
|
||||
|
||||
|
||||
def _configured_mcp_result_size() -> int:
|
||||
"""Read ``tool_budget.mcp_result_size_chars`` via ``load_config_readonly`` (the
|
||||
sanctioned read path; raw config.yaml parsing outside owner modules is test-guarded).
|
||||
sanctioned path; raw config.yaml parsing outside owner modules is test-guarded).
|
||||
Any error, missing key or non-positive value returns the built-in default."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
|
||||
data = load_config_readonly()
|
||||
block = data.get("tool_budget") if isinstance(data, dict) else None
|
||||
raw = block.get("mcp_result_size_chars") if isinstance(block, dict) else None
|
||||
@@ -47,12 +38,8 @@ def _configured_mcp_result_size() -> int:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BudgetConfig:
|
||||
"""Immutable budget constants for the 3-layer tool result persistence system.
|
||||
|
||||
Layer 2 (per-result): resolve_threshold(tool_name) -> threshold in chars.
|
||||
Layer 3 (per-turn): turn_budget -> aggregate char budget across one assistant turn.
|
||||
Preview: preview_size -> inline snippet size after persistence.
|
||||
"""
|
||||
"""Immutable budget constants: per-result threshold (``resolve_threshold``),
|
||||
per-turn aggregate (``turn_budget``) and inline snippet size (``preview_size``)."""
|
||||
|
||||
default_result_size: int = DEFAULT_RESULT_SIZE_CHARS
|
||||
turn_budget: int = DEFAULT_TURN_BUDGET_CHARS
|
||||
@@ -62,15 +49,9 @@ class BudgetConfig:
|
||||
|
||||
def resolve_threshold(self, tool_name: str) -> int | float:
|
||||
"""Priority: pinned -> tool_overrides -> mcp_ prefix -> registry per-tool -> default.
|
||||
|
||||
MCP tools get ``mcp_result_size`` because they have no per-tool registry
|
||||
entry to constrain them. Both the MCP value and the registry value are
|
||||
capped at ``default_result_size`` so a context-scaled budget (small
|
||||
model) still constrains tools that register a large fixed
|
||||
``max_result_size_chars`` (web/terminal/x_search all register 100K);
|
||||
a no-op for the default budget, but for a scaled-down budget it stops a
|
||||
registry value from re-inflating the cap past the model's window.
|
||||
"""
|
||||
MCP tools get ``mcp_result_size`` (no registry entry). MCP and registry values
|
||||
are capped at ``default_result_size`` so a context-scaled budget for a small
|
||||
model still constrains tools registering a fixed 100K ``max_result_size_chars``."""
|
||||
if tool_name in PINNED_THRESHOLDS:
|
||||
return PINNED_THRESHOLDS[tool_name]
|
||||
if tool_name in self.tool_overrides:
|
||||
@@ -87,34 +68,28 @@ class BudgetConfig:
|
||||
# Default config -- matches the historical hardcoded behavior exactly.
|
||||
DEFAULT_BUDGET = BudgetConfig()
|
||||
|
||||
|
||||
# Token<->char ratio for scaling to a context window; same rough 4-chars-per-token
|
||||
# the estimator uses (agent/model_metadata.py). A smaller divisor would
|
||||
# UNDER-protect small models.
|
||||
# Same rough 4-chars-per-token the estimator uses (agent/model_metadata.py);
|
||||
# a smaller divisor would UNDER-protect small models.
|
||||
_CHARS_PER_TOKEN: int = 4
|
||||
|
||||
# Window fraction a SINGLE tool result / the WHOLE turn's tool output may occupy.
|
||||
# System prompt, tool schemas, history and the reply all compete, so well under 1.0.
|
||||
# Window fraction ONE result / the WHOLE turn's tool output may occupy — well
|
||||
# under 1.0 since system prompt, schemas, history and the reply all compete.
|
||||
_PER_RESULT_WINDOW_FRACTION: float = 0.15
|
||||
_PER_TURN_WINDOW_FRACTION: float = 0.30
|
||||
|
||||
# Floors so a tiny model still gets a usable preview/result, never a 0-char budget.
|
||||
# Floors so a tiny model still gets a usable result, never a 0-char budget.
|
||||
_MIN_RESULT_SIZE_CHARS: int = 8_000
|
||||
_MIN_TURN_BUDGET_CHARS: int = 16_000
|
||||
|
||||
|
||||
def budget_for_context_window(context_length: int | None) -> BudgetConfig:
|
||||
"""Return a BudgetConfig scaled to the active model's context window. The fixed
|
||||
defaults suit 200K+ token models but on a 65K model one result/turn can fill
|
||||
the window; the proportional value is clamped to the defaults as a CAP (large
|
||||
models stay byte-identical) and floored so a usable preview always survives."""
|
||||
"""Return a BudgetConfig scaled to the model's context window: the fixed
|
||||
defaults suit 200K+ models but on 65K one result/turn can fill the window.
|
||||
The proportional value is clamped to the defaults as a CAP (large models
|
||||
stay byte-identical) and floored so a usable preview always survives."""
|
||||
mcp_result_size = _configured_mcp_result_size()
|
||||
|
||||
if not context_length or context_length <= 0:
|
||||
if mcp_result_size == DEFAULT_MCP_RESULT_SIZE_CHARS:
|
||||
return DEFAULT_BUDGET
|
||||
return BudgetConfig(mcp_result_size=mcp_result_size)
|
||||
|
||||
window_chars = context_length * _CHARS_PER_TOKEN
|
||||
return BudgetConfig(
|
||||
default_result_size=max(_MIN_RESULT_SIZE_CHARS, min(int(window_chars * _PER_RESULT_WINDOW_FRACTION), DEFAULT_RESULT_SIZE_CHARS)),
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Close the Hermes desktop GUI's preview pane, or one of its tabs.
|
||||
|
||||
Registration moved into `desktop_preview`; kept for its ``preview.close`` action. The
|
||||
renderer drops the matching tab — or the whole pane when no url is given — for the
|
||||
window that asked, never a background session's view.
|
||||
"""
|
||||
"""Close the Hermes desktop GUI's preview pane, or one tab (``preview.close``).
|
||||
Registration lives in `desktop_preview`. The renderer drops the matching tab — or
|
||||
the whole pane when no url is given — only for the window that asked."""
|
||||
|
||||
from tools import desktop_ui
|
||||
from tools.open_preview_tool import _normalize_target
|
||||
@@ -14,9 +10,6 @@ def close_preview_tool(url: str = "") -> str:
|
||||
"""Ask the desktop GUI to close the preview pane, or the tab for ``url``."""
|
||||
target = _normalize_target(url or "")
|
||||
return desktop_ui.emit_or_error(
|
||||
"preview.close",
|
||||
{"url": target},
|
||||
"Failed to close the preview pane: ",
|
||||
"The preview pane is only available in the Hermes desktop app.",
|
||||
{"success": True, "url": target},
|
||||
"preview.close", {"url": target}, "Failed to close the preview pane: ",
|
||||
"The preview pane is only available in the Hermes desktop app.", {"success": True, "url": target},
|
||||
)
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Close a read-only agent terminal tab in the Hermes desktop GUI.
|
||||
|
||||
Each ``terminal(background=true)`` process is mirrored as a read-only tab; this
|
||||
drops the tab WITHOUT killing the process (output keeps buffering, the user can
|
||||
reopen it from the status stack). Routes through the process registry's
|
||||
``on_close`` sink, which the desktop gateway wires to a ``terminal.close`` event.
|
||||
Lives in the ``desktop_ui`` toolset, enabled only for desktop-sourced sessions.
|
||||
"""
|
||||
"""Close a read-only agent terminal tab in the Hermes desktop GUI WITHOUT killing
|
||||
the mirrored ``terminal(background=true)`` process (output keeps buffering; the user
|
||||
can reopen it). Routes through the process registry's ``on_close`` sink, which the
|
||||
desktop gateway wires to a ``terminal.close`` event. ``desktop_ui`` toolset only."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -19,7 +14,6 @@ def close_terminal_tool(process_id: str) -> str:
|
||||
pid = (process_id or "").strip()
|
||||
if not pid:
|
||||
return tool_error("process_id is required (the background process whose tab to close).")
|
||||
|
||||
return json.dumps(process_registry.request_close_terminal(pid), ensure_ascii=False)
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
"""Shim for tool discovery. Registers `computer_use` with tools.registry.
|
||||
|
||||
The real implementation lives in the `tools/computer_use/` package to keep
|
||||
the file structure clean. This shim exists because tools.registry auto-imports
|
||||
`tools/*.py` — we need a top-level module to trigger the registration.
|
||||
"""
|
||||
"""Discovery shim: tools.registry auto-imports ``tools/*.py``, so this top-level
|
||||
module registers ``computer_use`` for the ``tools/computer_use/`` package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tools.computer_use.schema import COMPUTER_USE_SCHEMA
|
||||
from tools.computer_use.tool import (
|
||||
check_computer_use_requirements,
|
||||
handle_computer_use,
|
||||
release_computer_use_session,
|
||||
set_approval_callback,
|
||||
check_computer_use_requirements, handle_computer_use, release_computer_use_session, set_approval_callback,
|
||||
)
|
||||
from tools.registry import registry
|
||||
|
||||
@@ -33,9 +26,4 @@ registry.register(
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"handle_computer_use",
|
||||
"release_computer_use_session",
|
||||
"set_approval_callback",
|
||||
"check_computer_use_requirements",
|
||||
]
|
||||
__all__ = ["handle_computer_use", "release_computer_use_session", "set_approval_callback", "check_computer_use_requirements"]
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
"""Shared daemon-thread ThreadPoolExecutor.
|
||||
|
||||
Stdlib ``ThreadPoolExecutor`` workers are non-daemon AND registered in
|
||||
``concurrent.futures.thread._threads_queues``, whose atexit hook
|
||||
(``_python_exit``) joins every worker unconditionally — even after
|
||||
``shutdown(wait=False)``. A single wedged worker (tool blocked on network
|
||||
I/O, hung provider daemon, stuck subagent) therefore blocks interpreter exit
|
||||
forever; this is the root cause of multi-minute CLI exits on long sessions.
|
||||
|
||||
``DaemonThreadPoolExecutor`` spawns daemon workers and skips the
|
||||
``_threads_queues`` registration, so ``_python_exit`` never joins them and
|
||||
the interpreter's non-daemon thread join at shutdown skips them.
|
||||
|
||||
Semantics are otherwise identical (initializer/initargs, work queue,
|
||||
idle-thread reuse), plus context propagation: ``submit`` snapshots the
|
||||
submitting context with ``copy_context()`` and runs each work item inside it.
|
||||
Stdlib only does this from Python 3.14; on 3.11-3.13 a bare pool worker
|
||||
starts with an EMPTY Context and silently drops contextvar state (profile
|
||||
secret scope, HERMES_HOME override) — under the multiplexed gateway a
|
||||
credential read in such a worker fails closed with ``UnscopedSecretError``.
|
||||
|
||||
Use it for any pool whose work is best-effort or independently interruptible
|
||||
and must never hold the process open (concurrent tool execution, background
|
||||
memory sync, catalog fan-out, subagent timeout wrappers). Do NOT use it for
|
||||
work that must complete before exit (durable writes) — those belong on
|
||||
foreground threads with explicit bounded joins.
|
||||
Stdlib workers are non-daemon AND registered in ``_threads_queues``, whose atexit
|
||||
hook joins every worker even after ``shutdown(wait=False)`` — one wedged worker
|
||||
(tool blocked on network I/O, hung provider, stuck subagent) blocks interpreter
|
||||
exit forever. This variant spawns daemon workers and skips that registration.
|
||||
Use it for best-effort/interruptible work that must never hold the process open;
|
||||
NOT for work that must complete before exit (durable writes belong on foreground
|
||||
threads with explicit bounded joins).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,17 +24,15 @@ class DaemonThreadPoolExecutor(ThreadPoolExecutor):
|
||||
"""ThreadPoolExecutor variant whose workers do not block process exit."""
|
||||
|
||||
def submit(self, fn, /, *args, **kwargs):
|
||||
"""Submit a callable, propagating the caller's contextvars.
|
||||
|
||||
Done unconditionally so the pool behaves identically on every
|
||||
runtime; on 3.14+ (which already propagates) the inner ``ctx.run``
|
||||
re-applies the same immutable context and is a no-op.
|
||||
"""
|
||||
"""Submit a callable, propagating the caller's contextvars. Stdlib only does
|
||||
this from 3.14; on 3.11-3.13 a bare worker starts with an EMPTY Context and
|
||||
drops profile secret scope / HERMES_HOME override — under the multiplexed
|
||||
gateway a credential read then fails closed with ``UnscopedSecretError``.
|
||||
Unconditional: on 3.14+ ``ctx.run`` re-applies the same context (no-op)."""
|
||||
ctx = copy_context()
|
||||
|
||||
def _run_with_context(*call_args, **call_kwargs):
|
||||
return ctx.run(fn, *call_args, **call_kwargs)
|
||||
|
||||
return super().submit(_run_with_context, *args, **kwargs)
|
||||
|
||||
def _adjust_thread_count(self) -> None:
|
||||
@@ -62,20 +43,12 @@ class DaemonThreadPoolExecutor(ThreadPoolExecutor):
|
||||
|
||||
def weakref_cb(_, q=self._work_queue):
|
||||
q.put(None)
|
||||
|
||||
num_threads = len(self._threads)
|
||||
if num_threads < self._max_workers:
|
||||
thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads)
|
||||
t = threading.Thread(
|
||||
name=thread_name,
|
||||
target=_worker,
|
||||
args=(
|
||||
weakref.ref(self, weakref_cb),
|
||||
self._work_queue,
|
||||
self._initializer,
|
||||
self._initargs,
|
||||
),
|
||||
daemon=True,
|
||||
name=thread_name, target=_worker, daemon=True,
|
||||
args=(weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs),
|
||||
)
|
||||
t.start()
|
||||
self._threads.add(t)
|
||||
|
||||
Reference in New Issue
Block a user