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

398 lines
14 KiB
Python

"""
Yutori n1 agent loop implementation using litellm.
n1 is a browser-use model that outputs actions via tool_calls in OpenAI chat
completions format. Coordinates are in a 1000x1000 normalized space.
"""
from __future__ import annotations
import base64
import io
import json
from typing import Any, Dict, List, Optional, Tuple
import litellm
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from PIL import Image
from ..decorators import register_agent
from ..loops.base import AsyncAgentConfig
from ..responses import (
convert_completion_messages_to_responses_items,
convert_responses_items_to_completion_messages,
make_function_call_item,
make_output_text_item,
make_reasoning_item,
)
from ..types import AgentCapability
# Target resolution for n1 (docs recommend 1280x800 WebP)
N1_TARGET_WIDTH = 1280
N1_TARGET_HEIGHT = 800
N1_COORD_SPACE = 1000
def _prepare_image_for_n1(image_b64: str) -> str:
"""Convert a base64 PNG screenshot to WebP at 1280x800 for optimal n1 performance."""
try:
img_bytes = base64.b64decode(image_b64)
img = Image.open(io.BytesIO(img_bytes))
# Resize to n1's recommended resolution
if img.size != (N1_TARGET_WIDTH, N1_TARGET_HEIGHT):
img = img.resize((N1_TARGET_WIDTH, N1_TARGET_HEIGHT), Image.LANCZOS)
# Convert to WebP
buf = io.BytesIO()
img.save(buf, format="WEBP", quality=85)
return base64.b64encode(buf.getvalue()).decode("utf-8")
except Exception:
# Fallback: return original image if conversion fails
return image_b64
def _unnormalize_coordinates(
coords: List[int], screen_width: int, screen_height: int
) -> Tuple[int, int]:
"""Scale coordinates from n1's 1000x1000 space to actual screen pixels."""
x = max(0, min(screen_width, round((coords[0] / N1_COORD_SPACE) * screen_width)))
y = max(0, min(screen_height, round((coords[1] / N1_COORD_SPACE) * screen_height)))
return x, y
def _convert_n1_action_to_computer_action(
fn_name: str, args: Dict[str, Any], screen_width: int, screen_height: int
) -> Optional[Dict[str, Any]]:
"""
Convert an n1 tool call to the internal computer_call action schema.
Returns None for actions that should be emitted as function_calls instead
(goto_url, go_back, refresh).
"""
# Actions with coordinates
coords = args.get("coordinates")
x, y = None, None
if isinstance(coords, (list, tuple)) and len(coords) >= 2:
x, y = _unnormalize_coordinates(coords, screen_width, screen_height)
if fn_name == "left_click":
if x is None or y is None:
return None
return {"action": "left_click", "x": x, "y": y}
if fn_name == "double_click":
if x is None or y is None:
return None
return {"action": "double_click", "x": x, "y": y}
if fn_name == "triple_click":
# Approximate as double_click
if x is None or y is None:
return None
return {"action": "double_click", "x": x, "y": y}
if fn_name == "right_click":
if x is None or y is None:
return None
return {"action": "right_click", "x": x, "y": y}
if fn_name == "hover":
if x is None or y is None:
return None
return {"action": "move", "x": x, "y": y}
if fn_name == "drag":
start_coords = args.get("start_coordinates")
if (
not isinstance(start_coords, (list, tuple))
or len(start_coords) < 2
or x is None
or y is None
):
return None
sx, sy = _unnormalize_coordinates(start_coords, screen_width, screen_height)
return {
"action": "drag",
"start_x": sx,
"start_y": sy,
"end_x": x,
"end_y": y,
}
if fn_name == "scroll":
direction = args.get("direction", "down")
amount = int(args.get("amount", 3))
# Convert direction + amount to scroll_x/scroll_y pixels
# Use ~100 pixels per scroll unit as a reasonable default
pixels_per_unit = 100
scroll_x, scroll_y = 0, 0
if direction == "down":
scroll_y = amount * pixels_per_unit
elif direction == "up":
scroll_y = -(amount * pixels_per_unit)
elif direction == "right":
scroll_x = amount * pixels_per_unit
elif direction == "left":
scroll_x = -(amount * pixels_per_unit)
out: Dict[str, Any] = {"action": "scroll", "scroll_x": scroll_x, "scroll_y": scroll_y}
if x is not None and y is not None:
out["x"] = x
out["y"] = y
return out
if fn_name == "type":
text = args.get("text", "")
if args.get("press_enter_after"):
text = text + "\n"
# Note: clear_before_typing is not supported by the framework's type action.
# n1 rarely emits this flag; when it does, the field may already be empty.
return {"action": "type", "text": text}
if fn_name == "key_press":
key_comb = args.get("key_comb", "")
# n1 uses Playwright-compatible key combos like "Control+a", "Escape"
keys = [k.strip() for k in key_comb.split("+")]
return {"action": "keypress", "keys": keys}
if fn_name == "wait":
return {"action": "wait"}
if fn_name == "go_back":
return {"action": "history_back"}
if fn_name == "refresh":
return {"action": "keypress", "keys": ["F5"]}
if fn_name == "goto_url":
return {"action": "visit_url", "url": args.get("url", "")}
return None
def _convert_images_to_n1_format(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert all images in messages to WebP format optimized for n1."""
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
continue
for part in content:
if isinstance(part, dict) and part.get("type") == "image_url":
url = ((part.get("image_url") or {}).get("url")) or ""
if url.startswith("data:") and "," in url:
b64 = url.split(",", 1)[1]
converted = _prepare_image_for_n1(b64)
part["image_url"]["url"] = f"data:image/webp;base64,{converted}"
return messages
@register_agent(models=r"(yutori/)?n1(-.*)?$", tool_type="browser")
class YutoriN1Config(AsyncAgentConfig):
"""
Yutori n1 browser-use agent loop.
n1 is a browser-only model that outputs actions as tool_calls.
Coordinates use a 1000x1000 normalized space.
"""
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]:
"""Predict the next browser action using Yutori n1."""
tools = tools or []
# Get screen dimensions for coordinate denormalization
screen_width, screen_height = N1_TARGET_WIDTH, N1_TARGET_HEIGHT
if computer_handler:
try:
screen_width, screen_height = await computer_handler.get_dimensions()
except Exception:
# BrowserTool doesn't have get_dimensions() but has viewport attrs
vw = getattr(computer_handler, "viewport_width", None)
vh = getattr(computer_handler, "viewport_height", None)
if vw and vh:
screen_width, screen_height = vw, vh
# Convert messages from Responses API format to chat completions format
completion_messages = convert_responses_items_to_completion_messages(
messages,
allow_images_in_tool_results=True,
)
# Convert images to WebP at 1280x800
completion_messages = _convert_images_to_n1_format(completion_messages)
# If there's no screenshot, take one and inject it
def _has_any_image(msgs: List[Dict[str, Any]]) -> bool:
for m in msgs:
content = m.get("content")
if isinstance(content, list):
for p in content:
if isinstance(p, dict) and p.get("type") == "image_url":
return True
return False
pre_output_items: List[Dict[str, Any]] = []
if not _has_any_image(completion_messages):
if computer_handler is None or not hasattr(computer_handler, "screenshot"):
raise RuntimeError(
"No screenshots present and computer_handler.screenshot is not available."
)
screenshot_b64 = await computer_handler.screenshot()
if not screenshot_b64:
raise RuntimeError("Failed to capture screenshot from computer_handler.")
converted = _prepare_image_for_n1(screenshot_b64)
completion_messages.append(
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:image/webp;base64,{converted}"},
},
{"type": "text", "text": "Current browser screen"},
],
}
)
pre_output_items.append(
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Taking a screenshot to see the current browser screen.",
}
],
}
)
# Build tool list: pass through any custom function tools
n1_tools = []
for tool in tools:
if tool.get("type") == "function":
func = tool.get("function")
if func:
n1_tools.append({"type": "function", "function": func})
# Skip computer tools — n1 has built-in browser actions
api_kwargs: Dict[str, Any] = {
"model": model,
"messages": completion_messages,
"max_retries": max_retries,
"stream": False, # n1 does not support streaming
"temperature": kwargs.pop("temperature", 0.3),
}
if n1_tools:
api_kwargs["tools"] = n1_tools
# Pass through remaining kwargs (api_key, api_base, etc.)
api_kwargs.update({k: v for k, v in kwargs.items()})
if _on_api_start:
await _on_api_start(api_kwargs)
response = await litellm.acompletion(**api_kwargs)
if _on_api_end:
await _on_api_end(api_kwargs, response)
# Extract usage
usage = {
**LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( # type: ignore
response.usage
).model_dump(),
"response_cost": response._hidden_params.get("response_cost", 0.0),
}
if _on_usage:
await _on_usage(usage)
# Parse response
resp_dict = response.model_dump() # type: ignore
choice = (resp_dict.get("choices") or [{}])[0]
message = choice.get("message") or {}
content_text = message.get("content") or ""
tool_calls_array = message.get("tool_calls") or []
reasoning_text = message.get("reasoning") or ""
output_items: List[Dict[str, Any]] = []
# Add reasoning if present
if reasoning_text:
output_items.append(make_reasoning_item(reasoning_text))
if tool_calls_array:
for tc in tool_calls_array:
function = tc.get("function", {})
fn_name = function.get("name", "")
args_str = function.get("arguments", "{}")
tc_id = tc.get("id", "call_0")
try:
args = json.loads(args_str) if isinstance(args_str, str) else args_str
except json.JSONDecodeError:
args = {}
# Try converting to a computer action
computer_action = _convert_n1_action_to_computer_action(
fn_name, args, screen_width, screen_height
)
if computer_action is not None:
# Build a fake completion message for the converter
fake_cm = {
"role": "assistant",
"content": content_text or "",
"tool_calls": [
{
"type": "function",
"id": tc_id,
"function": {
"name": "computer",
"arguments": json.dumps(computer_action),
},
}
],
}
output_items.extend(convert_completion_messages_to_responses_items([fake_cm]))
# Only use content_text once
content_text = ""
else:
# Custom tool — emit as function_call
output_items.append(make_function_call_item(fn_name, args, call_id=tc_id))
else:
# No tool calls — task is complete
if content_text:
output_items.append(make_output_text_item(content_text))
else:
output_items.append(make_output_text_item("Task completed."))
return {"output": (pre_output_items + output_items), "usage": usage}
async def predict_click(
self, model: str, image_b64: str, instruction: str, **kwargs
) -> Optional[Tuple[int, int]]:
raise NotImplementedError(
"Yutori n1 does not support standalone click prediction. "
"Use predict_step for full browser automation."
)
def get_capabilities(self) -> List[AgentCapability]:
return ["step"]