Skip to content
Merged
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
11 changes: 10 additions & 1 deletion src/bub/builtin/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)"


Expand Down
27 changes: 27 additions & 0 deletions tests/test_builtin_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
Loading