Rebase onto upstream (a4d95fd)
#12
@@ -6,10 +6,12 @@ from nanobot.agent.tools.anthropic.base import (
|
|||||||
CLIResult,
|
CLIResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseAnthropicTool",
|
"BaseAnthropicTool",
|
||||||
"ToolResult",
|
"ToolResult",
|
||||||
"CLIResult",
|
"CLIResult",
|
||||||
"ToolError",
|
"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."""
|
"""Tests for beta flag collection from native tools."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, AsyncMock, MagicMock
|
|
||||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user