Files
2026-07-13 13:30:30 +08:00

290 lines
11 KiB
Python

# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import logging
import os
from google import genai
from google.genai.types import (
ComputerUse,
Content,
Environment,
FinishReason,
FunctionResponse,
FunctionResponseBlob,
GenerateContentConfig,
Part,
ThinkingConfig,
Tool,
)
from playwright.async_api import Page, async_playwright
logging.getLogger("google_genai._common").setLevel(logging.ERROR)
# --- CONFIGURATION ---
# Load configuration from environment variables for best practice.
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT")
LOCATION = os.environ.get("GOOGLE_LOCATION", "global")
MODEL_ID = os.environ.get("MODEL_ID", "gemini-3.5-flash")
# --- HELPER FUNCTIONS ---
def normalize_x(x: int, screen_width: int) -> int:
"""Convert normalized x coordinate (0-1000) to actual pixel coordinate."""
return int(x / 1000 * screen_width)
def normalize_y(y: int, screen_height: int) -> int:
"""Convert normalized y coordinate (0-1000) to actual pixel coordinate."""
return int(y / 1000 * screen_height)
async def execute_function_calls(
response, page: Page, screen_width: int, screen_height: int
) -> tuple[
str, list[tuple[str, str, bool]]
]: # <-- Note the added bool for safety status
"""Extracts and executes function calls from the model response."""
await asyncio.sleep(0.1)
function_calls = [
part.function_call
for part in response.candidates[0].content.parts
if hasattr(part, "function_call") and part.function_call
]
thoughts = [
part.text
for part in response.candidates[0].content.parts
if hasattr(part, "text") and part.text
]
if thoughts:
print(f"🤔 Model Reasoning: {' '.join(thoughts)}")
if not function_calls:
return "NO_ACTION", []
results = []
for function_call in function_calls:
result = None
safety_acknowledged = False
safety_decision = function_call.args.get("safety_decision")
if (
safety_decision
and safety_decision.get("decision") == "require_confirmation"
):
print(f"\n⚠️ SAFETY PROMPT: {safety_decision.get('explanation')}")
user_input = input(
f"Allow the agent to execute '{function_call.name}'? (y/n): "
)
if user_input.strip().lower() not in ["y", "yes"]:
print("🚫 Action denied by user.")
results.append((function_call.name, "user_denied", False))
continue # Skip execution and move to the next function call
print("✅ Action approved.")
safety_acknowledged = True
print(f"⚡ Executing Action: {function_call.name}")
try:
if function_call.name == "open_web_browser":
result = "success" # The browser is already open
elif function_call.name == "navigate":
await page.goto(function_call.args["url"])
result = "success"
elif function_call.name == "click_at":
actual_x = normalize_x(function_call.args["x"], screen_width)
actual_y = normalize_y(function_call.args["y"], screen_height)
await page.mouse.click(actual_x, actual_y)
result = "success"
elif function_call.name == "type_text_at":
text_to_type = function_call.args["text"]
print(f'[DEBUG] Typing text: "{text_to_type}"')
actual_x = normalize_x(function_call.args["x"], screen_width)
actual_y = normalize_y(function_call.args["y"], screen_height)
await page.mouse.click(actual_x, actual_y)
await asyncio.sleep(0.1)
await page.keyboard.type(text_to_type)
if function_call.args.get("press_enter", False):
await page.keyboard.press("Enter")
result = "success"
else:
result = "unknown_function"
except Exception as e:
print(f"❗️ Error executing {function_call.name}: {e}")
result = f"error: {e!s}"
results.append((function_call.name, result, safety_acknowledged))
return "CONTINUE", results
# --- THE AGENT LOOP ---
async def agent_loop(initial_prompt: str, max_turns: int = 20) -> None:
"""Main agent loop for local execution with a browser."""
if not PROJECT_ID:
raise ValueError("GOOGLE_PROJECT_ID environment variable not set.")
client = genai.Client(vertexai=True, project=PROJECT_ID, location=LOCATION)
browser = None
try:
async with async_playwright() as p:
# MODIFIED: Launch browser in a try...finally block
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()
sw, sh = 960, 1080
await page.set_viewport_size({"width": sw, "height": sh})
await page.goto("https://www.google.com")
print(f"🎬 Starting Agent Loop with prompt: '{initial_prompt}'")
# Configure Computer Use tool with browser environment
# Base configuration for the Computer Use tool
config_kwargs = {
"tools": [
Tool(
computer_use=ComputerUse(
environment=Environment.ENVIRONMENT_BROWSER,
# Optional: Exclude specific predefined functions
excluded_predefined_functions=["drag_and_drop"],
)
)
]
}
# Conditionally add thinking_config only for the Gemini 3 models
model_version = float(MODEL_ID.split("-")[1])
if model_version >= 3:
config_kwargs["thinking_config"] = ThinkingConfig(include_thoughts=True)
config = GenerateContentConfig(**config_kwargs)
screenshot = await page.screenshot()
contents = [
Content(
role="user",
parts=[
Part(text=initial_prompt),
Part.from_bytes(data=screenshot, mime_type="image/png"),
],
)
]
for turn in range(max_turns):
print(f"\n--- 🔁 Turn {turn + 1} ---")
print(f"[DEBUG] Current URL: {page.url}")
response = client.models.generate_content(
model=MODEL_ID, contents=contents, config=config
)
if not response.candidates:
print("❗️ Model returned no candidates. Terminating loop.")
print("Full Response:", response)
break
if response.candidates[0].finish_reason == FinishReason.SAFETY:
print(
"🛑 SAFETY TRIGGERED: The model halted execution due to safety policies."
)
print(f"Details: {response.candidates[0].safety_ratings}")
break
print(
f"[DEBUG] Model Finish Reason: {response.candidates[0].finish_reason}"
)
contents.append(response.candidates[0].content)
print("[DEBUG] Appended model response to history.")
# Check if the attribute exists AND is not None
active_function_calls = [
part.function_call
for part in response.candidates[0].content.parts
if hasattr(part, "function_call") and part.function_call
]
if not active_function_calls:
final_text = "".join(
part.text
for part in response.candidates[0].content.parts
if hasattr(part, "text") and part.text is not None
)
if final_text:
print(f"✅ Agent Finished: {final_text}")
break
status, execution_results = await execute_function_calls(
response, page, sw, sh
)
print(
f"[DEBUG] Execution Results: status='{status}', results={execution_results}"
)
if status == "NO_ACTION":
continue
function_response_parts = []
# Unpack the 3 variables returned by our updated function
for name, result, safety_acknowledged in execution_results:
screenshot = await page.screenshot()
current_url = page.url
# Prepare the response payload
response_payload = {"url": current_url}
# Handle the safety and denial states
if result == "user_denied":
response_payload["error"] = "user_denied"
elif safety_acknowledged:
# CRITICAL: Acknowledge the safety decision so the API doesn't throw an error
response_payload["safety_acknowledgement"] = True
function_response_parts.append(
Part(
function_response=FunctionResponse(
name=name,
response=response_payload,
parts=[
Part(
inline_data=FunctionResponseBlob(
mime_type="image/png", data=screenshot
)
)
],
)
)
)
contents.append(Content(role="user", parts=function_response_parts))
print(f"📝 State captured. History now has {len(contents)} messages.")
finally:
if browser:
await browser.close()
print("\n--- Browser closed. ---")
# --- SCRIPT ENTRY POINT ---
if __name__ == "__main__":
prompt = "Navigate to the Google Store and find the page of 'Pixel 10'."
asyncio.run(agent_loop(prompt))