91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
1960 lines
84 KiB
Python
1960 lines
84 KiB
Python
"""
|
|
Anthropic hosted tools agent loop implementation using liteLLM
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, Union
|
|
|
|
import litellm
|
|
from litellm.responses.litellm_completion_transformation.transformation import (
|
|
LiteLLMCompletionResponsesConfig,
|
|
)
|
|
|
|
from ..decorators import register_agent
|
|
from ..loops.base import AsyncAgentConfig
|
|
from ..responses import (
|
|
make_click_item,
|
|
make_double_click_item,
|
|
make_drag_item,
|
|
make_failed_tool_call_items,
|
|
make_input_image_item,
|
|
make_keypress_item,
|
|
make_left_mouse_down_item,
|
|
make_left_mouse_up_item,
|
|
make_move_item,
|
|
make_output_text_item,
|
|
make_reasoning_item,
|
|
make_screenshot_item,
|
|
make_scroll_item,
|
|
make_type_item,
|
|
make_wait_item,
|
|
)
|
|
from ..types import AgentCapability, AgentResponse, Messages, Tools
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Recommended maximum resolution for Anthropic's computer-use API.
|
|
# Screenshots larger than this are internally downscaled by the API, causing
|
|
# coordinate mismatches. We proactively downscale to avoid the offset.
|
|
RECOMMENDED_MAX_WIDTH = 1024
|
|
RECOMMENDED_MAX_HEIGHT = 768
|
|
|
|
|
|
def _scale_coordinate(coord: int, scale: float) -> int:
|
|
"""Scale a single coordinate value by a factor and round to int."""
|
|
scaled = int(round(coord * scale))
|
|
if scale != 1.0:
|
|
logger.debug("Scaling coordinate: %d * %.4f -> %d", coord, scale, scaled)
|
|
return scaled
|
|
|
|
|
|
# Model version mapping to tool version and beta flag
|
|
MODEL_TOOL_MAPPING = [
|
|
# Claude Opus 4.6/4.5 and Sonnet 4.6 require the 2025-11-24 computer-use beta
|
|
{
|
|
"pattern": r"claude-opus-4-6|claude-opus-4-5|claude-sonnet-4-6",
|
|
"tool_version": "computer_20251124",
|
|
"beta_flag": "computer-use-2025-11-24",
|
|
},
|
|
# Claude 4 models
|
|
{
|
|
"pattern": r"claude-4|claude-opus-4|claude-sonnet-4|claude-haiku-4",
|
|
"tool_version": "computer_20250124",
|
|
"beta_flag": "computer-use-2025-01-24",
|
|
},
|
|
# Claude 3.7 models
|
|
{
|
|
"pattern": r"claude-3\.?7|claude-3-7",
|
|
"tool_version": "computer_20250124",
|
|
"beta_flag": "computer-use-2025-01-24",
|
|
},
|
|
# Claude 3.5 models (fallback)
|
|
{
|
|
"pattern": r"claude-3\.?5|claude-3-5",
|
|
"tool_version": "computer_20241022",
|
|
"beta_flag": "computer-use-2024-10-22",
|
|
},
|
|
]
|
|
|
|
|
|
def _get_tool_config_for_model(model: str) -> Dict[str, str]:
|
|
"""Get tool version and beta flag for the given model."""
|
|
import re
|
|
|
|
for mapping in MODEL_TOOL_MAPPING:
|
|
if re.search(mapping["pattern"], model, re.IGNORECASE):
|
|
return {"tool_version": mapping["tool_version"], "beta_flag": mapping["beta_flag"]}
|
|
|
|
# Default to Claude 3.5 configuration
|
|
return {"tool_version": "computer_20241022", "beta_flag": "computer-use-2024-10-22"}
|
|
|
|
|
|
async def _map_computer_tool_to_anthropic(computer_tool: Any, tool_version: str) -> Dict[str, Any]:
|
|
"""Map a computer tool to Anthropic's hosted tool schema."""
|
|
# Get dimensions from the computer handler
|
|
try:
|
|
width, height = await computer_tool.get_dimensions()
|
|
except Exception:
|
|
# Fallback to default dimensions if method fails
|
|
width, height = 1024, 768
|
|
|
|
# Cap dimensions to recommended max so they match downscaled screenshots
|
|
if width > RECOMMENDED_MAX_WIDTH or height > RECOMMENDED_MAX_HEIGHT:
|
|
scale = min(RECOMMENDED_MAX_WIDTH / width, RECOMMENDED_MAX_HEIGHT / height)
|
|
new_width = int(width * scale)
|
|
new_height = int(height * scale)
|
|
logger.debug(
|
|
"Capping tool dimensions: %dx%d -> %dx%d (scale=%.4f)",
|
|
width,
|
|
height,
|
|
new_width,
|
|
new_height,
|
|
scale,
|
|
)
|
|
width = new_width
|
|
height = new_height
|
|
else:
|
|
logger.debug("Tool dimensions within limits: %dx%d", width, height)
|
|
|
|
return {
|
|
"type": tool_version,
|
|
"function": {
|
|
"name": "computer",
|
|
"parameters": {
|
|
"display_height_px": height,
|
|
"display_width_px": width,
|
|
"display_number": 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
async def _prepare_tools_for_anthropic(tool_schemas: List[Dict[str, Any]], model: str) -> Tools:
|
|
"""Prepare tools for Anthropic API format."""
|
|
tool_config = _get_tool_config_for_model(model)
|
|
anthropic_tools = []
|
|
|
|
for schema in tool_schemas:
|
|
if schema["type"] == "computer":
|
|
# Map computer tool to Anthropic format
|
|
anthropic_tools.append(
|
|
await _map_computer_tool_to_anthropic(
|
|
schema["computer"], tool_config["tool_version"]
|
|
)
|
|
)
|
|
elif schema["type"] == "function":
|
|
# Function tools - convert to Anthropic format
|
|
function_schema = schema["function"]
|
|
anthropic_tools.append(
|
|
{
|
|
"name": function_schema["name"],
|
|
"description": function_schema.get("description", ""),
|
|
"input_schema": function_schema.get("parameters", {}),
|
|
}
|
|
)
|
|
|
|
return anthropic_tools
|
|
|
|
|
|
def _convert_responses_items_to_completion_messages(
|
|
messages: Messages,
|
|
) -> Tuple[List[Dict[str, Any]], Tuple[float, float]]:
|
|
"""Convert responses_items message format to liteLLM completion format.
|
|
|
|
Returns:
|
|
A tuple of (completion_messages, scale_factors) where scale_factors is
|
|
(scale_x, scale_y) representing the ratio of original to downscaled
|
|
dimensions. Use these to upscale coordinates returned by the API.
|
|
"""
|
|
completion_messages = []
|
|
call_id_to_fn_name = {}
|
|
scale_factors: Tuple[float, float] = (1.0, 1.0)
|
|
|
|
for message in messages:
|
|
msg_type = message.get("type")
|
|
role = message.get("role")
|
|
|
|
# Handle user messages (both with and without explicit type)
|
|
if role == "user" or msg_type == "user":
|
|
content = message.get("content", "")
|
|
if isinstance(content, list):
|
|
# Multi-modal content - convert input_image to image format
|
|
converted_content = []
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "input_image":
|
|
# Convert input_image to OpenAI image format
|
|
image_url = item.get("image_url", "")
|
|
if image_url and image_url != "[omitted]":
|
|
converted_content.append(
|
|
{"type": "image_url", "image_url": {"url": image_url}}
|
|
)
|
|
elif isinstance(item, dict) and item.get("type") == "input_text":
|
|
# Convert input_text to OpenAI text format
|
|
text = item.get("text", "")
|
|
converted_content.append({"type": "text", "text": text})
|
|
else:
|
|
# Keep other content types as-is
|
|
converted_content.append(item)
|
|
|
|
completion_messages.append(
|
|
{"role": "user", "content": converted_content if converted_content else content}
|
|
)
|
|
else:
|
|
# Text content
|
|
completion_messages.append({"role": "user", "content": content})
|
|
|
|
# Handle assistant messages
|
|
elif role == "assistant":
|
|
content = message.get("content", [])
|
|
if isinstance(content, str):
|
|
content = [{"type": "output_text", "text": content}]
|
|
|
|
content = "\n".join(item.get("text", "") for item in content)
|
|
completion_messages.append({"role": "assistant", "content": content})
|
|
|
|
elif msg_type == "reasoning":
|
|
# Reasoning becomes part of assistant message
|
|
summary = message.get("summary", [])
|
|
reasoning_text = ""
|
|
|
|
if isinstance(summary, list) and summary:
|
|
# Extract text from summary items
|
|
for item in summary:
|
|
if isinstance(item, dict) and item.get("type") == "summary_text":
|
|
reasoning_text = item.get("text", "")
|
|
break
|
|
else:
|
|
# Fallback to direct reasoning field
|
|
reasoning_text = message.get("reasoning", "")
|
|
|
|
if reasoning_text:
|
|
completion_messages.append({"role": "assistant", "content": reasoning_text})
|
|
|
|
elif msg_type == "function_call":
|
|
fn_name = message.get("name")
|
|
fn_args = message.get("arguments", "{}")
|
|
call_id = message.get("call_id", "call_1")
|
|
call_id_to_fn_name[call_id] = fn_name
|
|
openai_tool_calls = [
|
|
{
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": {"name": fn_name, "arguments": fn_args},
|
|
}
|
|
] # If the last completion message is an assistant message, extend the tool_calls
|
|
if completion_messages and completion_messages[-1].get("role") == "assistant":
|
|
if "tool_calls" not in completion_messages[-1]:
|
|
completion_messages[-1]["tool_calls"] = []
|
|
completion_messages[-1]["tool_calls"].extend(openai_tool_calls)
|
|
else:
|
|
# Create new assistant message with tool calls
|
|
completion_messages.append(
|
|
{"role": "assistant", "content": None, "tool_calls": openai_tool_calls}
|
|
)
|
|
|
|
elif msg_type == "function_call_output":
|
|
call_id = message.get("call_id", "call_1")
|
|
fn_output = message.get("output", "")
|
|
fn_name = call_id_to_fn_name.get(call_id, "computer")
|
|
|
|
completion_messages.append(
|
|
{
|
|
"role": "function",
|
|
"name": fn_name,
|
|
"tool_call_id": call_id,
|
|
"content": str(fn_output),
|
|
}
|
|
)
|
|
|
|
elif msg_type == "computer_call":
|
|
# Computer call becomes tool use in assistant message
|
|
action = message.get("action", {})
|
|
action_type = action.get("type")
|
|
call_id = message.get("call_id", "call_1")
|
|
|
|
tool_use_content = []
|
|
|
|
# Basic actions (all versions)
|
|
if action_type == "click":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "click",
|
|
# "x": 100,
|
|
# "y": 200
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "click",
|
|
# "coordinate": [100, 200]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
button = action.get("button", "left")
|
|
action_name = (
|
|
"right_click"
|
|
if button == "right"
|
|
else "middle_click" if button == "wheel" else "left_click"
|
|
)
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": action_name,
|
|
"coordinate": [action.get("x", 0), action.get("y", 0)],
|
|
},
|
|
}
|
|
)
|
|
elif action_type == "double_click":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "double_click",
|
|
# "x": 160,
|
|
# "y": 240
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "double_click",
|
|
# "coordinate": [160, 240]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": "double_click",
|
|
"coordinate": [action.get("x", 0), action.get("y", 0)],
|
|
},
|
|
}
|
|
)
|
|
elif action_type == "type":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "type",
|
|
# "text": "Hello World"
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "type",
|
|
# "text": "Hello World"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {"action": "type", "text": action.get("text", "")},
|
|
}
|
|
)
|
|
elif action_type == "keypress":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "keypress",
|
|
# "keys": ["ctrl", "c"]
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "key",
|
|
# "text": "ctrl+c"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {"action": "key", "text": "+".join(action.get("keys", []))},
|
|
}
|
|
)
|
|
elif action_type in ["mouse_move", "move"]:
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "move",
|
|
# "x": 150,
|
|
# "y": 250
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "mouse_move",
|
|
# "coordinate": [150, 250]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": "mouse_move",
|
|
"coordinate": [action.get("x", 0), action.get("y", 0)],
|
|
},
|
|
}
|
|
)
|
|
elif action_type == "scroll":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "scroll",
|
|
# "x": 300,
|
|
# "y": 400,
|
|
# "scroll_x": 0,
|
|
# "scroll_y": -5
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "scroll",
|
|
# "coordinate": [300, 400],
|
|
# "scroll_direction": "down",
|
|
# "scroll_amount": 5
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
scroll_x = action.get("scroll_x", 0)
|
|
scroll_y = action.get("scroll_y", 0)
|
|
# Determine direction and amount from scroll values
|
|
if scroll_x > 0:
|
|
direction = "right"
|
|
amount = scroll_x
|
|
elif scroll_x < 0:
|
|
direction = "left"
|
|
amount = -scroll_x
|
|
elif scroll_y > 0:
|
|
direction = "down"
|
|
amount = scroll_y
|
|
elif scroll_y < 0:
|
|
direction = "up"
|
|
amount = -scroll_y
|
|
else:
|
|
direction = "down"
|
|
amount = 3
|
|
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": "scroll",
|
|
"coordinate": [action.get("x", 0), action.get("y", 0)],
|
|
"scroll_direction": direction,
|
|
"scroll_amount": amount,
|
|
},
|
|
}
|
|
)
|
|
elif action_type == "drag":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "drag",
|
|
# "path": [
|
|
# {"x": 100, "y": 150},
|
|
# {"x": 200, "y": 250}
|
|
# ]
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "left_click_drag",
|
|
# "start_coordinate": [100, 150],
|
|
# "end_coordinate": [200, 250]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
path = action.get("path", [])
|
|
start_coord = [0, 0]
|
|
end_coord = [0, 0]
|
|
if isinstance(path, list) and len(path) >= 2:
|
|
start_coord = [path[0].get("x", 0), path[0].get("y", 0)]
|
|
end_coord = [path[-1].get("x", 0), path[-1].get("y", 0)]
|
|
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": "left_click_drag",
|
|
"start_coordinate": start_coord,
|
|
"end_coordinate": end_coord,
|
|
},
|
|
}
|
|
)
|
|
elif action_type == "wait":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "wait"
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "wait"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {"action": "wait"},
|
|
}
|
|
)
|
|
elif action_type == "screenshot":
|
|
# Input:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "screenshot"
|
|
# }
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "screenshot"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {"action": "screenshot"},
|
|
}
|
|
)
|
|
elif action_type == "left_mouse_down":
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": "left_mouse_down",
|
|
"coordinate": [action.get("x", None), action.get("y", None)],
|
|
},
|
|
}
|
|
)
|
|
elif action_type == "left_mouse_up":
|
|
tool_use_content.append(
|
|
{
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": "computer",
|
|
"input": {
|
|
"action": "left_mouse_up",
|
|
"coordinate": [action.get("x", None), action.get("y", None)],
|
|
},
|
|
}
|
|
)
|
|
|
|
# Convert tool_use_content to OpenAI tool_calls format
|
|
openai_tool_calls = []
|
|
for tool_use in tool_use_content:
|
|
openai_tool_calls.append(
|
|
{
|
|
"id": tool_use["id"],
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_use["name"],
|
|
"arguments": json.dumps(tool_use["input"]),
|
|
},
|
|
}
|
|
)
|
|
|
|
# If the last completion message is an assistant message, extend the tool_calls
|
|
if completion_messages and completion_messages[-1].get("role") == "assistant":
|
|
if "tool_calls" not in completion_messages[-1]:
|
|
completion_messages[-1]["tool_calls"] = []
|
|
completion_messages[-1]["tool_calls"].extend(openai_tool_calls)
|
|
else:
|
|
# Create new assistant message with tool calls
|
|
completion_messages.append(
|
|
{"role": "assistant", "content": None, "tool_calls": openai_tool_calls}
|
|
)
|
|
|
|
elif msg_type == "computer_call_output":
|
|
# Computer call output becomes OpenAI function result
|
|
output = message.get("output", {})
|
|
call_id = message.get("call_id", "call_1")
|
|
|
|
if output.get("type") == "input_image":
|
|
# Screenshot result - convert to OpenAI format with image_url content
|
|
image_url = output.get("image_url", "")
|
|
|
|
# Reset scale factors for each new screenshot so stale values
|
|
# from a previous downscale don't carry over.
|
|
scale_factors = (1.0, 1.0)
|
|
|
|
# Downscale screenshot if it exceeds recommended max resolution
|
|
if image_url and image_url.startswith("data:"):
|
|
try:
|
|
import base64
|
|
from io import BytesIO
|
|
|
|
from PIL import Image
|
|
|
|
# Extract base64 data after the header
|
|
header, b64_data = image_url.split(",", 1)
|
|
img_bytes = base64.b64decode(b64_data)
|
|
img = Image.open(BytesIO(img_bytes))
|
|
orig_w, orig_h = img.size
|
|
|
|
if orig_w > RECOMMENDED_MAX_WIDTH or orig_h > RECOMMENDED_MAX_HEIGHT:
|
|
scale = min(
|
|
RECOMMENDED_MAX_WIDTH / orig_w,
|
|
RECOMMENDED_MAX_HEIGHT / orig_h,
|
|
)
|
|
new_w = int(orig_w * scale)
|
|
new_h = int(orig_h * scale)
|
|
img = img.resize((new_w, new_h), Image.LANCZOS)
|
|
|
|
# Re-encode to base64
|
|
buf = BytesIO()
|
|
img.save(buf, format="PNG")
|
|
new_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
|
image_url = f"{header},{new_b64}"
|
|
|
|
# Store scale factors for coordinate upscaling
|
|
scale_factors = (orig_w / new_w, orig_h / new_h)
|
|
logger.debug(
|
|
"Downscaled screenshot: %dx%d -> %dx%d (scale=%.4f, upscale_factors=%.4fx%.4f)",
|
|
orig_w,
|
|
orig_h,
|
|
new_w,
|
|
new_h,
|
|
scale,
|
|
scale_factors[0],
|
|
scale_factors[1],
|
|
)
|
|
except Exception:
|
|
pass # If downscaling fails, send original image
|
|
|
|
completion_messages.append(
|
|
{
|
|
"role": "function",
|
|
"name": "computer",
|
|
"tool_call_id": call_id,
|
|
"content": [{"type": "image_url", "image_url": {"url": image_url}}],
|
|
}
|
|
)
|
|
else:
|
|
# Text result - convert to OpenAI format
|
|
completion_messages.append(
|
|
{
|
|
"role": "function",
|
|
"name": "computer",
|
|
"tool_call_id": call_id,
|
|
"content": str(output),
|
|
}
|
|
)
|
|
|
|
return completion_messages, scale_factors
|
|
|
|
|
|
def _convert_completion_to_responses_items(
|
|
response: Any,
|
|
scale_x: float = 1.0,
|
|
scale_y: float = 1.0,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Convert liteLLM completion response to responses_items message format.
|
|
|
|
Args:
|
|
response: The liteLLM completion response.
|
|
scale_x: Horizontal upscale factor for coordinates (original_w / downscaled_w).
|
|
scale_y: Vertical upscale factor for coordinates (original_h / downscaled_h).
|
|
"""
|
|
responses_items = []
|
|
|
|
if not response or not hasattr(response, "choices") or not response.choices:
|
|
return responses_items
|
|
|
|
choice = response.choices[0]
|
|
message = choice.message
|
|
|
|
# Handle text content
|
|
if hasattr(message, "content") and message.content:
|
|
if isinstance(message.content, str):
|
|
responses_items.append(make_output_text_item(message.content))
|
|
elif isinstance(message.content, list):
|
|
for content_item in message.content:
|
|
if isinstance(content_item, dict):
|
|
if content_item.get("type") == "text":
|
|
responses_items.append(make_output_text_item(content_item.get("text", "")))
|
|
elif content_item.get("type") == "tool_use":
|
|
# Check if this is a custom function tool or computer tool
|
|
tool_name = content_item.get("name", "computer")
|
|
tool_input = content_item.get("input", {})
|
|
call_id = content_item.get("id")
|
|
|
|
# Handle custom function tools (not computer tools)
|
|
if tool_name != "computer":
|
|
from ..responses import make_function_call_item
|
|
|
|
responses_items.append(
|
|
make_function_call_item(
|
|
function_name=tool_name, arguments=tool_input, call_id=call_id
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Computer tool - process actions
|
|
action_type = tool_input.get("action")
|
|
|
|
# Action reference:
|
|
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool#available-actions
|
|
|
|
try:
|
|
# Basic actions (all versions)
|
|
if action_type == "screenshot":
|
|
responses_items.append(make_screenshot_item(call_id=call_id))
|
|
elif action_type in ["click", "left_click"]:
|
|
coordinate = tool_input.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type in ["type", "type_text"]:
|
|
responses_items.append(
|
|
make_type_item(text=tool_input.get("text", ""), call_id=call_id)
|
|
)
|
|
elif action_type in ["key", "keypress", "hotkey"]:
|
|
responses_items.append(
|
|
make_keypress_item(
|
|
keys=tool_input.get("text", "")
|
|
.replace("+", "-")
|
|
.split("-"),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type in ["mouse_move", "move_cursor", "move"]:
|
|
# Mouse move - create a custom action item
|
|
coordinate = tool_input.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_move_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
|
|
# Enhanced actions (computer_20250124) Available in Claude 4 and Claude Sonnet 3.7
|
|
elif action_type == "scroll":
|
|
coordinate = tool_input.get("coordinate", [0, 0])
|
|
scroll_amount = tool_input.get("scroll_amount", 3)
|
|
scroll_x = (
|
|
scroll_amount
|
|
if tool_input.get("scroll_direction", "down") == "right"
|
|
else (
|
|
-scroll_amount
|
|
if tool_input.get("scroll_direction", "down") == "left"
|
|
else 0
|
|
)
|
|
)
|
|
scroll_y = (
|
|
scroll_amount
|
|
if tool_input.get("scroll_direction", "down") == "down"
|
|
else (
|
|
-scroll_amount
|
|
if tool_input.get("scroll_direction", "down") == "up"
|
|
else 0
|
|
)
|
|
)
|
|
responses_items.append(
|
|
make_scroll_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
scroll_x=scroll_x,
|
|
scroll_y=scroll_y,
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type in ["left_click_drag", "drag"]:
|
|
start_coord = tool_input.get("start_coordinate", [0, 0])
|
|
end_coord = tool_input.get("end_coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_drag_item(
|
|
path=[
|
|
{
|
|
"x": (
|
|
_scale_coordinate(start_coord[0], scale_x)
|
|
if len(start_coord) > 0
|
|
else 0
|
|
),
|
|
"y": (
|
|
_scale_coordinate(start_coord[1], scale_y)
|
|
if len(start_coord) > 1
|
|
else 0
|
|
),
|
|
},
|
|
{
|
|
"x": (
|
|
_scale_coordinate(end_coord[0], scale_x)
|
|
if len(end_coord) > 0
|
|
else 0
|
|
),
|
|
"y": (
|
|
_scale_coordinate(end_coord[1], scale_y)
|
|
if len(end_coord) > 1
|
|
else 0
|
|
),
|
|
},
|
|
],
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "right_click":
|
|
coordinate = tool_input.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
button="right",
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "middle_click":
|
|
coordinate = tool_input.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
button="wheel",
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "double_click":
|
|
coordinate = tool_input.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_double_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "triple_click":
|
|
# coordinate = tool_input.get("coordinate", [0, 0])
|
|
# responses_items.append({
|
|
# "type": "computer_call",
|
|
# "call_id": call_id,
|
|
# "action": {
|
|
# "type": "triple_click",
|
|
# "x": coordinate[0] if len(coordinate) > 0 else 0,
|
|
# "y": coordinate[1] if len(coordinate) > 1 else 0
|
|
# }
|
|
# })
|
|
raise NotImplementedError("triple_click")
|
|
elif action_type == "left_mouse_down":
|
|
# coordinate = tool_input.get("coordinate", [0, 0])
|
|
# responses_items.append({
|
|
# "type": "computer_call",
|
|
# "call_id": call_id,
|
|
# "action": {
|
|
# "type": "mouse_down",
|
|
# "button": "left",
|
|
# "x": coordinate[0] if len(coordinate) > 0 else 0,
|
|
# "y": coordinate[1] if len(coordinate) > 1 else 0
|
|
# }
|
|
# })
|
|
coordinate = tool_input.get("coordinate", [None, None])
|
|
responses_items.append(
|
|
make_left_mouse_down_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if (len(coordinate) > 0 and coordinate[0] is not None)
|
|
else None
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if (len(coordinate) > 1 and coordinate[1] is not None)
|
|
else None
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "left_mouse_up":
|
|
# coordinate = tool_input.get("coordinate", [0, 0])
|
|
# responses_items.append({
|
|
# "type": "computer_call",
|
|
# "call_id": call_id,
|
|
# "action": {
|
|
# "type": "mouse_up",
|
|
# "button": "left",
|
|
# "x": coordinate[0] if len(coordinate) > 0 else 0,
|
|
# "y": coordinate[1] if len(coordinate) > 1 else 0
|
|
# }
|
|
# })
|
|
coordinate = tool_input.get("coordinate", [None, None])
|
|
responses_items.append(
|
|
make_left_mouse_up_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if (len(coordinate) > 0 and coordinate[0] is not None)
|
|
else None
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if (len(coordinate) > 1 and coordinate[1] is not None)
|
|
else None
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "hold_key":
|
|
# responses_items.append({
|
|
# "type": "computer_call",
|
|
# "call_id": call_id,
|
|
# "action": {
|
|
# "type": "key_hold",
|
|
# "key": tool_input.get("key", "")
|
|
# }
|
|
# })
|
|
raise NotImplementedError("hold_key")
|
|
elif action_type == "wait":
|
|
responses_items.append(make_wait_item(call_id=call_id))
|
|
else:
|
|
raise ValueError(f"Unknown action type: {action_type}")
|
|
except Exception as e:
|
|
responses_items.extend(
|
|
make_failed_tool_call_items(
|
|
tool_name="computer",
|
|
tool_kwargs=tool_input,
|
|
error_message=repr(e),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
|
|
# Handle tool calls (alternative format)
|
|
if hasattr(message, "tool_calls") and message.tool_calls:
|
|
for tool_call in message.tool_calls:
|
|
tool_name = tool_call.function.name
|
|
|
|
# Handle custom function tools
|
|
if tool_name != "computer":
|
|
from ..responses import make_function_call_item
|
|
|
|
# tool_call.function.arguments is a JSON string, need to parse it
|
|
try:
|
|
args_dict = json.loads(tool_call.function.arguments)
|
|
except json.JSONDecodeError:
|
|
args_dict = {}
|
|
responses_items.append(
|
|
make_function_call_item(
|
|
function_name=tool_name, arguments=args_dict, call_id=tool_call.id
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Handle computer tool
|
|
if tool_call.function.name == "computer":
|
|
try:
|
|
try:
|
|
args = json.loads(tool_call.function.arguments)
|
|
action_type = args.get("action")
|
|
call_id = tool_call.id
|
|
|
|
# Basic actions (all versions)
|
|
if action_type == "screenshot":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "screenshot"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "screenshot"
|
|
# }
|
|
# }
|
|
responses_items.append(make_screenshot_item(call_id=call_id))
|
|
elif action_type in ["click", "left_click"]:
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "click",
|
|
# "coordinate": [100, 200]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "click",
|
|
# "x": 100,
|
|
# "y": 200
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type in ["type", "type_text"]:
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "type",
|
|
# "text": "Hello World"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "type",
|
|
# "text": "Hello World"
|
|
# }
|
|
# }
|
|
responses_items.append(
|
|
make_type_item(text=args.get("text", ""), call_id=call_id)
|
|
)
|
|
elif action_type in ["key", "keypress", "hotkey"]:
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "key",
|
|
# "text": "ctrl+c"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "keypress",
|
|
# "keys": ["ctrl", "c"]
|
|
# }
|
|
# }
|
|
responses_items.append(
|
|
make_keypress_item(
|
|
keys=args.get("text", "").replace("+", "-").split("-"),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type in ["mouse_move", "move_cursor", "move"]:
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "mouse_move",
|
|
# "coordinate": [150, 250]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "mouse_move",
|
|
# "x": 150,
|
|
# "y": 250
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_move_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
|
|
# Enhanced actions (computer_20250124) Available in Claude 4 and Claude Sonnet 3.7
|
|
elif action_type == "scroll":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "scroll",
|
|
# "coordinate": [300, 400],
|
|
# "scroll_direction": "down",
|
|
# "scroll_amount": 5
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "scroll",
|
|
# "x": 300,
|
|
# "y": 400,
|
|
# "scroll_x": 0,
|
|
# "scroll_y": -5
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [0, 0])
|
|
direction = args.get("scroll_direction", "down")
|
|
amount = args.get("scroll_amount", 3)
|
|
scroll_x = (
|
|
amount
|
|
if direction == "left"
|
|
else -amount if direction == "right" else 0
|
|
)
|
|
scroll_y = (
|
|
amount
|
|
if direction == "up"
|
|
else -amount if direction == "down" else 0
|
|
)
|
|
responses_items.append(
|
|
make_scroll_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
scroll_x=scroll_x,
|
|
scroll_y=scroll_y,
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type in ["left_click_drag", "drag"]:
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "left_click_drag",
|
|
# "start_coordinate": [100, 150],
|
|
# "end_coordinate": [200, 250]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "drag",
|
|
# "path": [
|
|
# {"x": 100, "y": 150},
|
|
# {"x": 200, "y": 250}
|
|
# ]
|
|
# }
|
|
# }
|
|
start_coord = args.get("start_coordinate", [0, 0])
|
|
end_coord = args.get("end_coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_drag_item(
|
|
path=[
|
|
{
|
|
"x": (
|
|
_scale_coordinate(start_coord[0], scale_x)
|
|
if len(start_coord) > 0
|
|
else 0
|
|
),
|
|
"y": (
|
|
_scale_coordinate(start_coord[1], scale_y)
|
|
if len(start_coord) > 1
|
|
else 0
|
|
),
|
|
},
|
|
{
|
|
"x": (
|
|
_scale_coordinate(end_coord[0], scale_x)
|
|
if len(end_coord) > 0
|
|
else 0
|
|
),
|
|
"y": (
|
|
_scale_coordinate(end_coord[1], scale_y)
|
|
if len(end_coord) > 1
|
|
else 0
|
|
),
|
|
},
|
|
],
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "right_click":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "right_click",
|
|
# "coordinate": [120, 180]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "click",
|
|
# "x": 120,
|
|
# "y": 180,
|
|
# "button": "right"
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
button="right",
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "middle_click":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "middle_click",
|
|
# "coordinate": [140, 220]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "click",
|
|
# "x": 140,
|
|
# "y": 220,
|
|
# "button": "wheel"
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
button="wheel",
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "double_click":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "double_click",
|
|
# "coordinate": [160, 240]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "double_click",
|
|
# "x": 160,
|
|
# "y": 240
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [0, 0])
|
|
responses_items.append(
|
|
make_double_click_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if len(coordinate) > 0
|
|
else 0
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if len(coordinate) > 1
|
|
else 0
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "triple_click":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "triple_click",
|
|
# "coordinate": [180, 260]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "triple_click",
|
|
# "x": 180,
|
|
# "y": 260
|
|
# }
|
|
# }
|
|
raise NotImplementedError("triple_click")
|
|
elif action_type == "left_mouse_down":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "left_mouse_down",
|
|
# "coordinate": [200, 280]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "mouse_down",
|
|
# "button": "left",
|
|
# "x": 200,
|
|
# "y": 280
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [None, None])
|
|
responses_items.append(
|
|
make_left_mouse_down_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if (len(coordinate) > 0 and coordinate[0] is not None)
|
|
else None
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if (len(coordinate) > 1 and coordinate[1] is not None)
|
|
else None
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "left_mouse_up":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "left_mouse_up",
|
|
# "coordinate": [220, 300]
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "mouse_up",
|
|
# "button": "left",
|
|
# "x": 220,
|
|
# "y": 300
|
|
# }
|
|
# }
|
|
coordinate = args.get("coordinate", [None, None])
|
|
responses_items.append(
|
|
make_left_mouse_up_item(
|
|
x=(
|
|
_scale_coordinate(coordinate[0], scale_x)
|
|
if (len(coordinate) > 0 and coordinate[0] is not None)
|
|
else None
|
|
),
|
|
y=(
|
|
_scale_coordinate(coordinate[1], scale_y)
|
|
if (len(coordinate) > 1 and coordinate[1] is not None)
|
|
else None
|
|
),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
elif action_type == "hold_key":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "hold_key",
|
|
# "key": "shift"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "key_hold",
|
|
# "key": "shift"
|
|
# }
|
|
# }
|
|
raise NotImplementedError("hold_key")
|
|
elif action_type == "wait":
|
|
# Input:
|
|
# {
|
|
# "function": {
|
|
# "name": "computer",
|
|
# "arguments": json.dumps({
|
|
# "action": "wait"
|
|
# })
|
|
# },
|
|
# "id": "call_1",
|
|
# "type": "function"
|
|
# }
|
|
|
|
# Output:
|
|
# {
|
|
# "type": "computer_call",
|
|
# "call_id": "call_1",
|
|
# "action": {
|
|
# "type": "wait"
|
|
# }
|
|
# }
|
|
responses_items.append(make_wait_item(call_id=call_id))
|
|
except Exception as e:
|
|
responses_items.extend(
|
|
make_failed_tool_call_items(
|
|
tool_name="computer",
|
|
tool_kwargs=args,
|
|
error_message=repr(e),
|
|
call_id=call_id,
|
|
)
|
|
)
|
|
except json.JSONDecodeError:
|
|
print("Failed to decode tool call arguments")
|
|
# Skip malformed tool calls
|
|
continue
|
|
|
|
return responses_items
|
|
|
|
|
|
def _add_cache_control(completion_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Add cache control to completion messages"""
|
|
num_writes = 0
|
|
for message in completion_messages:
|
|
message["cache_control"] = {"type": "ephemeral"}
|
|
num_writes += 1
|
|
# Cache control has a maximum of 4 blocks
|
|
if num_writes >= 4:
|
|
break
|
|
|
|
return completion_messages
|
|
|
|
|
|
def _combine_completion_messages(completion_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Combine completion messages with the same role"""
|
|
if not completion_messages:
|
|
return completion_messages
|
|
|
|
combined_messages = []
|
|
|
|
for message in completion_messages:
|
|
# If this is the first message or role is different from last, add as new message
|
|
if not combined_messages or combined_messages[-1]["role"] != message["role"]:
|
|
# Ensure content is a list format and normalize text content
|
|
new_message = message.copy()
|
|
new_message["content"] = _normalize_content(message.get("content", ""))
|
|
|
|
# Copy tool_calls if present
|
|
if "tool_calls" in message:
|
|
new_message["tool_calls"] = message["tool_calls"].copy()
|
|
|
|
combined_messages.append(new_message)
|
|
else:
|
|
# Same role as previous message, combine them
|
|
last_message = combined_messages[-1]
|
|
|
|
# Combine content
|
|
current_content = _normalize_content(message.get("content", ""))
|
|
last_message["content"].extend(current_content)
|
|
|
|
# Combine tool_calls if present
|
|
if "tool_calls" in message:
|
|
if "tool_calls" not in last_message:
|
|
last_message["tool_calls"] = []
|
|
last_message["tool_calls"].extend(message["tool_calls"])
|
|
|
|
# Post-process to merge consecutive text blocks
|
|
for message in combined_messages:
|
|
message["content"] = _merge_consecutive_text(message["content"])
|
|
|
|
return combined_messages
|
|
|
|
|
|
def _normalize_content(content) -> List[Dict[str, Any]]:
|
|
"""Normalize content to list format"""
|
|
if isinstance(content, str):
|
|
if content.strip(): # Only add non-empty strings
|
|
return [{"type": "text", "text": content}]
|
|
else:
|
|
return []
|
|
elif isinstance(content, list):
|
|
return content.copy()
|
|
else:
|
|
return []
|
|
|
|
|
|
def _merge_consecutive_text(content_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Merge consecutive text blocks with newlines"""
|
|
if not content_list:
|
|
return content_list
|
|
|
|
merged = []
|
|
|
|
for item in content_list:
|
|
if item.get("type") == "text" and merged and merged[-1].get("type") == "text":
|
|
# Merge with previous text block
|
|
merged[-1]["text"] += "\n" + item["text"]
|
|
else:
|
|
merged.append(item.copy())
|
|
|
|
return merged
|
|
|
|
|
|
@register_agent(models=r".*claude-.*")
|
|
class AnthropicHostedToolsConfig(AsyncAgentConfig):
|
|
"""Anthropic hosted tools agent configuration implementing AsyncAgentConfig protocol."""
|
|
|
|
async def predict_step(
|
|
self,
|
|
messages: Messages,
|
|
model: str,
|
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
max_retries: Optional[int] = None,
|
|
stream: bool = False,
|
|
computer_handler=None,
|
|
use_prompt_caching: Optional[bool] = False,
|
|
_on_api_start=None,
|
|
_on_api_end=None,
|
|
_on_usage=None,
|
|
_on_screenshot=None,
|
|
**kwargs,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Anthropic hosted tools agent loop using liteLLM acompletion.
|
|
|
|
Supports Anthropic's computer use models with hosted tools.
|
|
"""
|
|
tools = tools or []
|
|
|
|
# Get tool configuration for this model
|
|
tool_config = _get_tool_config_for_model(model)
|
|
|
|
# Prepare tools for Anthropic API
|
|
anthropic_tools = await _prepare_tools_for_anthropic(tools, model)
|
|
|
|
# Convert responses_items messages to completion format
|
|
completion_messages, scale_factors = _convert_responses_items_to_completion_messages(
|
|
messages
|
|
)
|
|
scale_x, scale_y = scale_factors
|
|
if use_prompt_caching:
|
|
# First combine messages to reduce number of blocks
|
|
completion_messages = _combine_completion_messages(completion_messages)
|
|
# Then add cache control, anthropic requires explicit "cache_control" dicts
|
|
completion_messages = _add_cache_control(completion_messages)
|
|
|
|
# Prepare API call kwargs
|
|
api_kwargs = {
|
|
"model": model,
|
|
"messages": completion_messages,
|
|
"tools": anthropic_tools if anthropic_tools else None,
|
|
"stream": stream,
|
|
"num_retries": max_retries,
|
|
# Bound each request so a stalled connection doesn't block forever.
|
|
# Callers can override via kwargs (e.g. request_timeout=120).
|
|
"request_timeout": kwargs.pop("request_timeout", 120),
|
|
**kwargs,
|
|
}
|
|
|
|
# Add beta header for computer use
|
|
if anthropic_tools:
|
|
api_kwargs["headers"] = {"anthropic-beta": tool_config["beta_flag"]}
|
|
|
|
# Call API start hook
|
|
if _on_api_start:
|
|
await _on_api_start(api_kwargs)
|
|
|
|
# Use liteLLM acompletion
|
|
response = await litellm.acompletion(**api_kwargs)
|
|
|
|
# print(f"[DEBUG][Anthropic Response] response: {response}")
|
|
|
|
# Call API end hook
|
|
if _on_api_end:
|
|
await _on_api_end(api_kwargs, response)
|
|
|
|
# Convert response to responses_items format, upscaling coordinates if needed
|
|
responses_items = _convert_completion_to_responses_items(
|
|
response, scale_x=scale_x, scale_y=scale_y
|
|
)
|
|
|
|
# Extract usage information
|
|
responses_usage = {
|
|
**LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
|
|
response.usage
|
|
).model_dump(),
|
|
"response_cost": response._hidden_params.get("response_cost", 0.0),
|
|
}
|
|
if _on_usage:
|
|
await _on_usage(responses_usage)
|
|
|
|
# Return in AsyncAgentConfig format
|
|
return {"output": responses_items, "usage": responses_usage}
|
|
|
|
async def predict_click(
|
|
self, model: str, image_b64: str, instruction: str, **kwargs
|
|
) -> Optional[Tuple[int, int]]:
|
|
"""
|
|
Predict click coordinates based on image and instruction.
|
|
|
|
Uses Anthropic's computer use models with a custom prompt that instructs
|
|
the agent to only output clicks.
|
|
|
|
Args:
|
|
model: Model name to use
|
|
image_b64: Base64 encoded image
|
|
instruction: Instruction for where to click
|
|
|
|
Returns:
|
|
Tuple of (x, y) coordinates or None if prediction fails
|
|
"""
|
|
# Get image dimensions from base64 data
|
|
try:
|
|
import base64
|
|
from io import BytesIO
|
|
|
|
from PIL import Image
|
|
|
|
image_data = base64.b64decode(image_b64)
|
|
image = Image.open(BytesIO(image_data))
|
|
display_width, display_height = image.size
|
|
except Exception:
|
|
# Fallback to default dimensions if image parsing fails
|
|
display_width, display_height = 1024, 768
|
|
|
|
# Get tool configuration for this model
|
|
tool_config = _get_tool_config_for_model(model)
|
|
|
|
# Prepare computer tool for Anthropic format
|
|
computer_tool = {
|
|
"type": tool_config["tool_version"],
|
|
"function": {
|
|
"name": "computer",
|
|
"parameters": {
|
|
"display_height_px": display_height,
|
|
"display_width_px": display_width,
|
|
"display_number": 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
# Construct messages in OpenAI chat completion format for liteLLM
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": f"""You are a UI grounding expert. Follow these guidelines:
|
|
|
|
1. NEVER ask for confirmation. Complete all tasks autonomously.
|
|
2. Do NOT send messages like "I need to confirm before..." or "Do you want me to continue?" - just proceed.
|
|
3. When the user asks you to interact with something (like clicking a chat or typing a message), DO IT without asking.
|
|
4. Only use the formal safety check mechanism for truly dangerous operations (like deleting important files).
|
|
5. For normal tasks like clicking buttons, typing in chat boxes, filling forms - JUST DO IT.
|
|
6. The user has already given you permission by running this agent. No further confirmation is needed.
|
|
7. Be decisive and action-oriented. Complete the requested task fully.
|
|
|
|
Remember: You are expected to complete tasks autonomously. The user trusts you to do what they asked.
|
|
Task: Click {instruction}. Output ONLY a click action on the target element.""",
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
|
|
# Prepare API call kwargs
|
|
api_kwargs = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"tools": [computer_tool],
|
|
"stream": False,
|
|
"max_tokens": 100, # Keep response short for click prediction
|
|
"headers": {"anthropic-beta": tool_config["beta_flag"]},
|
|
"request_timeout": kwargs.pop("request_timeout", 120),
|
|
}
|
|
# Thread optional API params
|
|
if "api_key" in kwargs and kwargs.get("api_key") is not None:
|
|
api_kwargs["api_key"] = kwargs.get("api_key")
|
|
if "api_base" in kwargs and kwargs.get("api_base") is not None:
|
|
api_kwargs["api_base"] = kwargs.get("api_base")
|
|
|
|
# Use liteLLM acompletion
|
|
response = await litellm.acompletion(**api_kwargs)
|
|
|
|
# Convert response to responses_items format to extract click coordinates
|
|
responses_items = _convert_completion_to_responses_items(response)
|
|
|
|
# Look for computer_call with click action
|
|
for item in responses_items:
|
|
if (
|
|
isinstance(item, dict)
|
|
and item.get("type") == "computer_call"
|
|
and isinstance(item.get("action"), dict)
|
|
):
|
|
|
|
action = item["action"]
|
|
if action.get("x") is not None and action.get("y") is not None:
|
|
x = action.get("x")
|
|
y = action.get("y")
|
|
return (int(x), int(y))
|
|
|
|
return None
|
|
|
|
def get_capabilities(self) -> List[AgentCapability]:
|
|
"""Return the capabilities supported by this agent."""
|
|
return ["click", "step"]
|