From 936040d8f7b8a9364ba0ecebb9ececf398d2f1ba Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 02:19:46 -0700 Subject: [PATCH] fix: guard init-time stdio writes --- run_agent.py | 28 ++++++++++++++++++---------- tests/test_run_agent.py | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/run_agent.py b/run_agent.py index b9bacf7d6..a76d26672 100644 --- a/run_agent.py +++ b/run_agent.py @@ -110,18 +110,17 @@ HONCHO_TOOL_NAMES = { class _SafeWriter: - """Transparent stdout wrapper that catches OSError from broken pipes. + """Transparent stdio wrapper that catches OSError from broken pipes. When hermes-agent runs as a systemd service, Docker container, or headless - daemon, the stdout pipe can become unavailable (idle timeout, buffer + daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer exhaustion, socket reset). Any print() call then raises - ``OSError: [Errno 5] Input/output error``, which can crash - run_conversation() — especially via double-fault when the except handler + ``OSError: [Errno 5] Input/output error``, which can crash agent setup or + run_conversation() — especially via double-fault when an except handler also tries to print. This wrapper delegates all writes to the underlying stream and silently - catches OSError. It is installed once at the start of run_conversation() - and is transparent when stdout is healthy (zero overhead on the happy path). + catches OSError. It is transparent when the wrapped stream is healthy. """ __slots__ = ("_inner",) @@ -154,6 +153,14 @@ class _SafeWriter: return getattr(self._inner, name) +def _install_safe_stdio() -> None: + """Wrap stdout/stderr so best-effort console output cannot crash the agent.""" + for stream_name in ("stdout", "stderr"): + stream = getattr(sys, stream_name, None) + if stream is not None and not isinstance(stream, _SafeWriter): + setattr(sys, stream_name, _SafeWriter(stream)) + + class IterationBudget: """Thread-safe shared iteration counter for parent and child agents. @@ -324,6 +331,8 @@ class AIAgent: honcho_manager: Optional shared HonchoSessionManager owned by the caller. honcho_config: Optional HonchoClientConfig corresponding to honcho_manager. """ + _install_safe_stdio() + self.model = model self.max_iterations = max_iterations # Shared iteration budget — parent creates, children inherit. @@ -3868,10 +3877,9 @@ class AIAgent: Returns: Dict: Complete conversation result with final response and message history """ - # Guard stdout against OSError from broken pipes (systemd/headless/daemon). - # Installed once, transparent when stdout is healthy, prevents crash on write. - if not isinstance(sys.stdout, _SafeWriter): - sys.stdout = _SafeWriter(sys.stdout) + # Guard stdio against OSError from broken pipes (systemd/headless/daemon). + # Installed once, transparent when streams are healthy, prevents crash on write. + _install_safe_stdio() # Generate unique task_id if not provided to isolate VMs between concurrent tasks effective_task_id = task_id or str(uuid.uuid4()) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index b20625450..0b6b28116 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1800,12 +1800,13 @@ class TestSafeWriter: sys.stdout = original def test_installed_in_run_conversation(self, agent): - """run_conversation installs _SafeWriter on sys.stdout.""" + """run_conversation installs _SafeWriter on stdio.""" import sys from run_agent import _SafeWriter resp = _mock_response(content="Done", finish_reason="stop") agent.client.chat.completions.create.return_value = resp - original = sys.stdout + original_stdout = sys.stdout + original_stderr = sys.stderr try: with ( patch.object(agent, "_persist_session"), @@ -1814,6 +1815,41 @@ class TestSafeWriter: ): agent.run_conversation("test") assert isinstance(sys.stdout, _SafeWriter) + assert isinstance(sys.stderr, _SafeWriter) + finally: + sys.stdout = original_stdout + sys.stderr = original_stderr + + def test_installed_before_init_time_honcho_error_prints(self): + """AIAgent.__init__ wraps stdout before Honcho fallback prints can fire.""" + import sys + from run_agent import _SafeWriter + + broken = MagicMock() + broken.write.side_effect = OSError(5, "Input/output error") + broken.flush.side_effect = OSError(5, "Input/output error") + + original = sys.stdout + sys.stdout = broken + try: + hcfg = HonchoClientConfig(enabled=True, api_key="test-honcho-key") + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch("hermes_cli.config.load_config", return_value={"memory": {}}), + patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg), + patch("honcho_integration.client.get_honcho_client", side_effect=RuntimeError("boom")), + ): + agent = AIAgent( + api_key="test-k...7890", + quiet_mode=True, + skip_context_files=True, + skip_memory=False, + ) + + assert isinstance(sys.stdout, _SafeWriter) + assert agent._honcho is None finally: sys.stdout = original