Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

1029 lines
40 KiB
Python

"""
Gemini Computer Use agent loop
Maps internal Agent SDK message format to Google's Gemini Computer Use API and back.
Supported models:
- gemini-(2.5/3/3.1)-(flash/pro/computer-use-preview)
Key features:
- Lazy import of google.genai
- Configure Computer Use tool with excluded browser-specific predefined functions (Gemini 2.5)
- Custom function declarations for computer use actions (Gemini 3 models)
- Convert Gemini function_call parts into internal computer_call actions
- Gemini 3-specific: thinking_level and media_resolution parameters
"""
from __future__ import annotations
import base64
import enum
import io
import uuid
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image
from ..decorators import register_agent
from ..loops.base import AsyncAgentConfig
from ..responses import make_reasoning_item
from ..types import AgentCapability
def _lazy_import_genai():
"""Import google.genai lazily to avoid hard dependency unless used."""
try:
from google import genai # type: ignore
from google.genai import types # type: ignore
return genai, types
except Exception as e: # pragma: no cover
raise RuntimeError(
"google.genai is required for the Gemini Computer Use loop. Install the Google Gemini SDK."
) from e
def _data_url_to_bytes(data_url: str) -> Tuple[bytes, str]:
"""Convert a data URL to raw bytes and mime type."""
if not data_url.startswith("data:"):
# Assume it's base64 png payload
try:
return base64.b64decode(data_url), "image/png"
except Exception:
return b"", "application/octet-stream"
header, b64 = data_url.split(",", 1)
mime = "image/png"
if ";" in header:
mime = header.split(";")[0].split(":", 1)[1] or "image/png"
return base64.b64decode(b64), mime
def _bytes_image_size(img_bytes: bytes) -> Tuple[int, int]:
try:
img = Image.open(io.BytesIO(img_bytes))
return img.size
except Exception:
return (1024, 768)
def _sanitize_for_json(obj: Any) -> Any:
"""
Recursively sanitize an object for JSON serialization.
Handles bytes fields (like thought_signature in Gemini 3 responses).
"""
if obj is None:
return None
if isinstance(obj, bytes):
return f"<bytes:{len(obj)}>"
if isinstance(obj, (str, int, float, bool)):
return obj
# Handle enums early — just use their value
if isinstance(obj, enum.Enum):
return obj.value
if isinstance(obj, dict):
return {k: _sanitize_for_json(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_sanitize_for_json(item) for item in obj]
# Handle objects with model_dump (Pydantic models)
if hasattr(obj, "model_dump"):
return _sanitize_for_json(obj.model_dump())
# Handle objects with __dict__ (like Gemini SDK response objects)
if hasattr(obj, "__dict__"):
return {k: _sanitize_for_json(v) for k, v in obj.__dict__.items() if not k.startswith("__")}
# Fallback to string representation
return str(obj)
def _create_gemini_client(
original_model: str, genai: Any, kwargs: Dict[str, Any]
) -> Tuple[Any, str]:
"""Create a Gemini SDK client, routing through CUA proxy if model has cua/ prefix.
Returns (client, bare_model_name).
When the model string starts with ``cua/<provider>/`` the Google GenAI SDK
is configured to send requests through the CUA inference proxy at
``{CUA_BASE_URL}/gemini``. This keeps the Gemini loop as the single code
path for both direct-Google and CUA-routed Gemini models.
"""
import os
from ..decorators import _strip_cua_prefix
model = _strip_cua_prefix(original_model)
is_cua_routed = original_model != model
if is_cua_routed:
api_key = (
kwargs.get("api_key") or os.getenv("CUA_INFERENCE_API_KEY") or os.getenv("CUA_API_KEY")
)
if not api_key:
raise ValueError(
"No CUA API key found for cua/ model routing. "
"Set CUA_API_KEY environment variable or pass api_key to ComputerAgent()."
)
cua_base_url = os.getenv("CUA_BASE_URL", "https://inference.cua.ai/v1")
http_options: Dict[str, Any] = {"base_url": f"{cua_base_url}/gemini"}
# Include CUA version headers if available
try:
from cua_core.http import cua_version_headers
hdrs = cua_version_headers()
if hdrs:
http_options["headers"] = hdrs
except Exception:
pass
client = genai.Client(api_key=api_key, http_options=http_options)
else:
api_key = kwargs.get("api_key", os.getenv("GOOGLE_API_KEY"))
if api_key:
client = genai.Client(api_key=api_key)
else:
# Vertex AI mode - requires GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION env vars
# and Application Default Credentials (ADC)
client = genai.Client()
return client, model
def _find_last_user_text(messages: List[Dict[str, Any]]) -> List[str]:
texts: List[str] = []
for msg in reversed(messages):
if msg.get("type") in (None, "message") and msg.get("role") == "user":
content = msg.get("content")
if isinstance(content, str):
return [content]
elif isinstance(content, list):
for c in content:
if c.get("type") in ("input_text", "output_text") and c.get("text"):
texts.append(c["text"]) # newest first
if texts:
return list(reversed(texts))
return []
def _find_last_screenshot(messages: List[Dict[str, Any]]) -> Optional[bytes]:
for msg in reversed(messages):
if msg.get("type") == "computer_call_output":
out = msg.get("output", {})
if isinstance(out, dict) and out.get("type") in ("input_image", "computer_screenshot"):
image_url = out.get("image_url", "")
if image_url:
data, _ = _data_url_to_bytes(image_url)
return data
return None
def _convert_messages_to_gemini_contents(
messages: List[Dict[str, Any]],
types: Any,
) -> Tuple[List[Any], Tuple[int, int]]:
"""
Convert internal message format to Gemini's Content format with full conversation history.
Similar to how Anthropic loop uses _convert_responses_items_to_completion_messages,
this converts ALL messages to Gemini's format.
Gemini requires:
- role: "user" or "model"
- parts: list of Part objects (text, image, function_call, function_response)
Returns:
Tuple of (list of Content objects, (screen_width, screen_height))
"""
contents: List[Any] = []
screen_w, screen_h = 1024, 768 # Default dimensions
for msg in messages:
msg_type = msg.get("type")
role = msg.get("role")
# User messages
if role == "user" or (msg_type in (None, "message") and role == "user"):
parts: List[Any] = []
content = msg.get("content")
if isinstance(content, str):
parts.append(types.Part(text=content))
elif isinstance(content, list):
for c in content:
if c.get("type") in ("input_text", "text") and c.get("text"):
parts.append(types.Part(text=c["text"]))
elif c.get("type") == "input_image" and c.get("image_url"):
img_bytes, _ = _data_url_to_bytes(c["image_url"])
if img_bytes:
w, h = _bytes_image_size(img_bytes)
screen_w, screen_h = w, h
parts.append(
types.Part.from_bytes(data=img_bytes, mime_type="image/png")
)
if parts:
contents.append(types.Content(role="user", parts=parts))
# Assistant messages
elif role == "assistant" or (msg_type == "message" and role == "assistant"):
parts = []
content = msg.get("content")
if isinstance(content, str):
parts.append(types.Part(text=content))
elif isinstance(content, list):
for c in content:
if c.get("type") in ("output_text", "text") and c.get("text"):
parts.append(types.Part(text=c["text"]))
if parts:
contents.append(types.Content(role="model", parts=parts))
# Reasoning (treat as model output)
elif msg_type == "reasoning":
summary = msg.get("summary", [])
for s in summary:
if s.get("type") == "summary_text" and s.get("text"):
contents.append(
types.Content(
role="model", parts=[types.Part(text=f"[Thinking: {s['text']}]")]
)
)
break
# Computer call (model action) - represent as text description for context
elif msg_type == "computer_call":
action = msg.get("action", {})
action_type = action.get("type", "unknown")
action_desc = f"[Action: {action_type}"
for k, v in action.items():
if k != "type":
action_desc += f", {k}={v}"
action_desc += "]"
contents.append(types.Content(role="model", parts=[types.Part(text=action_desc)]))
# Computer call output (screenshot result) - this is the key part!
elif msg_type == "computer_call_output":
out = msg.get("output", {})
if isinstance(out, dict) and out.get("type") in ("input_image", "computer_screenshot"):
image_url = out.get("image_url", "")
if image_url and image_url != "[omitted]":
img_bytes, _ = _data_url_to_bytes(image_url)
if img_bytes:
w, h = _bytes_image_size(img_bytes)
screen_w, screen_h = w, h
contents.append(
types.Content(
role="user",
parts=[
types.Part(text="[screenshot]"),
types.Part.from_bytes(data=img_bytes, mime_type="image/png"),
],
)
)
else:
# Image was omitted (by ImageRetentionCallback)
contents.append(
types.Content(
role="user",
parts=[
types.Part(
text="[Screenshot taken - image omitted for context limit]"
)
],
)
)
# Function call (model action)
elif msg_type == "function_call":
fn_name = msg.get("name", "unknown")
fn_args = msg.get("arguments", "{}")
contents.append(
types.Content(
role="model", parts=[types.Part(text=f"[Function call: {fn_name}({fn_args})]")]
)
)
# Function call output
elif msg_type == "function_call_output":
output = msg.get("output", "")
contents.append(
types.Content(role="user", parts=[types.Part(text=f"[Function result: {output}]")])
)
# Gemini requires alternating user/model turns - merge consecutive same-role contents
merged: List[Any] = []
for content in contents:
if merged and merged[-1].role == content.role:
# Merge parts into the previous content of same role
merged[-1] = types.Content(
role=content.role, parts=list(merged[-1].parts) + list(content.parts)
)
else:
merged.append(content)
# Gemini requires conversation to start with user
if merged and merged[0].role == "model":
merged.insert(0, types.Content(role="user", parts=[types.Part(text="Begin the task.")]))
# Ensure we have at least one message
if not merged:
merged = [
types.Content(role="user", parts=[types.Part(text="Proceed to the next action.")])
]
return merged, (screen_w, screen_h)
def _denormalize(v: int, size: int) -> int:
# Gemini returns 0-999 normalized
try:
return max(0, min(size - 1, int(round(v / 1000 * size))))
except Exception:
return 0
def _has_builtin_computer_use(model: str) -> bool:
"""Check if the model has a built-in ComputerUse tool (e.g. gemini-2.5-computer-use-preview)."""
return "computer-use" in model.lower()
def _build_custom_function_declarations(types: Any) -> List[Any]:
"""
Build custom function declarations for Gemini 3 models.
These function declarations replicate the built-in ComputerUse tool actions
that are available in Gemini 2.5 Computer Use Preview, but using the standard
function calling interface.
Note: Coordinates use 0-999 normalized range for both x and y.
"""
return [
types.FunctionDeclaration(
name="click_at",
description="Click at the specified x,y coordinates on the screen. x and y are normalized 0-999 where 0 is the left/top edge and 999 is the right/bottom edge of the screen. Look carefully at the screenshot to identify the exact position of the target element before clicking.",
parameters={
"type": "object",
"properties": {
"x": {
"type": "integer",
"description": "X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"y": {
"type": "integer",
"description": "Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
},
"required": ["x", "y"],
},
),
types.FunctionDeclaration(
name="type_text_at",
description="Type text at the specified x,y coordinates. First clicks at the location, then types the text. x and y are normalized 0-999 where 0 is the left/top edge and 999 is the right/bottom edge of the screen.",
parameters={
"type": "object",
"properties": {
"x": {
"type": "integer",
"description": "X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"y": {
"type": "integer",
"description": "Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
"text": {"type": "string", "description": "Text to type"},
"press_enter": {
"type": "boolean",
"description": "Whether to press Enter after typing",
},
},
"required": ["x", "y", "text"],
},
),
types.FunctionDeclaration(
name="hover_at",
description="Move the mouse cursor to the specified x,y coordinates without clicking. x and y are normalized 0-999 where 0 is the left/top edge and 999 is the right/bottom edge of the screen.",
parameters={
"type": "object",
"properties": {
"x": {
"type": "integer",
"description": "X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"y": {
"type": "integer",
"description": "Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
},
"required": ["x", "y"],
},
),
types.FunctionDeclaration(
name="key_combination",
description="Press a key combination (e.g., 'ctrl+c', 'alt+tab', 'enter').",
parameters={
"type": "object",
"properties": {
"keys": {
"type": "string",
"description": "Key combination to press (e.g., 'ctrl+c', 'enter', 'alt+tab')",
},
},
"required": ["keys"],
},
),
types.FunctionDeclaration(
name="scroll_at",
description="Scroll at the specified x,y coordinates in a given direction. x and y are normalized 0-999 where 0 is the left/top edge and 999 is the right/bottom edge of the screen.",
parameters={
"type": "object",
"properties": {
"x": {
"type": "integer",
"description": "X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"y": {
"type": "integer",
"description": "Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
"direction": {
"type": "string",
"enum": ["up", "down", "left", "right"],
"description": "Direction to scroll",
},
"magnitude": {
"type": "integer",
"description": "Amount to scroll in pixels (default 800)",
},
},
"required": ["x", "y", "direction"],
},
),
types.FunctionDeclaration(
name="scroll_document",
description="Scroll the entire document/page in a given direction.",
parameters={
"type": "object",
"properties": {
"direction": {
"type": "string",
"enum": ["up", "down", "left", "right"],
"description": "Direction to scroll",
},
},
"required": ["direction"],
},
),
types.FunctionDeclaration(
name="drag_and_drop",
description="Drag from one coordinate to another. x and y are normalized 0-999 where 0 is the left/top edge and 999 is the right/bottom edge of the screen.",
parameters={
"type": "object",
"properties": {
"x": {
"type": "integer",
"description": "Starting X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"y": {
"type": "integer",
"description": "Starting Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
"destination_x": {
"type": "integer",
"description": "Destination X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"destination_y": {
"type": "integer",
"description": "Destination Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
},
"required": ["x", "y", "destination_x", "destination_y"],
},
),
types.FunctionDeclaration(
name="wait_5_seconds",
description="Wait for 5 seconds before the next action. Use this when waiting for page loads or animations.",
parameters={
"type": "object",
"properties": {},
},
),
# # Browser-specific functions -> commented out for future support of browser exposed functions
# types.FunctionDeclaration(
# name="navigate",
# description="Navigate the browser to a specific URL.",
# parameters={
# "type": "object",
# "properties": {
# "url": {"type": "string", "description": "URL to navigate to"},
# },
# "required": ["url"],
# },
# ),
# types.FunctionDeclaration(
# name="open_web_browser",
# description="Open a web browser.",
# parameters={
# "type": "object",
# "properties": {},
# },
# ),
# types.FunctionDeclaration(
# name="search",
# description="Perform a web search with the given query.",
# parameters={
# "type": "object",
# "properties": {
# "query": {"type": "string", "description": "Search query"},
# },
# "required": ["query"],
# },
# ),
# types.FunctionDeclaration(
# name="go_back",
# description="Go back to the previous page in the browser.",
# parameters={
# "type": "object",
# "properties": {},
# },
# ),
# types.FunctionDeclaration(
# name="go_forward",
# description="Go forward to the next page in the browser.",
# parameters={
# "type": "object",
# "properties": {},
# },
# ),
]
def _map_gemini_fc_to_computer_call(
fc: Dict[str, Any],
screen_w: int,
screen_h: int,
) -> Optional[Dict[str, Any]]:
name = fc.get("name")
args = fc.get("args", {}) or {}
# Gemini 3 Flash uses "web_agent_api:" prefix for browser functions
# Strip the prefix to normalize function names
if name and name.startswith("web_agent_api:"):
name = name[len("web_agent_api:") :]
action: Dict[str, Any] = {}
if name == "click_at":
x = _denormalize(int(args.get("x", 0)), screen_w)
y = _denormalize(int(args.get("y", 0)), screen_h)
action = {"type": "click", "x": x, "y": y, "button": "left"}
elif name == "type_text_at":
x = _denormalize(int(args.get("x", 0)), screen_w)
y = _denormalize(int(args.get("y", 0)), screen_h)
text = args.get("text", "")
if args.get("press_enter") == True:
text += "\n"
action = {"type": "type", "x": x, "y": y, "text": text}
elif name == "hover_at":
x = _denormalize(int(args.get("x", 0)), screen_w)
y = _denormalize(int(args.get("y", 0)), screen_h)
action = {"type": "move", "x": x, "y": y}
elif name == "key_combination":
keys = str(args.get("keys", ""))
action = {"type": "keypress", "keys": keys}
elif name == "scroll_document":
direction = args.get("direction", "down")
magnitude = 800
dx, dy = 0, 0
if direction == "down":
dy = magnitude
elif direction == "up":
dy = -magnitude
elif direction == "right":
dx = magnitude
elif direction == "left":
dx = -magnitude
action = {
"type": "scroll",
"scroll_x": dx,
"scroll_y": dy,
"x": int(screen_w / 2),
"y": int(screen_h / 2),
}
elif name == "scroll_at":
x = _denormalize(int(args.get("x", 500)), screen_w)
y = _denormalize(int(args.get("y", 500)), screen_h)
direction = args.get("direction", "down")
magnitude = int(args.get("magnitude", 800))
dx, dy = 0, 0
if direction == "down":
dy = magnitude
elif direction == "up":
dy = -magnitude
elif direction == "right":
dx = magnitude
elif direction == "left":
dx = -magnitude
action = {"type": "scroll", "scroll_x": dx, "scroll_y": dy, "x": x, "y": y}
elif name == "drag_and_drop":
x = _denormalize(int(args.get("x", 0)), screen_w)
y = _denormalize(int(args.get("y", 0)), screen_h)
dx = _denormalize(int(args.get("destination_x", x)), screen_w)
dy = _denormalize(int(args.get("destination_y", y)), screen_h)
action = {
"type": "drag",
"start_x": x,
"start_y": y,
"end_x": dx,
"end_y": dy,
"button": "left",
}
elif name == "wait_5_seconds":
action = {"type": "wait"}
# Browser-specific functions - use playwright_exec for browser control
# (Note: Gemini API does not respect exclusions, so we implement these)
elif name == "navigate":
url = args.get("url", "")
if url:
action = {"type": "playwright_exec", "command": "visit_url", "params": {"url": url}}
else:
return None
elif name in ("open_web_browser", "open_browser"):
# Open browser with blank page or google
action = {
"type": "playwright_exec",
"command": "visit_url",
"params": {"url": "https://www.google.com"},
}
elif name == "search":
query = args.get("query", "")
if query:
action = {
"type": "playwright_exec",
"command": "web_search",
"params": {"query": query},
}
else:
return None
elif name == "go_back":
# Browser back via Playwright's native navigation
action = {"type": "playwright_exec", "command": "go_back", "params": {}}
elif name == "go_forward":
# Browser forward via Playwright's native navigation
action = {"type": "playwright_exec", "command": "go_forward", "params": {}}
else:
# Unsupported / unknown function
print(f"[WARN] Unsupported Gemini function: {name}")
return None
return {
"type": "computer_call",
"call_id": uuid.uuid4().hex,
"status": "completed",
"action": action,
}
# Supported models:
# - gemini-2.5-computer-use-preview-* : Uses built-in ComputerUse tool
# - gemini-3-flash-preview-* : Uses custom function declarations
# - gemini-3-pro-preview-* : Uses custom function declarations
# - gemini-3.1-pro-preview-* : Uses custom function declarations
@register_agent(
models=r"^(gemini-2\.5-computer-use-preview.*|gemini-3(\.\d+)?-flash-preview.*|gemini-3(\.\d+)?-pro-preview.*)$"
)
class GeminiComputerUseConfig(AsyncAgentConfig):
async def predict_step(
self,
messages: List[Dict[str, Any]],
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]:
genai, types = _lazy_import_genai()
# Create client with CUA routing support (detects cua/ prefix automatically)
client, model = _create_gemini_client(model, genai, kwargs)
# Extract Gemini 3-specific parameters
# thinking_level: Use types.ThinkingLevel enum values (e.g., "LOW", "HIGH", "MEDIUM", "MINIMAL")
# media_resolution: Use types.MediaResolution enum values (e.g., "MEDIA_RESOLUTION_LOW", "MEDIA_RESOLUTION_HIGH")
thinking_level = kwargs.pop("thinking_level", None)
media_resolution = kwargs.pop("media_resolution", None)
# Build thinking_config for Gemini 3 models if specified
thinking_config = None
if thinking_level:
# Accept string values and map to SDK enum
level_map = {
"minimal": types.ThinkingLevel.MINIMAL,
"low": types.ThinkingLevel.LOW,
"medium": types.ThinkingLevel.MEDIUM,
"high": types.ThinkingLevel.HIGH,
}
# Handle both lowercase strings and SDK enum values
if isinstance(thinking_level, str) and thinking_level.lower() in level_map:
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.lower()]
)
else:
# Assume it's already an SDK enum value
thinking_config = types.ThinkingConfig(thinking_level=thinking_level)
# Build media_resolution for Gemini 3 models if specified
resolved_media_resolution = None
if media_resolution:
resolution_map = {
"low": types.MediaResolution.MEDIA_RESOLUTION_LOW,
"medium": types.MediaResolution.MEDIA_RESOLUTION_MEDIUM,
"high": types.MediaResolution.MEDIA_RESOLUTION_HIGH,
}
if isinstance(media_resolution, str) and media_resolution.lower() in resolution_map:
resolved_media_resolution = resolution_map[media_resolution.lower()]
else:
# Assume it's already an SDK enum value
resolved_media_resolution = media_resolution
# Convert full message history to Gemini Contents format
# (done early so screen dimensions are available for system instruction)
contents, (screen_w, screen_h) = _convert_messages_to_gemini_contents(messages, types)
# Compose tools config based on model type
# Models with "computer-use" in the name use built-in ComputerUse tool
# All other models use custom function declarations
has_builtin_cu = _has_builtin_computer_use(model)
if not has_builtin_cu:
custom_functions = _build_custom_function_declarations(types)
print(f"[DEBUG] Using custom function declarations for model: {model}")
print(f"[DEBUG] Number of custom functions: {len(custom_functions)}")
system_instruction = (
f"You are controlling a computer with screen resolution {screen_w}x{screen_h} pixels. "
"When using coordinate-based functions (click_at, type_text_at, hover_at, scroll_at, drag_and_drop), "
"provide x and y as normalized values in the 0-999 range: "
"x=0 is the left edge, x=999 is the right edge; "
"y=0 is the top edge, y=999 is the bottom edge. "
"Look carefully at the screenshot to identify the exact position of UI elements before clicking."
)
generate_content_config = types.GenerateContentConfig(
system_instruction=system_instruction,
tools=[
types.Tool(function_declarations=custom_functions),
],
thinking_config=thinking_config,
media_resolution=resolved_media_resolution,
)
else:
excluded = [
"open_web_browser",
"search",
"navigate",
"go_forward",
"go_back",
"scroll_document",
]
# Note: ENVIRONMENT_BROWSER biases model towards browser actions
# Use ENVIRONMENT_UNSPECIFIED for general desktop tasks
computer_environment = kwargs.pop("computer_environment", "browser")
env_map = {
"browser": types.Environment.ENVIRONMENT_BROWSER,
"unspecified": types.Environment.ENVIRONMENT_UNSPECIFIED,
}
resolved_environment = env_map.get(
computer_environment.lower(), types.Environment.ENVIRONMENT_BROWSER
)
print(f"[DEBUG] Using built-in ComputerUse tool for model: {model}")
print(f"[DEBUG] Environment: {resolved_environment}")
print(f"[DEBUG] Excluded functions: {excluded}")
generate_content_config = types.GenerateContentConfig(
tools=[
types.Tool(
computer_use=types.ComputerUse(
environment=resolved_environment,
excluded_predefined_functions=excluded,
)
),
],
thinking_config=thinking_config,
media_resolution=resolved_media_resolution,
)
api_kwargs = {
"model": model,
"contents": contents,
"config": generate_content_config,
}
if _on_api_start:
await _on_api_start(_sanitize_for_json(api_kwargs))
response = client.models.generate_content(**api_kwargs)
# Debug: print raw function calls from response
try:
_dbg_candidates = getattr(response, "candidates", None) or []
_dbg_parts = (
getattr(
getattr(_dbg_candidates[0] if _dbg_candidates else None, "content", None),
"parts",
None,
)
or []
)
for p in _dbg_parts:
if hasattr(p, "function_call") and p.function_call:
print(
f"[DEBUG] Raw function_call from model: name={p.function_call.name}, args={dict(p.function_call.args or {})}"
)
except Exception as e:
print(f"[DEBUG] Error printing function calls: {e}")
if _on_api_end:
# Sanitize response to handle bytes fields (e.g., thought_signature in Gemini 3)
await _on_api_end(
{
"model": api_kwargs["model"],
# "contents": api_kwargs["contents"], # Disabled for now
"config": api_kwargs["config"],
},
_sanitize_for_json(response),
)
# Usage (Gemini SDK may not always provide token usage; populate when available)
usage: Dict[str, Any] = {}
try:
# Some SDKs expose response.usage; if available, copy
if getattr(response, "usage_metadata", None):
md = response.usage_metadata
usage = {
"prompt_tokens": getattr(md, "prompt_token_count", None) or 0,
"completion_tokens": getattr(md, "candidates_token_count", None) or 0,
"total_tokens": getattr(md, "total_token_count", None) or 0,
}
except Exception:
pass
if _on_usage and usage:
await _on_usage(usage)
# Parse output into internal items
output_items: List[Dict[str, Any]] = []
candidates = getattr(response, "candidates", None) or []
if not candidates:
return {"output": output_items, "usage": usage}
candidate = candidates[0]
# Text parts from the model (assistant message)
text_parts: List[str] = []
function_calls: List[Dict[str, Any]] = []
parts = getattr(getattr(candidate, "content", None), "parts", None) or []
for p in parts:
# Check for thinking/reasoning content first
if getattr(p, "thought", False) and getattr(p, "text", None):
output_items.append(make_reasoning_item(p.text))
continue
if getattr(p, "text", None):
text_parts.append(p.text)
if getattr(p, "function_call", None):
# p.function_call has name and args
fc = {
"name": getattr(p.function_call, "name", None),
"args": dict(getattr(p.function_call, "args", {}) or {}),
}
function_calls.append(fc)
if text_parts:
output_items.append(
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "\n".join(text_parts)}],
}
)
# Map function calls to internal computer_call actions
for fc in function_calls:
print(f"[DEBUG] Model returned function_call: {fc}")
item = _map_gemini_fc_to_computer_call(fc, screen_w, screen_h)
if item is not None:
output_items.append(item)
else:
print(f"[DEBUG] Function '{fc.get('name')}' not mapped (excluded or unsupported)")
return {"output": output_items, "usage": usage}
async def predict_click(
self,
model: str,
image_b64: str,
instruction: str,
**kwargs,
) -> Optional[Tuple[float, float]]:
"""Ask Gemini Cua to output a single click action for the given instruction.
For Gemini 2.5: Excludes all predefined tools except `click_at` and sends the screenshot.
For Gemini 3: Uses only the click_at function declaration.
Returns pixel (x, y) if a click is proposed, else None.
"""
genai, types = _lazy_import_genai()
# Create client with CUA routing support (detects cua/ prefix automatically)
client, model = _create_gemini_client(model, genai, kwargs)
# Build tools config based on model type
has_builtin_cu = _has_builtin_computer_use(model)
if not has_builtin_cu:
# Use only click_at function declaration for models without built-in ComputerUse
click_function = types.FunctionDeclaration(
name="click_at",
description="Click at the specified x,y coordinates on the screen. x and y are normalized 0-999 where 0 is the left/top edge and 999 is the right/bottom edge of the screen. Look carefully at the screenshot to identify the exact position of the target element before clicking.",
parameters={
"type": "object",
"properties": {
"x": {
"type": "integer",
"description": "X coordinate (0-999 normalized). 0 is the left edge, 999 is the right edge.",
},
"y": {
"type": "integer",
"description": "Y coordinate (0-999 normalized). 0 is the top edge, 999 is the bottom edge.",
},
},
"required": ["x", "y"],
},
)
config = types.GenerateContentConfig(
tools=[
types.Tool(function_declarations=[click_function]),
]
)
else:
exclude_all_but_click = [
"open_web_browser",
"search",
"navigate",
"go_forward",
"go_back",
"scroll_document",
]
config = types.GenerateContentConfig(
tools=[
types.Tool(
computer_use=types.ComputerUse(
environment=types.Environment.ENVIRONMENT_BROWSER,
excluded_predefined_functions=exclude_all_but_click,
)
)
]
)
# Prepare prompt parts
try:
img_bytes = base64.b64decode(image_b64)
except Exception:
img_bytes = b""
w, h = _bytes_image_size(img_bytes) if img_bytes else (1024, 768)
parts: List[Any] = [types.Part(text=f"Click {instruction}.")]
if img_bytes:
parts.append(types.Part.from_bytes(data=img_bytes, mime_type="image/png"))
contents = [types.Content(role="user", parts=parts)]
response = client.models.generate_content(
model=model,
contents=contents,
config=config,
)
# Parse first click_at
try:
candidate = response.candidates[0]
for p in candidate.content.parts:
fc = getattr(p, "function_call", None)
if fc and getattr(fc, "name", None) == "click_at":
args = dict(getattr(fc, "args", {}) or {})
x = _denormalize(int(args.get("x", 0)), w)
y = _denormalize(int(args.get("y", 0)), h)
return float(x), float(y)
except Exception:
return None
return None
def get_capabilities(self) -> List[AgentCapability]:
return ["click", "step"]