|
8 | 8 | from pydantic import BaseModel, Field |
9 | 9 | from republic import AsyncTapeStore, TapeQuery, ToolContext |
10 | 10 |
|
| 11 | +from bub.builtin.shell_manager import shell_manager |
11 | 12 | from bub.skills import discover_skills |
12 | 13 | from bub.tools import tool |
13 | 14 |
|
@@ -59,24 +60,52 @@ class SubAgentInput(BaseModel): |
59 | 60 |
|
60 | 61 | @tool(context=True) |
61 | 62 | async def bash( |
62 | | - cmd: str, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS, *, context: ToolContext |
| 63 | + cmd: str, |
| 64 | + cwd: str | None = None, |
| 65 | + timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS, |
| 66 | + background: bool = False, |
| 67 | + *, |
| 68 | + context: ToolContext, |
63 | 69 | ) -> str: |
64 | | - """Run a shell command and return its output within a time limit. Raises if the command fails or times out.""" |
| 70 | + """Run a shell command. Use background=true to keep it running and fetch output later via bash_output.""" |
65 | 71 | workspace = context.state.get("_runtime_workspace") |
66 | | - completed = await asyncio.create_subprocess_shell( |
67 | | - cmd, |
68 | | - cwd=cwd or workspace, |
69 | | - stdout=asyncio.subprocess.PIPE, |
70 | | - stderr=asyncio.subprocess.PIPE, |
71 | | - ) |
72 | | - async with asyncio.timeout(timeout_seconds): |
73 | | - stdout_bytes, stderr_bytes = await completed.communicate() |
74 | | - stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() |
75 | | - stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() |
76 | | - if completed.returncode != 0: |
77 | | - message = stderr_text or stdout_text or f"exit={completed.returncode}" |
78 | | - raise RuntimeError(f"exit={completed.returncode}: {message}") |
79 | | - return stdout_text or "(no output)" |
| 72 | + target_cwd = cwd or workspace |
| 73 | + shell = await shell_manager.start(cmd=cmd, cwd=target_cwd) |
| 74 | + if background: |
| 75 | + return f"started: {shell.shell_id}" |
| 76 | + try: |
| 77 | + async with asyncio.timeout(timeout_seconds): |
| 78 | + await shell_manager.wait_closed(shell.shell_id) |
| 79 | + except TimeoutError: |
| 80 | + await shell_manager.terminate(shell.shell_id) |
| 81 | + return f"command timed out after {timeout_seconds} seconds and was terminated" |
| 82 | + return shell.output.strip() or "(no output)" |
| 83 | + |
| 84 | + |
| 85 | +@tool(name="bash.output") |
| 86 | +async def bash_output(shell_id: str, offset: int = 0, limit: int | None = None) -> str: |
| 87 | + """Read buffered output from a background shell, with optional offset/limit for incremental polling.""" |
| 88 | + shell = shell_manager.get(shell_id) |
| 89 | + if shell.returncode is not None: |
| 90 | + await shell_manager.wait_closed(shell_id) |
| 91 | + output = shell.output |
| 92 | + start = max(0, min(offset, len(output))) |
| 93 | + end = len(output) if limit is None else min(len(output), start + max(0, limit)) |
| 94 | + chunk = output[start:end].rstrip() |
| 95 | + exit_code = "null" if shell.returncode is None else str(shell.returncode) |
| 96 | + body = chunk or "(no output)" |
| 97 | + return f"id: {shell.shell_id}\nstatus: {shell.status}\nexit_code: {exit_code}\nnext_offset: {end}\noutput:\n{body}" |
| 98 | + |
| 99 | + |
| 100 | +@tool(name="bash.kill") |
| 101 | +async def kill_bash(shell_id: str) -> str: |
| 102 | + """Terminate a background shell process.""" |
| 103 | + shell = shell_manager.get(shell_id) |
| 104 | + if shell.returncode is None: |
| 105 | + shell = await shell_manager.terminate(shell_id) |
| 106 | + else: |
| 107 | + await shell_manager.wait_closed(shell_id) |
| 108 | + return f"id: {shell.shell_id}\nstatus: {shell.status}\nexit_code: {shell.returncode}" |
80 | 109 |
|
81 | 110 |
|
82 | 111 | @tool(context=True, name="fs.read") |
@@ -243,6 +272,9 @@ def show_help() -> str: |
243 | 272 | " ,fs.read path=README.md\n" |
244 | 273 | " ,fs.write path=tmp.txt content='hello'\n" |
245 | 274 | " ,fs.edit path=tmp.txt old=hello new=world\n" |
| 275 | + " ,bash cmd='sleep 5' background=true\n" |
| 276 | + " ,bash_output shell_id=bsh-12345678\n" |
| 277 | + " ,kill_bash shell_id=bsh-12345678\n" |
246 | 278 | "Any unknown command after ',' is executed as shell via bash." |
247 | 279 | ) |
248 | 280 |
|
|
0 commit comments