diff --git a/omnigent/inner/acp_executor.py b/omnigent/inner/acp_executor.py index b12abf8574..2a385515ac 100644 --- a/omnigent/inner/acp_executor.py +++ b/omnigent/inner/acp_executor.py @@ -74,6 +74,7 @@ ToolCallStatus, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.inner.os_env import OSEnvironment, create_os_environment @@ -472,7 +473,7 @@ async def _read_stdout(self) -> None: for fut in self._pending.values(): if not fut.done(): fut.set_exception(exc) - await self._queue.put({"type": "error", "message": str(exc)}) + await self._queue.put({"type": "error", "message": describe_exception(exc)}) async def _send(self, msg: _AcpJsonObject) -> None: """Write one newline-terminated JSON message to the agent's stdin.""" @@ -1057,7 +1058,7 @@ async def run_turn( await self._ensure_initialized() session_id = await self._ensure_session() except Exception as exc: # noqa: BLE001 - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return # A fresh ACP session holds no prior context. Captured before the latch diff --git a/omnigent/inner/antigravity_executor.py b/omnigent/inner/antigravity_executor.py index 85606b6695..2767aea81f 100644 --- a/omnigent/inner/antigravity_executor.py +++ b/omnigent/inner/antigravity_executor.py @@ -69,6 +69,7 @@ TurnCancelled, TurnComplete, classify_tool_result, + describe_exception, ) logger = logging.getLogger(__name__) @@ -498,7 +499,7 @@ async def run_turn( tools=tools, ) except ImportError as exc: - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return except Exception as exc: logger.exception("Antigravity agent construction failed") diff --git a/omnigent/inner/antigravity_native_executor.py b/omnigent/inner/antigravity_native_executor.py index 5393ce9442..837611f29d 100644 --- a/omnigent/inner/antigravity_native_executor.py +++ b/omnigent/inner/antigravity_native_executor.py @@ -82,6 +82,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.llms.errors import PermanentLLMError from omnigent.reasoning_effort import ANTIGRAVITY_EFFORTS, validate_effort_or_llm_error @@ -231,7 +232,7 @@ async def run_turn( try: validate_effort_or_llm_error(effort, "antigravity", ANTIGRAVITY_EFFORTS) except PermanentLLMError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return text = _latest_user_text(messages, self._bridge_dir) if not text: diff --git a/omnigent/inner/claude_native_executor.py b/omnigent/inner/claude_native_executor.py index 0e27ff2e53..d42feca50a 100644 --- a/omnigent/inner/claude_native_executor.py +++ b/omnigent/inner/claude_native_executor.py @@ -29,6 +29,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.inner.native_attachments import attachment_reference_line @@ -192,7 +193,7 @@ async def run_turn( content=text, ) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return yield TurnComplete(response=None) diff --git a/omnigent/inner/claude_sdk_executor.py b/omnigent/inner/claude_sdk_executor.py index 4fbbf2af8b..84bb3f9a25 100644 --- a/omnigent/inner/claude_sdk_executor.py +++ b/omnigent/inner/claude_sdk_executor.py @@ -71,6 +71,7 @@ ToolSpec, TurnComplete, classify_tool_result, + describe_exception, ) from .native_attachments import unresolved_attachment_marker from .sandbox import ( @@ -2361,7 +2362,7 @@ def _on_stderr(line: str) -> None: cfg.extra.get("reasoning_effort"), "Claude Agent SDK", CLAUDE_EFFORTS ) except ValueError as exc: - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return if reasoning_effort is not None: options_kwargs["effort"] = reasoning_effort diff --git a/omnigent/inner/codex_executor.py b/omnigent/inner/codex_executor.py index cf16f897fe..35990439af 100644 --- a/omnigent/inner/codex_executor.py +++ b/omnigent/inner/codex_executor.py @@ -71,6 +71,7 @@ ToolSpec, TurnComplete, classify_tool_result, + describe_exception, ) from .hook_scripts.subagent_router import HOOK_TIMEOUT_HEADROOM_S as _ROUTER_HOOK_HEADROOM_S from .hook_scripts.subagent_router import REQUEST_TIMEOUT_S as _ROUTER_REQUEST_TIMEOUT_S @@ -3319,7 +3320,7 @@ async def run_turn( cfg.extra.get("reasoning_effort"), "codex", CODEX_EFFORTS ) except ValueError as exc: - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return app_session = await self._ensure_app_session( diff --git a/omnigent/inner/cursor_native_executor.py b/omnigent/inner/cursor_native_executor.py index 5ce198901f..ebf0a5c93a 100644 --- a/omnigent/inner/cursor_native_executor.py +++ b/omnigent/inner/cursor_native_executor.py @@ -32,6 +32,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) logger = logging.getLogger(__name__) @@ -105,7 +106,7 @@ async def run_turn( async with self._inject_lock: await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return # Injection landed — now it's safe to consume the preamble so later # turns inject the plain user text. diff --git a/omnigent/inner/executor.py b/omnigent/inner/executor.py index d3045e944f..00ab4f0470 100644 --- a/omnigent/inner/executor.py +++ b/omnigent/inner/executor.py @@ -51,6 +51,22 @@ ProviderStreamItem: TypeAlias = Any # type: ignore[explicit-any] +def describe_exception(exc: BaseException) -> str: + """Return a non-empty, human-readable description of *exc*. + + ``str(exc)`` is empty for several stdlib exceptions raised without a + message (a bare ``RuntimeError()``, ``TimeoutError()``, etc.). Executors + that report a failure by ``str(exc)`` then surface a blank error to the + operator (issue #4281: "inner executor error: " with no detail). Fall back + to ``repr(exc)`` — which always includes the class name — so a failure is + never reported without at least naming its type. + + :param exc: The exception to describe. + :returns: ``str(exc)`` when non-empty, otherwise ``repr(exc)``. + """ + return str(exc) or repr(exc) + + @runtime_checkable class _ClosableIterator(Protocol): """Iterator that may optionally expose a ``close`` method. diff --git a/omnigent/inner/goose_executor.py b/omnigent/inner/goose_executor.py index 9fd3e987a2..bd04322b0f 100644 --- a/omnigent/inner/goose_executor.py +++ b/omnigent/inner/goose_executor.py @@ -56,6 +56,7 @@ ToolCallStatus, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.inner.os_env import OSEnvironment, create_os_environment @@ -456,7 +457,7 @@ async def _read_stdout(self) -> None: for fut in self._pending.values(): if not fut.done(): fut.set_exception(exc) - await self._queue.put({"type": "error", "message": str(exc)}) + await self._queue.put({"type": "error", "message": describe_exception(exc)}) async def _send(self, msg: _AcpJsonObject) -> None: """Write one newline-terminated JSON message to goose stdin.""" @@ -1102,7 +1103,7 @@ async def run_turn( await self._ensure_initialized() session_id = await self._ensure_session() except Exception as exc: # noqa: BLE001 - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return # A fresh ACP session (first turn of a new/respawned process, or after diff --git a/omnigent/inner/goose_native_executor.py b/omnigent/inner/goose_native_executor.py index c0cf08cf2f..59a69901a2 100644 --- a/omnigent/inner/goose_native_executor.py +++ b/omnigent/inner/goose_native_executor.py @@ -26,6 +26,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) logger = logging.getLogger(__name__) @@ -87,7 +88,7 @@ async def run_turn( async with self._inject_lock: await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return yield TurnComplete(response=None) diff --git a/omnigent/inner/hermes_native_executor.py b/omnigent/inner/hermes_native_executor.py index da1c70b795..65b38a6489 100644 --- a/omnigent/inner/hermes_native_executor.py +++ b/omnigent/inner/hermes_native_executor.py @@ -27,6 +27,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) logger = logging.getLogger(__name__) @@ -88,7 +89,7 @@ async def run_turn( async with self._inject_lock: await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return yield TurnComplete(response=None) diff --git a/omnigent/inner/kimi_native_executor.py b/omnigent/inner/kimi_native_executor.py index c11cb4d142..7b65ee70c6 100644 --- a/omnigent/inner/kimi_native_executor.py +++ b/omnigent/inner/kimi_native_executor.py @@ -29,6 +29,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.kimi_native_bridge import BRIDGE_DIR_ENV_VAR, inject_user_message @@ -91,7 +92,7 @@ async def run_turn( async with self._inject_lock: await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return yield TurnComplete(response=None) diff --git a/omnigent/inner/kiro_native_executor.py b/omnigent/inner/kiro_native_executor.py index 34cd899d87..90a5a03cbe 100644 --- a/omnigent/inner/kiro_native_executor.py +++ b/omnigent/inner/kiro_native_executor.py @@ -16,6 +16,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.kiro_native_bridge import KIRO_NATIVE_BRIDGE_DIR_ENV_VAR, inject_user_message @@ -65,7 +66,7 @@ async def run_turn( async with self._inject_lock: await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return yield TurnComplete(response=None) diff --git a/omnigent/inner/openai_agents_sdk_executor.py b/omnigent/inner/openai_agents_sdk_executor.py index e772c73449..f8b833b696 100644 --- a/omnigent/inner/openai_agents_sdk_executor.py +++ b/omnigent/inner/openai_agents_sdk_executor.py @@ -47,6 +47,7 @@ ToolSpec, TurnComplete, classify_tool_result, + describe_exception, split_transient_tail, ) from .open_responses_sdk import ( @@ -1472,7 +1473,7 @@ async def run_turn( cfg.extra.get("reasoning_effort"), "OpenAI Agents SDK", OPENAI_AGENTS_EFFORTS ) except ValueError as exc: - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return state = self._get_or_create_session_state(agents_sdk, session_key) diff --git a/omnigent/inner/qwen_executor.py b/omnigent/inner/qwen_executor.py index 12ca130286..2f878c55e1 100644 --- a/omnigent/inner/qwen_executor.py +++ b/omnigent/inner/qwen_executor.py @@ -47,6 +47,7 @@ ToolCallStatus, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.inner.os_env import OSEnvironment, create_os_environment from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict @@ -531,7 +532,7 @@ async def _read_stdout(self) -> None: for fut in self._pending.values(): if not fut.done(): fut.set_exception(exc) - await self._queue.put({"type": "error", "message": str(exc)}) + await self._queue.put({"type": "error", "message": describe_exception(exc)}) async def _send(self, msg: _AcpJsonObject) -> None: """Write one newline-terminated JSON message to qwen stdin.""" @@ -1239,7 +1240,7 @@ async def run_turn( await self._ensure_initialized() session_id = await self._ensure_session() except Exception as exc: # noqa: BLE001 - yield ExecutorError(message=str(exc), retryable=False) + yield ExecutorError(message=describe_exception(exc), retryable=False) return # A fresh ACP session (first turn of a new/respawned process, or after diff --git a/omnigent/inner/qwen_native_executor.py b/omnigent/inner/qwen_native_executor.py index 1b8326d185..493477df97 100644 --- a/omnigent/inner/qwen_native_executor.py +++ b/omnigent/inner/qwen_native_executor.py @@ -31,6 +31,7 @@ Message, ToolSpec, TurnComplete, + describe_exception, ) from omnigent.qwen_native_bridge import ( BRIDGE_DIR_ENV_VAR, @@ -131,7 +132,7 @@ async def run_turn( async with self._inject_lock: await asyncio.to_thread(submit_user_message, self._bridge_dir, content=text) except RuntimeError as exc: - yield ExecutorError(message=str(exc)) + yield ExecutorError(message=describe_exception(exc)) return yield TurnComplete(response=None) diff --git a/omnigent/runtime/harnesses/_executor_adapter.py b/omnigent/runtime/harnesses/_executor_adapter.py index efc2563483..a3514fba9f 100644 --- a/omnigent/runtime/harnesses/_executor_adapter.py +++ b/omnigent/runtime/harnesses/_executor_adapter.py @@ -457,7 +457,14 @@ async def run_turn(self, request: CreateResponseRequest, ctx: TurnContext) -> No error=event.message, ) agent_span = None - raise RuntimeError(f"inner executor error: {event.message}") + # Never surface a blank "inner executor error: " to the + # operator: an ExecutorError whose message is empty (e.g. + # built from a bare exception) would otherwise report a + # failure with no detail at all (#4281). Executors now + # fall back to repr() at the source; this is the last-line + # guard for any other empty-message path. + detail = event.message or "no detail reported (see runner/harness logs)" + raise RuntimeError(f"inner executor error: {detail}") except ElicitationDeclinedError: # Fallback for executors that propagate the exception directly # (non-SDK / non-spawned-task paths). SDK-based executors use diff --git a/tests/inner/test_acp_executor.py b/tests/inner/test_acp_executor.py index ebda3e3a4a..0e068bb379 100644 --- a/tests/inner/test_acp_executor.py +++ b/tests/inner/test_acp_executor.py @@ -35,6 +35,7 @@ ToolCallRequest, ToolCallStatus, TurnComplete, + describe_exception, ) # --------------------------------------------------------------------------- @@ -589,6 +590,38 @@ async def deny(tool_name: str, tool_input: dict) -> bool: assert any(isinstance(e, TurnComplete) for e in events) +# --------------------------------------------------------------------------- +# describe_exception — never report a blank turn error (#4281) +# --------------------------------------------------------------------------- + + +def test_describe_exception_falls_back_to_repr_for_blank_message(): + """A bare exception whose ``str()`` is empty is described by ``repr()``. + + Regression for #4281: executors reported failures via ``str(exc)``, so a + bare ``RuntimeError()`` reached the operator as "inner executor error: " + with no detail. The fallback must at least name the exception type. + """ + assert str(RuntimeError()) == "" # the exact blank-message case from the bug + described = describe_exception(RuntimeError()) + assert described != "" + assert "RuntimeError" in described + + +def test_describe_exception_preserves_a_real_message(): + """When the exception carries a message, it is used verbatim (no repr noise).""" + assert describe_exception(ValueError("boom: bad line")) == "boom: bad line" + + +@pytest.mark.parametrize( + "exc", + [RuntimeError(), TimeoutError(), OSError(), Exception()], +) +def test_describe_exception_never_blank(exc: BaseException): + """No bare stdlib exception yields an empty description.""" + assert describe_exception(exc).strip() != "" + + # --------------------------------------------------------------------------- # Spawn env: the agent must actually receive credentials (#4281) # ---------------------------------------------------------------------------