fix: batch of 5 small contributor fixes (#2466)

fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill
This commit is contained in:
Teknium 2026-03-22 04:40:20 -07:00 committed by GitHub
commit edda0e324b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 48 additions and 23 deletions

View file

@ -230,7 +230,7 @@ class EmailAdapter(BasePlatformAdapter):
# Mark all existing messages as seen so we only process new ones # Mark all existing messages as seen so we only process new ones
imap.select("INBOX") imap.select("INBOX")
status, data = imap.uid("search", None, "ALL") 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(): for uid in data[0].split():
self._seen_uids.add(uid) self._seen_uids.add(uid)
imap.logout() imap.logout()
@ -295,7 +295,7 @@ class EmailAdapter(BasePlatformAdapter):
imap.select("INBOX") imap.select("INBOX")
status, data = imap.uid("search", None, "UNSEEN") 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() imap.logout()
return results return results

View file

@ -855,23 +855,25 @@ class SessionDB:
def session_count(self, source: str = None) -> int: def session_count(self, source: str = None) -> int:
"""Count sessions, optionally filtered by source.""" """Count sessions, optionally filtered by source."""
if source: with self._lock:
cursor = self._conn.execute( if source:
"SELECT COUNT(*) FROM sessions WHERE source = ?", (source,) cursor = self._conn.execute(
) "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
else: )
cursor = self._conn.execute("SELECT COUNT(*) FROM sessions") else:
return cursor.fetchone()[0] cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
return cursor.fetchone()[0]
def message_count(self, session_id: str = None) -> int: def message_count(self, session_id: str = None) -> int:
"""Count messages, optionally for a specific session.""" """Count messages, optionally for a specific session."""
if session_id: with self._lock:
cursor = self._conn.execute( if session_id:
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,) cursor = self._conn.execute(
) "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
else: )
cursor = self._conn.execute("SELECT COUNT(*) FROM messages") else:
return cursor.fetchone()[0] cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
return cursor.fetchone()[0]
# ========================================================================= # =========================================================================
# Export and cleanup # Export and cleanup

View file

@ -108,7 +108,7 @@ HONCHO_TOOL_NAMES = {
class _SafeWriter: 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 When hermes-agent runs as a systemd service, Docker container, or headless
daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer 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 run_conversation() especially via double-fault when an except handler
also tries to print. 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 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",) __slots__ = ("_inner",)
@ -129,13 +134,13 @@ class _SafeWriter:
def write(self, data): def write(self, data):
try: try:
return self._inner.write(data) return self._inner.write(data)
except OSError: except (OSError, ValueError):
return len(data) if isinstance(data, str) else 0 return len(data) if isinstance(data, str) else 0
def flush(self): def flush(self):
try: try:
self._inner.flush() self._inner.flush()
except OSError: except (OSError, ValueError):
pass pass
def fileno(self): def fileno(self):
@ -144,7 +149,7 @@ class _SafeWriter:
def isatty(self): def isatty(self):
try: try:
return self._inner.isatty() return self._inner.isatty()
except OSError: except (OSError, ValueError):
return False return False
def __getattr__(self, name): def __getattr__(self, name):
@ -2438,7 +2443,18 @@ class AIAgent:
"Pre-call sanitizer: added %d stub tool result(s)", "Pre-call sanitizer: added %d stub tool result(s)",
len(missing_results), 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 return messages
@staticmethod @staticmethod

View file

@ -81,8 +81,15 @@ def detect_audio_environment() -> dict:
warnings.append("No audio input/output devices detected") warnings.append("No audio input/output devices detected")
except Exception: except Exception:
warnings.append("Audio subsystem error (PortAudio cannot query devices)") warnings.append("Audio subsystem error (PortAudio cannot query devices)")
except (ImportError, OSError): except ImportError:
warnings.append("Audio libraries not installed (pip install sounddevice numpy)") 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 { return {
"available": len(warnings) == 0, "available": len(warnings) == 0,