Rebase onto upstream (a4d95fd)
#12
@@ -6,10 +6,12 @@ from nanobot.agent.tools.anthropic.base import (
|
||||
CLIResult,
|
||||
ToolError,
|
||||
)
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
|
||||
__all__ = [
|
||||
"BaseAnthropicTool",
|
||||
"ToolResult",
|
||||
"CLIResult",
|
||||
"ToolError",
|
||||
"BashTool20250124",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""BashTool20250124 - Persistent bash session with sentinel-based output.
|
||||
|
||||
Anthropic's native bash_20250124 tool with a long-running session.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult
|
||||
|
||||
|
||||
class _BashSession:
|
||||
"""Manages a persistent bash subprocess with sentinel-based output reading."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
self._start()
|
||||
|
||||
def _start(self):
|
||||
"""Start the bash process."""
|
||||
self.process = subprocess.Popen(
|
||||
["bash"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
def restart(self):
|
||||
"""Restart the bash session."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self._start()
|
||||
|
||||
async def run_command(self, command: str, timeout: float = 120.0) -> str:
|
||||
"""Run a command in the persistent bash session.
|
||||
|
||||
Uses a unique sentinel to detect command completion.
|
||||
|
||||
Args:
|
||||
command: Bash command to execute
|
||||
timeout: Maximum time to wait for command completion (seconds)
|
||||
|
||||
Returns:
|
||||
Command output (stdout + stderr combined)
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: If command doesn't complete within timeout
|
||||
RuntimeError: If bash process has died
|
||||
"""
|
||||
if not self.process or self.process.poll() is not None:
|
||||
raise RuntimeError("Bash process has died")
|
||||
|
||||
# Generate unique sentinel
|
||||
sentinel = f"<<BASH_COMMAND_DONE_{uuid.uuid4().hex}>>"
|
||||
|
||||
# Send command + sentinel
|
||||
full_command = f"{command}\necho '{sentinel}'\n"
|
||||
self.process.stdin.write(full_command)
|
||||
self.process.stdin.flush()
|
||||
|
||||
# Read output until sentinel appears
|
||||
output_lines = []
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
# Check timeout
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise asyncio.TimeoutError(
|
||||
f"Command timed out after {timeout}s: {command[:50]}..."
|
||||
)
|
||||
|
||||
# Read line (non-blocking via asyncio)
|
||||
try:
|
||||
line = await asyncio.wait_for(
|
||||
asyncio.to_thread(self.process.stdout.readline),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# No output yet, continue waiting
|
||||
continue
|
||||
|
||||
if not line:
|
||||
# EOF - process died
|
||||
raise RuntimeError("Bash process terminated unexpectedly")
|
||||
|
||||
# Check for sentinel
|
||||
if sentinel in line:
|
||||
break
|
||||
|
||||
output_lines.append(line.rstrip("\n"))
|
||||
|
||||
return "\n".join(output_lines)
|
||||
|
||||
def __del__(self):
|
||||
"""Clean up bash process on deletion."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
|
||||
|
||||
class BashTool20250124(BaseAnthropicTool):
|
||||
"""Anthropic's native bash_20250124 tool with persistent session.
|
||||
|
||||
Executes bash commands in a long-running shell session. Environment
|
||||
variables and working directory persist across commands.
|
||||
|
||||
Parameters:
|
||||
command (str, optional): Bash command to execute
|
||||
restart (bool, optional): Restart the bash session (clears state)
|
||||
"""
|
||||
|
||||
api_type = "bash_20250124"
|
||||
name = "bash"
|
||||
|
||||
def __init__(self):
|
||||
self._session = _BashSession()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: str | None = None,
|
||||
restart: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> ToolResult:
|
||||
"""Execute bash command or restart session.
|
||||
|
||||
Args:
|
||||
command: Bash command to execute (optional)
|
||||
restart: Restart the bash session (optional)
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
ToolResult with command output or error
|
||||
"""
|
||||
if restart:
|
||||
self._session.restart()
|
||||
return ToolResult(output="Bash session restarted successfully.")
|
||||
|
||||
if not command:
|
||||
return ToolResult(
|
||||
error="Either 'command' or 'restart=True' must be provided."
|
||||
)
|
||||
|
||||
try:
|
||||
output = await self._session.run_command(command)
|
||||
return ToolResult(output=output if output else "(no output)")
|
||||
except asyncio.TimeoutError as e:
|
||||
return ToolResult(error=f"Command timed out: {e}")
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"{e}")
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API with bash_20250124 type
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for BashTool20250124."""
|
||||
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_simple_command():
|
||||
"""Test bash tool executes simple command."""
|
||||
tool = BashTool20250124()
|
||||
result = await tool(command="echo hello")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert "hello" in result.output
|
||||
assert result.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_persistent_session():
|
||||
"""Test bash tool maintains session across calls."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Set variable
|
||||
result1 = await tool(command="export TEST_VAR=42")
|
||||
assert result1.error is None
|
||||
|
||||
# Read variable (should persist)
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
assert "42" in result2.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_restart():
|
||||
"""Test bash tool can restart session."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Set variable
|
||||
await tool(command="export TEST_VAR=42")
|
||||
|
||||
# Restart
|
||||
result = await tool(restart=True)
|
||||
assert "restarted" in result.output.lower()
|
||||
|
||||
# Variable should be gone
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
assert "42" not in result2.output
|
||||
|
||||
|
||||
def test_bash_tool_to_params():
|
||||
"""Test bash tool returns correct params."""
|
||||
tool = BashTool20250124()
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "bash_20250124"
|
||||
assert params["name"] == "bash"
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Tests for beta flag collection from native tools."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user