From e80489135ba8989a2c7409005fdebdae5d770c67 Mon Sep 17 00:00:00 2001 From: Ivelin Tenev Date: Sun, 22 Mar 2026 12:37:18 +0200 Subject: [PATCH 1/5] fix: improve error message when PortAudio system library is missing When sounddevice is installed but libportaudio2 is not present on the system, the OSError was caught together with ImportError and showed a generic 'pip install sounddevice' message that sent users down the wrong path. Split the except clause to give a clear, actionable message for the OSError case, including the correct apt/brew commands to install the system library. --- tools/voice_mode.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 78358489..39e6e753 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -81,8 +81,15 @@ def detect_audio_environment() -> dict: warnings.append("No audio input/output devices detected") except Exception: warnings.append("Audio subsystem error (PortAudio cannot query devices)") - except (ImportError, OSError): + except ImportError: warnings.append("Audio libraries not installed (pip install sounddevice numpy)") + except OSError: + warnings.append( + "PortAudio system library not found -- install it first:\n" + " Linux: sudo apt-get install libportaudio2\n" + " macOS: brew install portaudio\n" + "Then retry /voice on." + ) return { "available": len(warnings) == 0, From e6a708aa04805a118acc7b260dc042f9a416755a Mon Sep 17 00:00:00 2001 From: Bartok Moltbot Date: Sun, 22 Mar 2026 03:31:42 -0400 Subject: [PATCH 2/5] fix(io): catch ValueError in _SafeWriter for closed file handles (#2428) When subagents run in ThreadPoolExecutor threads, the shared stdout handle can close between thread teardown and KawaiiSpinner cleanup. Python raises ValueError (not OSError) for I/O operations on closed files: ValueError: I/O operation on closed file The _SafeWriter class was only catching OSError, missing this case. Changes: - Add ValueError to exception handling in write(), flush(), and isatty() - Update docstring to document the ThreadPoolExecutor teardown scenario Fixes #2428 --- run_agent.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/run_agent.py b/run_agent.py index 7931581f..fcef3057 100644 --- a/run_agent.py +++ b/run_agent.py @@ -108,7 +108,7 @@ HONCHO_TOOL_NAMES = { class _SafeWriter: - """Transparent stdio wrapper that catches OSError from broken pipes. + """Transparent stdio wrapper that catches OSError/ValueError from broken pipes. When hermes-agent runs as a systemd service, Docker container, or headless daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer @@ -117,8 +117,13 @@ class _SafeWriter: run_conversation() — especially via double-fault when an except handler also tries to print. + Additionally, when subagents run in ThreadPoolExecutor threads, the shared + stdout handle can close between thread teardown and cleanup, raising + ``ValueError: I/O operation on closed file`` instead of OSError. + This wrapper delegates all writes to the underlying stream and silently - catches OSError. It is transparent when the wrapped stream is healthy. + catches both OSError and ValueError. It is transparent when the wrapped + stream is healthy. """ __slots__ = ("_inner",) @@ -129,13 +134,13 @@ class _SafeWriter: def write(self, data): try: return self._inner.write(data) - except OSError: + except (OSError, ValueError): return len(data) if isinstance(data, str) else 0 def flush(self): try: self._inner.flush() - except OSError: + except (OSError, ValueError): pass def fileno(self): @@ -144,7 +149,7 @@ class _SafeWriter: def isatty(self): try: return self._inner.isatty() - except OSError: + except (OSError, ValueError): return False def __getattr__(self, name): From f3301a31d52253e76bc643c7197490f4857e3c40 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 21 Mar 2026 10:16:06 +0000 Subject: [PATCH 3/5] fix(email): guard against IndexError when IMAP search returns empty list imap.uid('search') can return data=[] when the mailbox is empty or has no matching messages. Accessing data[0] without checking len first raises IndexError: list index out of range. Fixed at both call sites in gateway/platforms/email.py: - Line 233 (connect): ALL search on startup - Line 298 (fetch): UNSEEN search in the polling loop Closes #2137 --- gateway/platforms/email.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 04841278..ec44c60e 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -230,7 +230,7 @@ class EmailAdapter(BasePlatformAdapter): # Mark all existing messages as seen so we only process new ones imap.select("INBOX") status, data = imap.uid("search", None, "ALL") - if status == "OK" and data[0]: + if status == "OK" and data and data[0]: for uid in data[0].split(): self._seen_uids.add(uid) imap.logout() @@ -295,7 +295,7 @@ class EmailAdapter(BasePlatformAdapter): imap.select("INBOX") status, data = imap.uid("search", None, "UNSEEN") - if status != "OK" or not data[0]: + if status != "OK" or not data or not data[0]: imap.logout() return results From 2de42ba6901240eaefc256798db700866a7739ae Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 21 Mar 2026 10:15:06 +0000 Subject: [PATCH 4/5] fix(state): add missing thread lock to session_count() and message_count() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both methods accessed self._conn without self._lock, breaking the thread-safety contract documented on SessionDB (line 111). All 22 other DB methods use with self._lock — these two were the only exceptions. In the gateway's multi-threaded environment (multiple platform reader threads + single writer) this could cause cursor interleaving, sqlite3.ProgrammingError, or inconsistent COUNT results. Closes #2130 --- hermes_state.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 34b553dc..c8a59060 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -855,23 +855,25 @@ class SessionDB: def session_count(self, source: str = None) -> int: """Count sessions, optionally filtered by source.""" - if source: - cursor = self._conn.execute( - "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,) - ) - else: - cursor = self._conn.execute("SELECT COUNT(*) FROM sessions") - return cursor.fetchone()[0] + with self._lock: + if source: + cursor = self._conn.execute( + "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,) + ) + else: + cursor = self._conn.execute("SELECT COUNT(*) FROM sessions") + return cursor.fetchone()[0] def message_count(self, session_id: str = None) -> int: """Count messages, optionally for a specific session.""" - if session_id: - cursor = self._conn.execute( - "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,) - ) - else: - cursor = self._conn.execute("SELECT COUNT(*) FROM messages") - return cursor.fetchone()[0] + with self._lock: + if session_id: + cursor = self._conn.execute( + "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,) + ) + else: + cursor = self._conn.execute("SELECT COUNT(*) FROM messages") + return cursor.fetchone()[0] # ========================================================================= # Export and cleanup From 5407d12bc61f18e2b5cd1988315448ad3ea71086 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Fri, 20 Mar 2026 08:02:01 +0100 Subject: [PATCH 5/5] fix(agent): strip trailing empty assistant messages before API calls to prevent prefill rejection --- run_agent.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index fcef3057..67a18758 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2443,7 +2443,18 @@ class AIAgent: "Pre-call sanitizer: added %d stub tool result(s)", len(missing_results), ) - + # 3. Strip trailing empty assistant messages to prevent prefill rejection. + # These can leak from Responses API reasoning-only turns (Codex/MiniMax) + # where an empty assistant message is required by the Responses API but + # must NOT be sent to Chat Completions or Anthropic Messages API providers. + while ( + messages + and messages[-1].get("role") == "assistant" + and not (messages[-1].get("content") or "").strip() + and not messages[-1].get("tool_calls") + ): + logger.debug("Pre-call sanitizer: removed trailing empty assistant message") + messages = messages[:-1] return messages @staticmethod