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:
Teknium
2026-09-02 22:36:16 -07:00
parent ffbd5ef633
commit 5442a2a371
11 changed files with 128 additions and 329 deletions

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)

View File

@@ -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},
)

View File

@@ -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]

View File

@@ -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")

View File

@@ -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)),

View File

@@ -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},
)

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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)