Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion omnigent/inner/acp_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
ToolCallStatus,
ToolSpec,
TurnComplete,
describe_exception,
)
from omnigent.inner.os_env import OSEnvironment, create_os_environment

Expand Down Expand Up @@ -465,7 +466,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."""
Expand Down
16 changes: 16 additions & 0 deletions omnigent/inner/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/goose_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
ToolCallStatus,
ToolSpec,
TurnComplete,
describe_exception,
)
from omnigent.inner.os_env import OSEnvironment, create_os_environment

Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/qwen_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
9 changes: 8 additions & 1 deletion omnigent/runtime/harnesses/_executor_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/inner/test_acp_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ToolCallRequest,
ToolCallStatus,
TurnComplete,
describe_exception,
)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -569,3 +570,35 @@ async def deny(tool_name: str, tool_input: dict) -> bool:

# Turn still completes even though the tool was rejected.
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() != ""
Loading