/
githubmirror
/
AgentPilot
Обзор
Документация
Войти
/
githubmirror
/
AgentPilot
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
v0.4.1
src/system/tools.py
457 строк
16 KB
jb
0.4.0
30 дек 2024, 07:21
30 дек 2024, 07:21
21f8b16
Код
Авторство
О чём код?
import asyncio import json from abc import ABCMeta, abstractmethod from dataclasses import dataclass, fields, replace from typing import Any from anthropic.types.beta import BetaToolUnionParam from src.utils import sql from src.utils.helpers import receive_workflow, params_to_schema class ToolManager: def __init__(self, parent): self.system = parent self.tools = {} self.tool_id_names = {} def load(self): tools_data = sql.get_results("SELECT name, config FROM tools", return_type='dict') self.tools = {name: json.loads(config) for name, config in tools_data.items()} self.tool_id_names = sql.get_results("SELECT uuid, name FROM tools", return_type='dict') def to_dict(self): return self.tools def get_param_schema(self, tool_uuid): tool_name = self.tool_id_names.get(tool_uuid) tool_config = self.tools.get(tool_name) tool_params = tool_config.get('params', []) return params_to_schema(tool_params) async def compute_tool_async(self, tool_uuid, params=None): tool_name = self.tool_id_names.get(tool_uuid) tool_config = self.tools.get(tool_name) output = '' status = 'success' async for key, chunk in receive_workflow(tool_config, 'TOOL', params, tool_uuid): output += chunk if key == 'error': status = 'error' return json.dumps({'output': output, 'status': status, 'tool_uuid': tool_uuid}) def compute_tool(self, tool_uuid, params=None): # , visited=None, ): # return asyncio.run(self.receive_block(name, add_input)) return asyncio.run(self.compute_tool_async(tool_uuid, params)) class BaseAnthropicTool(metaclass=ABCMeta): """Abstract base class for Anthropic-defined tools.""" @abstractmethod def __call__(self, **kwargs) -> Any: """Executes the tool with the given arguments.""" ... @abstractmethod def to_params( self, ) -> BetaToolUnionParam: raise NotImplementedError @dataclass(kw_only=True, frozen=True) class ToolResult: """Represents the result of a tool execution.""" output: str | None = None error: str | None = None base64_image: str | None = None system: str | None = None def __bool__(self): return any(getattr(self, field.name) for field in fields(self)) def __add__(self, other: "ToolResult"): def combine_fields( field: str | None, other_field: str | None, concatenate: bool = True ): if field and other_field: if concatenate: return field + other_field raise ValueError("Cannot combine tool results") return field or other_field return ToolResult( output=combine_fields(self.output, other.output), error=combine_fields(self.error, other.error), base64_image=combine_fields(self.base64_image, other.base64_image, False), system=combine_fields(self.system, other.system), ) def replace(self, **kwargs): """Returns a new ToolResult with the given fields replaced.""" return replace(self, **kwargs) class CLIResult(ToolResult): """A ToolResult that can be rendered as a CLI output.""" class ToolFailure(ToolResult): """A ToolResult that represents a failure.""" class ToolError(Exception): """Raised when a tool encounters an error.""" def __init__(self, message): self.message = message class ToolCollection: """A collection of anthropic-defined tools.""" def __init__(self, *tools: BaseAnthropicTool): self.tools = tools self.tool_map = {tool.to_params()["name"]: tool for tool in tools} def to_params( self, ) -> list[BetaToolUnionParam]: return [tool.to_params() for tool in self.tools] async def run(self, *, name: str, tool_input: dict[str, Any]) -> ToolResult: tool = self.tool_map.get(name) if not tool: return ToolFailure(error=f"Tool {name} is invalid") try: return await tool(**tool_input) except ToolError as e: return ToolFailure(error=e.message) """Utility to run shell commands asynchronously with a timeout.""" # import asyncio TRUNCATED_MESSAGE: str = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>" MAX_RESPONSE_LEN: int = 16000 def maybe_truncate(content: str, truncate_after: int | None = MAX_RESPONSE_LEN): """Truncate content and append a notice if content exceeds the specified length.""" return ( content if not truncate_after or len(content) <= truncate_after else content[:truncate_after] + TRUNCATED_MESSAGE ) async def run( cmd: str, timeout: float | None = 120.0, # seconds truncate_after: int | None = MAX_RESPONSE_LEN, ): """Run a shell command asynchronously with a timeout.""" process = await asyncio.create_subprocess_shell( cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) try: stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) return ( process.returncode or 0, maybe_truncate(stdout.decode(), truncate_after=truncate_after), maybe_truncate(stderr.decode(), truncate_after=truncate_after), ) except asyncio.TimeoutError as exc: try: process.kill() except ProcessLookupError: pass raise TimeoutError( f"Command '{cmd}' timed out after {timeout} seconds" ) from exc import asyncio import base64 import os import shlex import shutil from enum import Enum from pathlib import Path from typing import Literal, TypedDict from uuid import uuid4 from anthropic.types.beta import BetaToolComputerUse20241022Param # from .run import run class StrEnum(str, Enum): def __new__(cls, value, *args, **kwargs): if not isinstance(value, (str, int)): raise TypeError(f"Values of StrEnums must be strings: {value!r} is a {type(value)}") return super().__new__(cls, value, *args, **kwargs) def __str__(self): return self.value OUTPUT_DIR = "/tmp/outputs" TYPING_DELAY_MS = 12 TYPING_GROUP_SIZE = 50 Action = Literal[ "key", "type", "mouse_move", "left_click", "left_click_drag", "right_click", "middle_click", "double_click", "screenshot", "cursor_position", ] class Resolution(TypedDict): width: int height: int # sizes above XGA/WXGA are not recommended (see README.md) # scale down to one of these targets if ComputerTool._scaling_enabled is set MAX_SCALING_TARGETS: dict[str, Resolution] = { "XGA": Resolution(width=1024, height=768), # 4:3 "WXGA": Resolution(width=1280, height=800), # 16:10 "FWXGA": Resolution(width=1366, height=768), # ~16:9 } class ScalingSource(StrEnum): COMPUTER = "computer" API = "api" class ComputerToolOptions(TypedDict): display_height_px: int display_width_px: int display_number: int | None def chunks(s: str, chunk_size: int) -> list[str]: return [s[i : i + chunk_size] for i in range(0, len(s), chunk_size)] class ComputerTool(BaseAnthropicTool): """ A tool that allows the agent to interact with the screen, keyboard, and mouse of the current computer. The tool parameters are defined by Anthropic and are not editable. """ name: Literal["computer"] = "computer" api_type: Literal["computer_20241022"] = "computer_20241022" width: int height: int display_num: int | None _screenshot_delay = 2.0 _scaling_enabled = True @property def options(self) -> ComputerToolOptions: width, height = self.scale_coordinates( ScalingSource.COMPUTER, self.width, self.height ) return { "display_width_px": width, "display_height_px": height, "display_number": self.display_num, } def to_params(self) -> BetaToolComputerUse20241022Param: return {"name": self.name, "type": self.api_type, **self.options} def get_screen_resolution(self): from screeninfo import get_monitors monitors = get_monitors() if monitors: main_monitor = monitors[0] # Get the primary monitor return main_monitor.width, main_monitor.height return None, None def __init__(self): super().__init__() self.width, self.height = self.get_screen_resolution() # self.width = int(os.getenv("WIDTH") or 0) # self.height = int(os.getenv("HEIGHT") or 0) assert self.width and self.height, "WIDTH, HEIGHT must be set" if (display_num := os.getenv("DISPLAY_NUM")) is not None: self.display_num = int(display_num) self._display_prefix = f"DISPLAY=:{self.display_num} " else: self.display_num = None self._display_prefix = "" self.xdotool = f"{self._display_prefix}xdotool" async def __call__( self, *, action: Action, text: str | None = None, coordinate: tuple[int, int] | None = None, **kwargs, ): if action in ("mouse_move", "left_click_drag"): if coordinate is None: raise ToolError(f"coordinate is required for {action}") if text is not None: raise ToolError(f"text is not accepted for {action}") if not isinstance(coordinate, list) or len(coordinate) != 2: raise ToolError(f"{coordinate} must be a tuple of length 2") if not all(isinstance(i, int) and i >= 0 for i in coordinate): raise ToolError(f"{coordinate} must be a tuple of non-negative ints") x, y = self.scale_coordinates( ScalingSource.API, coordinate[0], coordinate[1] ) if action == "mouse_move": return await self.shell(f"{self.xdotool} mousemove --sync {x} {y}") elif action == "left_click_drag": return await self.shell( f"{self.xdotool} mousedown 1 mousemove --sync {x} {y} mouseup 1" ) if action in ("key", "type"): if text is None: raise ToolError(f"text is required for {action}") if coordinate is not None: raise ToolError(f"coordinate is not accepted for {action}") if not isinstance(text, str): raise ToolError(output=f"{text} must be a string") if action == "key": return await self.shell(f"{self.xdotool} key -- {text}") elif action == "type": results: list[ToolResult] = [] for chunk in chunks(text, TYPING_GROUP_SIZE): cmd = f"{self.xdotool} type --delay {TYPING_DELAY_MS} -- {shlex.quote(chunk)}" results.append(await self.shell(cmd, take_screenshot=False)) screenshot_base64 = (await self.screenshot()).base64_image return ToolResult( output="".join(result.output or "" for result in results), error="".join(result.error or "" for result in results), base64_image=screenshot_base64, ) if action in ( "left_click", "right_click", "double_click", "middle_click", "screenshot", "cursor_position", ): if text is not None: raise ToolError(f"text is not accepted for {action}") if coordinate is not None: raise ToolError(f"coordinate is not accepted for {action}") if action == "screenshot": return await self.screenshot() elif action == "cursor_position": result = await self.shell( f"{self.xdotool} getmouselocation --shell", take_screenshot=False, ) output = result.output or "" x, y = self.scale_coordinates( ScalingSource.COMPUTER, int(output.split("X=")[1].split("\n")[0]), int(output.split("Y=")[1].split("\n")[0]), ) return result.replace(output=f"X={x},Y={y}") else: click_arg = { "left_click": "1", "right_click": "3", "middle_click": "2", "double_click": "--repeat 2 --delay 500 1", }[action] return await self.shell(f"{self.xdotool} click {click_arg}") raise ToolError(f"Invalid action: {action}") async def screenshot(self): """Take a screenshot of the current screen and return the base64 encoded image.""" output_dir = Path(OUTPUT_DIR) output_dir.mkdir(parents=True, exist_ok=True) path = output_dir / f"screenshot_{uuid4().hex}.png" # Try gnome-screenshot first if shutil.which("gnome-screenshot"): screenshot_cmd = f"{self._display_prefix}gnome-screenshot -f {path} -p" else: # Fall back to scrot if gnome-screenshot isn't available screenshot_cmd = f"{self._display_prefix}scrot -p {path}" result = await self.shell(screenshot_cmd, take_screenshot=False) if self._scaling_enabled: x, y = self.scale_coordinates( ScalingSource.COMPUTER, self.width, self.height ) await self.shell( f"convert {path} -resize {x}x{y}! {path}", take_screenshot=False ) if path.exists(): return result.replace( base64_image=base64.b64encode(path.read_bytes()).decode() ) raise ToolError(f"Failed to take screenshot: {result.error}") async def shell(self, command: str, take_screenshot=True) -> ToolResult: """Run a shell command and return the output, error, and optionally a screenshot.""" _, stdout, stderr = await run(command) base64_image = None if take_screenshot: # delay to let things settle before taking a screenshot await asyncio.sleep(self._screenshot_delay) base64_image = (await self.screenshot()).base64_image return ToolResult(output=stdout, error=stderr, base64_image=base64_image) def scale_coordinates(self, source: ScalingSource, x: int, y: int): """Scale coordinates to a target maximum resolution.""" if not self._scaling_enabled: return x, y ratio = self.width / self.height target_dimension = None for dimension in MAX_SCALING_TARGETS.values(): # allow some error in the aspect ratio - not ratios are exactly 16:9 if abs(dimension["width"] / dimension["height"] - ratio) < 0.02: if dimension["width"] < self.width: target_dimension = dimension break if target_dimension is None: return x, y # should be less than 1 x_scaling_factor = target_dimension["width"] / self.width y_scaling_factor = target_dimension["height"] / self.height if source == ScalingSource.API: if x > self.width or y > self.height: raise ToolError(f"Coordinates {x}, {y} are out of bounds") # scale up return round(x / x_scaling_factor), round(y / y_scaling_factor) # scale down return round(x * x_scaling_factor), round(y * y_scaling_factor)