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
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Playground server implementation for Cua agents."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import platform
|
|
import socket
|
|
import traceback
|
|
import webbrowser
|
|
from typing import Any, Dict, List, Optional, Union
|
|
from urllib.parse import quote
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PlaygroundServer:
|
|
"""Playground server for running Cua agents via HTTP API."""
|
|
|
|
def __init__(self, agent_instance=None):
|
|
"""
|
|
Initialize the playground server.
|
|
|
|
Args:
|
|
agent_instance: Optional pre-configured agent instance to use
|
|
"""
|
|
self.agent_instance = agent_instance
|
|
self.app = FastAPI(
|
|
title="Cua Playground Server",
|
|
description="Playground server for Cua agents",
|
|
version="0.1.0",
|
|
)
|
|
self._setup_middleware()
|
|
self._setup_routes()
|
|
self.server = None
|
|
self.port = None
|
|
|
|
def _setup_middleware(self):
|
|
"""Setup CORS middleware."""
|
|
self.app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
def _setup_routes(self):
|
|
"""Setup API routes."""
|
|
|
|
@self.app.get("/status")
|
|
async def status():
|
|
"""Health check endpoint."""
|
|
sys = platform.system().lower()
|
|
if "darwin" in sys or sys in ("macos", "mac"):
|
|
os_type = "macos"
|
|
elif "windows" in sys:
|
|
os_type = "windows"
|
|
else:
|
|
os_type = "linux"
|
|
|
|
return {
|
|
"status": "ok",
|
|
"os_type": os_type,
|
|
"features": ["agent", "playground"],
|
|
}
|
|
|
|
@self.app.post("/responses")
|
|
async def responses_endpoint(request: Request):
|
|
"""
|
|
Run ComputerAgent for up to 2 turns.
|
|
|
|
Body JSON:
|
|
{
|
|
"model": "...", # required
|
|
"input": "... or messages[]", # required
|
|
"agent_kwargs": { ... }, # optional, passed directly to ComputerAgent
|
|
"env": { ... } # optional env overrides for agent
|
|
}
|
|
"""
|
|
# Import here to avoid circular imports
|
|
try:
|
|
from cua_agent import ComputerAgent
|
|
except ImportError:
|
|
raise HTTPException(status_code=501, detail="ComputerAgent not available")
|
|
|
|
# Parse request body
|
|
try:
|
|
body = await request.json()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid JSON body: {str(e)}")
|
|
|
|
model = body.get("model")
|
|
input_data = body.get("input")
|
|
if not model or input_data is None:
|
|
raise HTTPException(status_code=400, detail="'model' and 'input' are required")
|
|
|
|
agent_kwargs: Dict[str, Any] = body.get("agent_kwargs") or {}
|
|
env_overrides: Dict[str, str] = body.get("env") or {}
|
|
|
|
# Simple env override context
|
|
class _EnvOverride:
|
|
def __init__(self, overrides: Dict[str, str]):
|
|
self.overrides = overrides
|
|
self._original: Dict[str, Optional[str]] = {}
|
|
|
|
def __enter__(self):
|
|
for k, v in (self.overrides or {}).items():
|
|
self._original[k] = os.environ.get(k)
|
|
os.environ[k] = str(v)
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
for k, old in self._original.items():
|
|
if old is None:
|
|
os.environ.pop(k, None)
|
|
else:
|
|
os.environ[k] = old
|
|
|
|
# Convert input to messages
|
|
def _to_messages(data: Union[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
if isinstance(data, str):
|
|
return [{"role": "user", "content": data}]
|
|
if isinstance(data, list):
|
|
return data
|
|
return []
|
|
|
|
messages = _to_messages(input_data)
|
|
|
|
error = None
|
|
|
|
with _EnvOverride(env_overrides):
|
|
# Use pre-configured agent only if model matches, otherwise create new one
|
|
# This ensures the model picker in the UI is respected
|
|
if self.agent_instance and self.agent_instance.model == model:
|
|
agent = self.agent_instance
|
|
else:
|
|
# Model changed or no pre-configured agent - create new agent with requested model
|
|
agent = ComputerAgent(model=model, **agent_kwargs) # type: ignore[arg-type]
|
|
|
|
total_output: List[Any] = []
|
|
total_usage: Dict[str, Any] = {}
|
|
|
|
pending_computer_call_ids = set()
|
|
try:
|
|
async for result in agent.run(messages):
|
|
total_output += result["output"]
|
|
# Try to collect usage if present
|
|
if (
|
|
isinstance(result, dict)
|
|
and "usage" in result
|
|
and isinstance(result["usage"], dict)
|
|
):
|
|
# Merge usage counters
|
|
for k, v in result["usage"].items():
|
|
if isinstance(v, (int, float)):
|
|
total_usage[k] = total_usage.get(k, 0) + v
|
|
else:
|
|
total_usage[k] = v
|
|
for msg in result.get("output", []):
|
|
if msg.get("type") == "computer_call":
|
|
pending_computer_call_ids.add(msg["call_id"])
|
|
elif msg.get("type") == "computer_call_output":
|
|
pending_computer_call_ids.discard(msg["call_id"])
|
|
elif msg.get("type") == "function_call":
|
|
pending_computer_call_ids.add(msg["call_id"])
|
|
elif msg.get("type") == "function_call_output":
|
|
pending_computer_call_ids.discard(msg["call_id"])
|
|
# exit if no pending computer calls
|
|
if not pending_computer_call_ids:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error running agent: {str(e)}")
|
|
logger.error(traceback.format_exc())
|
|
error = str(e)
|
|
|
|
# Build response payload
|
|
payload = {
|
|
"model": model,
|
|
"error": error,
|
|
"output": total_output,
|
|
"usage": total_usage,
|
|
"status": "completed" if not error else "failed",
|
|
}
|
|
|
|
# CORS: allow any origin
|
|
headers = {
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
}
|
|
|
|
return JSONResponse(content=payload, headers=headers)
|
|
|
|
def _find_available_port(self, start_port: int = 8000, max_attempts: int = 100) -> int:
|
|
"""Find an available port starting from start_port."""
|
|
for port in range(start_port, start_port + max_attempts):
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", port))
|
|
return port
|
|
except OSError:
|
|
continue
|
|
raise RuntimeError(
|
|
f"Could not find an available port in range {start_port}-{start_port + max_attempts}"
|
|
)
|
|
|
|
async def start_async(self, port: Optional[int] = None, open_browser: bool = False):
|
|
"""
|
|
Start the playground server asynchronously.
|
|
|
|
Args:
|
|
port: Port to run the server on. If None, finds an available port.
|
|
open_browser: Whether to open the browser automatically.
|
|
"""
|
|
if port is None:
|
|
port = self._find_available_port()
|
|
|
|
self.port = port
|
|
host = f"http://localhost:{port}"
|
|
|
|
logger.info(f"Starting playground server on {host}")
|
|
|
|
if open_browser:
|
|
# Construct the playground URL
|
|
encoded_host = quote(host, safe="")
|
|
encoded_model = quote(self.agent_instance.model, safe="")
|
|
encoded_vnc_url = quote("http://localhost:8006/?autoconnect=true", safe="")
|
|
|
|
# Build URL with custom_model if agent instance is configured
|
|
playground_url = (
|
|
# f"http://cua.ai/dashboard/playground"
|
|
f"http://localhost:3000/dashboard/playground"
|
|
f"?host={encoded_host}"
|
|
f"&port={port}"
|
|
f"&id=localhost"
|
|
f"&name=localhost"
|
|
f"&custom_model={encoded_model}"
|
|
f"&custom_vnc_url={encoded_vnc_url}"
|
|
f"&vnc_password=null"
|
|
f"&resize=scale"
|
|
f"&fullscreen=true"
|
|
)
|
|
|
|
logger.info(f"Opening browser at: {playground_url}")
|
|
webbrowser.open(playground_url)
|
|
|
|
config = uvicorn.Config(
|
|
self.app,
|
|
host="0.0.0.0",
|
|
port=port,
|
|
log_level="info",
|
|
)
|
|
self.server = uvicorn.Server(config)
|
|
await self.server.serve()
|
|
|
|
def start(self, port: Optional[int] = None, open_browser: bool = False):
|
|
"""
|
|
Start the playground server (blocking).
|
|
|
|
Args:
|
|
port: Port to run the server on. If None, finds an available port.
|
|
open_browser: Whether to open the browser automatically.
|
|
"""
|
|
# Check if there's already a running event loop
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
# If we're in an async context, schedule as a task
|
|
import threading
|
|
|
|
# Run the server in a separate thread to avoid blocking
|
|
server_thread = threading.Thread(
|
|
target=self._run_in_new_loop,
|
|
args=(port, open_browser),
|
|
daemon=True,
|
|
)
|
|
server_thread.start()
|
|
|
|
# Give the server a moment to start and open browser
|
|
import time
|
|
|
|
time.sleep(1)
|
|
|
|
except RuntimeError:
|
|
# No running loop, can use asyncio.run() safely
|
|
asyncio.run(self.start_async(port=port, open_browser=open_browser))
|
|
|
|
def _run_in_new_loop(self, port: Optional[int] = None, open_browser: bool = False):
|
|
"""Helper to run server in a new event loop (for threading)."""
|
|
new_loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(new_loop)
|
|
try:
|
|
new_loop.run_until_complete(self.start_async(port=port, open_browser=open_browser))
|
|
finally:
|
|
new_loop.close()
|
|
|
|
async def stop(self):
|
|
"""Stop the playground server."""
|
|
if self.server:
|
|
logger.info("Stopping playground server")
|
|
await self.server.shutdown()
|