diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 31af4bc3..9c821573 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -23,6 +23,14 @@ DEFAULT_REQUEST_TIMEOUT_SECONDS = 10 +def _raise_for_failed_shell(returncode: int | None, output: str) -> None: + if returncode in (None, 0): + return + + body = output.strip() or "(no output)" + raise RuntimeError(f"command exited with code {returncode}\noutput:\n{body}") + + def _get_agent(context: ToolContext) -> Agent: if "_runtime_agent" not in context.state: raise RuntimeError("no runtime agent found in tool context") @@ -76,10 +84,11 @@ async def bash( return f"started: {shell.shell_id}" try: async with asyncio.timeout(timeout_seconds): - await shell_manager.wait_closed(shell.shell_id) + shell = await shell_manager.wait_closed(shell.shell_id) except TimeoutError: await shell_manager.terminate(shell.shell_id) return f"command timed out after {timeout_seconds} seconds and was terminated" + _raise_for_failed_shell(shell.returncode, shell.output) return shell.output.strip() or "(no output)" diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 3431492c..36c7e41e 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -6,6 +6,8 @@ import pytest from republic import ToolContext +from republic.core.errors import ErrorKind +from republic.tools.executor import ToolExecutor from bub.builtin.tools import bash, bash_output, kill_bash @@ -25,6 +27,31 @@ async def test_bash_returns_stdout_for_foreground_command(tmp_path) -> None: assert result == "hello" +@pytest.mark.asyncio +async def test_bash_non_zero_exit_is_returned_as_tool_error(tmp_path) -> None: + command = _python_shell("import sys; print('boom'); sys.exit(7)") + executor = ToolExecutor() + tool_call = { + "type": "function", + "function": { + "name": bash.name, + "arguments": {"cmd": command}, + }, + } + + result = await executor.execute_async([tool_call], tools=[bash], context=_tool_context(tmp_path)) + + assert result.error is not None + assert result.error.kind is ErrorKind.TOOL + assert len(result.tool_results) == 1 + tool_result = result.tool_results[0] + assert tool_result["kind"] == "tool" + assert tool_result["message"] == "Tool 'bash' execution failed." + error_detail = tool_result["details"]["error"] + assert "command exited with code 7" in error_detail + assert "boom" in error_detail + + @pytest.mark.asyncio async def test_background_bash_exposes_output_via_bash_output(tmp_path) -> None: command = _python_shell(