Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 3 additions & 2 deletions 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 @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/antigravity_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
TurnCancelled,
TurnComplete,
classify_tool_result,
describe_exception,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/antigravity_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/claude_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)
from omnigent.inner.native_attachments import attachment_reference_line

Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/claude_sdk_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
ToolSpec,
TurnComplete,
classify_tool_result,
describe_exception,
)
from .native_attachments import unresolved_attachment_marker
from .sandbox import (
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/codex_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/cursor_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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.
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
5 changes: 3 additions & 2 deletions 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 Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/goose_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/hermes_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/kimi_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)
from omnigent.kimi_native_bridge import BRIDGE_DIR_ENV_VAR, inject_user_message

Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/kiro_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)
from omnigent.kiro_native_bridge import KIRO_NATIVE_BRIDGE_DIR_ENV_VAR, inject_user_message

Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/openai_agents_sdk_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
ToolSpec,
TurnComplete,
classify_tool_result,
describe_exception,
split_transient_tail,
)
from .open_responses_sdk import (
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions 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 Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion omnigent/inner/qwen_native_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Message,
ToolSpec,
TurnComplete,
describe_exception,
)
from omnigent.qwen_native_bridge import (
BRIDGE_DIR_ENV_VAR,
Expand Down Expand Up @@ -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)

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 @@ -35,6 +35,7 @@
ToolCallRequest,
ToolCallStatus,
TurnComplete,
describe_exception,
)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading