| import asyncio |
| import os |
| from typing import Optional |
|
|
| from app.exceptions import ToolError |
| from app.tool.base import BaseTool, CLIResult |
|
|
|
|
| _BASH_DESCRIPTION = """Execute a bash command in the terminal. |
| * Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`. |
| * Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process. |
| * Timeout: If a command execution result says "Command timed out. Sending SIGINT to the process", the assistant should retry running the command in the background. |
| """ |
|
|
|
|
| class _BashSession: |
| """A session of a bash shell.""" |
|
|
| _started: bool |
| _process: asyncio.subprocess.Process |
|
|
| command: str = "/bin/bash" |
| _output_delay: float = 0.2 |
| _timeout: float = 120.0 |
| _sentinel: str = "<<exit>>" |
|
|
| def __init__(self): |
| self._started = False |
| self._timed_out = False |
|
|
| async def start(self): |
| if self._started: |
| return |
|
|
| self._process = await asyncio.create_subprocess_shell( |
| self.command, |
| preexec_fn=os.setsid, |
| shell=True, |
| bufsize=0, |
| stdin=asyncio.subprocess.PIPE, |
| stdout=asyncio.subprocess.PIPE, |
| stderr=asyncio.subprocess.PIPE, |
| ) |
|
|
| self._started = True |
|
|
| def stop(self): |
| """Terminate the bash shell.""" |
| if not self._started: |
| raise ToolError("Session has not started.") |
| if self._process.returncode is not None: |
| return |
| self._process.terminate() |
|
|
| async def run(self, command: str): |
| """Execute a command in the bash shell.""" |
| if not self._started: |
| raise ToolError("Session has not started.") |
| if self._process.returncode is not None: |
| return CLIResult( |
| system="tool must be restarted", |
| error=f"bash has exited with returncode {self._process.returncode}", |
| ) |
| if self._timed_out: |
| raise ToolError( |
| f"timed out: bash has not returned in {self._timeout} seconds and must be restarted", |
| ) |
|
|
| |
| assert self._process.stdin |
| assert self._process.stdout |
| assert self._process.stderr |
|
|
| |
| self._process.stdin.write( |
| command.encode() + f"; echo '{self._sentinel}'\n".encode() |
| ) |
| await self._process.stdin.drain() |
|
|
| |
| try: |
| async with asyncio.timeout(self._timeout): |
| while True: |
| await asyncio.sleep(self._output_delay) |
| |
| |
| output = ( |
| self._process.stdout._buffer.decode() |
| ) |
| if self._sentinel in output: |
| |
| output = output[: output.index(self._sentinel)] |
| break |
| except asyncio.TimeoutError: |
| self._timed_out = True |
| raise ToolError( |
| f"timed out: bash has not returned in {self._timeout} seconds and must be restarted", |
| ) from None |
|
|
| if output.endswith("\n"): |
| output = output[:-1] |
|
|
| error = ( |
| self._process.stderr._buffer.decode() |
| ) |
| if error.endswith("\n"): |
| error = error[:-1] |
|
|
| |
| self._process.stdout._buffer.clear() |
| self._process.stderr._buffer.clear() |
|
|
| return CLIResult(output=output, error=error) |
|
|
|
|
| class Bash(BaseTool): |
| """A tool for executing bash commands""" |
|
|
| name: str = "bash" |
| description: str = _BASH_DESCRIPTION |
| parameters: dict = { |
| "type": "object", |
| "properties": { |
| "command": { |
| "type": "string", |
| "description": "The bash command to execute. Can be empty to view additional logs when previous exit code is `-1`. Can be `ctrl+c` to interrupt the currently running process.", |
| }, |
| }, |
| "required": ["command"], |
| } |
|
|
| _session: Optional[_BashSession] = None |
|
|
| async def execute( |
| self, command: str | None = None, restart: bool = False, **kwargs |
| ) -> CLIResult: |
| if restart: |
| if self._session: |
| self._session.stop() |
| self._session = _BashSession() |
| await self._session.start() |
|
|
| return CLIResult(system="tool has been restarted.") |
|
|
| if self._session is None: |
| self._session = _BashSession() |
| await self._session.start() |
|
|
| if command is not None: |
| return await self._session.run(command) |
|
|
| raise ToolError("no command provided.") |
|
|
|
|
| if __name__ == "__main__": |
| bash = Bash() |
| rst = asyncio.run(bash.execute("ls -l")) |
| print(rst) |
|
|