diff --git a/tools/__init__.py b/tools/__init__.py index 8fac3d7c81..db946d0e2b 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -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() diff --git a/tools/annotate_preview_tool.py b/tools/annotate_preview_tool.py index a066b343a4..847fbacc82 100644 --- a/tools/annotate_preview_tool.py +++ b/tools/annotate_preview_tool.py @@ -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): diff --git a/tools/ansi_strip.py b/tools/ansi_strip.py index 577cb79ef4..6a81fb2288 100644 --- a/tools/ansi_strip.py +++ b/tools/ansi_strip.py @@ -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) diff --git a/tools/apply_layout_tool.py b/tools/apply_layout_tool.py index bda3960306..167bca418c 100644 --- a/tools/apply_layout_tool.py +++ b/tools/apply_layout_tool.py @@ -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}, ) diff --git a/tools/audio_container.py b/tools/audio_container.py index 9a22ecf048..910b7b4196 100644 --- a/tools/audio_container.py +++ b/tools/audio_container.py @@ -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] diff --git a/tools/binary_extensions.py b/tools/binary_extensions.py index 307cb4b54f..a9282b0569 100644 --- a/tools/binary_extensions.py +++ b/tools/binary_extensions.py @@ -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") diff --git a/tools/budget_config.py b/tools/budget_config.py index 7cebc01506..37697f53ca 100644 --- a/tools/budget_config.py +++ b/tools/budget_config.py @@ -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)), diff --git a/tools/close_preview_tool.py b/tools/close_preview_tool.py index 5b3cdc5410..61687c76fd 100644 --- a/tools/close_preview_tool.py +++ b/tools/close_preview_tool.py @@ -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}, ) diff --git a/tools/close_terminal_tool.py b/tools/close_terminal_tool.py index 56bb2a102d..b10b26cebf 100644 --- a/tools/close_terminal_tool.py +++ b/tools/close_terminal_tool.py @@ -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) diff --git a/tools/computer_use_tool.py b/tools/computer_use_tool.py index 3e14f7aedc..8ddd93390c 100644 --- a/tools/computer_use_tool.py +++ b/tools/computer_use_tool.py @@ -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"] diff --git a/tools/daemon_pool.py b/tools/daemon_pool.py index 7f45de1131..6b79615f8d 100644 --- a/tools/daemon_pool.py +++ b/tools/daemon_pool.py @@ -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)