-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathapi_agent.py
More file actions
540 lines (448 loc) · 19.6 KB
/
Copy pathapi_agent.py
File metadata and controls
540 lines (448 loc) · 19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
"""WAA-compatible API Agent that uses Claude Sonnet 4.5 or GPT-5.1 directly.
This module provides a drop-in replacement for the Navi agent in Windows Agent Arena
that uses hosted VLM APIs (Claude or GPT-5.1) instead of the buggy Navi agent.
The agent receives observations from WAA and returns actions in WAA's expected format
(code blocks for the pyautogui action space).
Why this exists:
The default Navi agent in WAA has NoneType errors and other bugs.
This API agent provides a reliable alternative that uses Claude Sonnet 4.5
or GPT-5.1 directly, bypassing the problematic Navi implementation.
Usage from CLI:
# Run with Claude Sonnet 4.5 (requires ANTHROPIC_API_KEY)
uv run python -m openadapt_ml.benchmarks.cli vm run-waa --agent api-claude --num-tasks 5
# Run with GPT-5.1 (requires OPENAI_API_KEY)
uv run python -m openadapt_ml.benchmarks.cli vm run-waa --agent api-openai --num-tasks 5
How it works:
1. The Dockerfile copies this file to /client/mm_agents/api_agent.py
2. The Dockerfile patches run.py to recognize "api-claude" and "api-openai" agents
3. When the agent is selected, it:
- Receives screenshots from WAA's DesktopEnv
- Sends them to Claude or GPT-5.1 via their respective APIs
- Parses the response into pyautogui code blocks
- Returns actions in WAA's expected format
Example usage in WAA run.py (auto-patched by Dockerfile):
if cfg_args["agent_name"] == "api-claude":
from mm_agents.api_agent import ApiAgent
agent = ApiAgent(provider="anthropic")
elif cfg_args["agent_name"] == "api-openai":
from mm_agents.api_agent import ApiAgent
agent = ApiAgent(provider="openai")
"""
from __future__ import annotations
import base64
import logging
import os
import re
from io import BytesIO
from typing import Dict, List
from PIL import Image
logger = logging.getLogger("desktopenv.agent.api")
# System prompt for GUI automation - adapted from APIBenchmarkAgent
SYSTEM_PROMPT = """You are a GUI automation agent controlling a Windows desktop. Given a screenshot and task instruction, determine the next action to take.
You must respond with a Python code block that uses the pyautogui API. Available functions:
- computer.click(x, y) - Click at pixel coordinates
- computer.double_click(x, y) - Double-click at pixel coordinates
- computer.right_click(x, y) - Right-click at pixel coordinates
- computer.type(text) - Type the given text
- computer.hotkey(key1, key2, ...) - Press key combination (e.g., 'ctrl', 'c')
- computer.press(key) - Press a single key (e.g., 'enter', 'tab', 'escape')
- computer.scroll(direction) - Scroll up (-3) or down (3)
- computer.drag(x1, y1, x2, y2) - Drag from (x1,y1) to (x2,y2)
Coordinates are pixel values within the screen (1920x1200 by default).
Format your response as:
```memory
# Your notes about the task state (optional)
```
```decision
CONTINUE
```
```python
computer.click(500, 300)
```
Important:
- Use DONE in the decision block when the task is complete
- Use FAIL if the task cannot be completed
- Always output exactly one action per response
- Click on UI elements by their visual center coordinates
- For text input, first click to focus the field, then type
Think step by step:
1. What is the current state of the UI?
2. What is the goal?
3. What is the next logical action?
"""
def format_accessibility_tree(tree: dict, indent: int = 0, max_depth: int = 5) -> str:
"""Format accessibility tree for prompt.
Args:
tree: Accessibility tree dict from WAA.
indent: Current indentation level.
max_depth: Maximum depth to traverse.
Returns:
Formatted string representation.
"""
if indent >= max_depth:
return ""
lines = []
prefix = " " * indent
role = tree.get("role", tree.get("control_type", "unknown"))
name = tree.get("name", "")
node_id = tree.get("id", tree.get("node_id", ""))
# Get bounding box if available
bbox_str = ""
if "bounding_rectangle" in tree:
br = tree["bounding_rectangle"]
bbox_str = f" [{br.get('left', 0)},{br.get('top', 0)},{br.get('right', 0)},{br.get('bottom', 0)}]"
line = f"{prefix}[{node_id}] {role}"
if name:
line += f": {name[:50]}" # Truncate long names
if bbox_str:
line += bbox_str
lines.append(line)
for child in tree.get("children", []):
child_text = format_accessibility_tree(child, indent + 1, max_depth)
if child_text:
lines.append(child_text)
return "\n".join(lines)
def prev_actions_to_string(prev_actions: List[str], n_prev: int = 3) -> str:
"""Format previous actions for the prompt.
Args:
prev_actions: List of previous action strings.
n_prev: Number of previous actions to include.
Returns:
Formatted string of previous actions.
"""
result = ""
n_prev = min(n_prev, len(prev_actions))
for i in range(1, n_prev + 1):
action = prev_actions[-i]
result += f"Action at T-{i}:\n{action}\n\n"
return result
class ApiAgent:
"""WAA-compatible agent that uses Claude or GPT-5.1 API directly.
This agent implements the same interface as NaviAgent but uses hosted
VLM APIs instead of the local Navi implementation (which has NoneType bugs).
Args:
provider: API provider - "anthropic" (Claude) or "openai" (GPT-5.1).
api_key: Optional API key. If not provided, uses environment variables.
model: Optional model name override.
temperature: Sampling temperature (0.0-1.0).
max_tokens: Maximum tokens for API response.
use_accessibility_tree: Whether to include a11y tree in prompts.
use_history: Whether to include action history in prompts.
demo: Optional demonstration trajectory to include at every step.
This is the key fix for 100% first-action / 0% episode success:
the demo must persist across ALL steps, not just step 1.
"""
# Default models for each provider
DEFAULT_MODELS = {
"anthropic": "claude-sonnet-4-5-20250929",
"openai": "gpt-5.1",
}
def __init__(
self,
provider: str = "anthropic",
api_key: str | None = None,
model: str | None = None,
temperature: float = 0.5,
max_tokens: int = 1500,
use_accessibility_tree: bool = True,
use_history: bool = True,
demo: str | None = None,
):
self.provider = provider
self.model = model or self.DEFAULT_MODELS.get(provider)
self.temperature = temperature
self.max_tokens = max_tokens
self.use_accessibility_tree = use_accessibility_tree
self.use_history = use_history
self.demo = demo # Demo persists across ALL steps
# WAA compatibility
self.action_space = "code_block"
# Get API key
if provider == "anthropic":
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
if not self.api_key:
raise RuntimeError(
"ANTHROPIC_API_KEY is required for provider='anthropic'. "
"Set it in environment or pass api_key parameter."
)
try:
from anthropic import Anthropic
self._client = Anthropic(api_key=self.api_key)
except ImportError:
raise RuntimeError(
"anthropic package required. Install with: pip install anthropic"
)
elif provider == "openai":
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
if not self.api_key:
raise RuntimeError(
"OPENAI_API_KEY is required for provider='openai'. "
"Set it in environment or pass api_key parameter."
)
try:
from openai import OpenAI
self._client = OpenAI(api_key=self.api_key)
except ImportError:
raise RuntimeError(
"openai package required. Install with: pip install openai"
)
else:
raise ValueError(f"Unsupported provider: {provider}")
# State tracking
self.prev_actions: List[str] = [] # Raw action codes for WAA compatibility
self.history: List[str] = [] # Rich history with reasoning (like PC Agent-E)
self.history_cutoff = 10 # Max history entries to include
self.memory_block_text = "# empty memory block"
self.step_counter = 0
logger.info(
f"ApiAgent initialized with provider={provider}, model={self.model}"
)
if self.demo:
logger.info(
f"Demo trajectory provided ({len(self.demo)} chars) - will persist across all steps"
)
def predict(self, instruction: str, obs: Dict) -> tuple:
"""Predict the next action based on observation.
This method implements the same interface as NaviAgent.predict().
Args:
instruction: The task instruction.
obs: Observation dict containing:
- screenshot: PNG bytes of current screen
- accessibility_tree: A11y tree dict (optional)
- window_title: Current window title
- window_names_str: List of open windows
- computer_clipboard: Current clipboard content
Returns:
Tuple of (response_text, actions_list, logs_dict, computer_update_args)
"""
logs = {}
self.step_counter += 1
# Extract screenshot
screenshot_bytes = obs.get("screenshot")
if screenshot_bytes is None:
logger.error("No screenshot in observation")
return "", ["# No screenshot available"], logs, {}
# Convert screenshot to PIL Image
try:
image = Image.open(BytesIO(screenshot_bytes))
w, h = image.size
except Exception as e:
logger.error(f"Failed to load screenshot: {e}")
return "", ["# Failed to load screenshot"], logs, {}
logs["image_width"] = w
logs["image_height"] = h
# Build the prompt
content_parts = [f"TASK: {instruction}"]
# CRITICAL FIX: Include demo at EVERY step, not just step 1
# This is the key fix for 100% first-action / 0% episode success
if self.demo:
content_parts.append(
f"DEMONSTRATION (follow this pattern):\n"
f"---\n{self.demo}\n---\n"
f"Use the demonstration above as a guide. You are currently at step {self.step_counter}."
)
logs["demo_included"] = True
logs["demo_length"] = len(self.demo)
# Add context
window_title = obs.get("window_title", "")
if window_title:
content_parts.append(f"Current window: {window_title}")
logs["window_title"] = window_title
window_names_str = obs.get("window_names_str", "")
if window_names_str:
content_parts.append(f"Open windows: {window_names_str}")
logs["window_names_str"] = window_names_str
clipboard = obs.get("computer_clipboard", "")
if clipboard:
content_parts.append(f"Clipboard: {clipboard[:100]}")
logs["computer_clipboard"] = clipboard
# Add accessibility tree if available and enabled
if self.use_accessibility_tree:
a11y_tree = obs.get("accessibility_tree")
if a11y_tree:
tree_str = format_accessibility_tree(a11y_tree)
# Truncate if too long
if len(tree_str) > 4000:
tree_str = tree_str[:4000] + "\n... (truncated)"
content_parts.append(f"UI Elements:\n{tree_str}")
logs["accessibility_tree_len"] = len(tree_str)
# Add action history if enabled (enhanced: includes reasoning, not just raw actions)
if self.use_history and self.history:
# Use rich history with reasoning (like PC Agent-E)
history_entries = self.history[-self.history_cutoff :]
history_str = "\n\n".join(
f"[Step {i + 1}] {entry}" for i, entry in enumerate(history_entries)
)
content_parts.append(f"History of previous steps:\n{history_str}")
logs["history_entries"] = len(history_entries)
elif self.use_history and self.prev_actions:
# Fallback to raw action history
history_str = prev_actions_to_string(self.prev_actions, n_prev=5)
content_parts.append(f"Previous actions:\n{history_str}")
# Add memory block
content_parts.append(f"Your memory:\n```memory\n{self.memory_block_text}\n```")
content_parts.append(f"\nScreen dimensions: {w}x{h} pixels")
content_parts.append("\nWhat is the next action?")
user_prompt = "\n\n".join(content_parts)
logs["user_question"] = user_prompt
# Call the API
try:
response_text = self._call_api(screenshot_bytes, user_prompt)
except Exception as e:
logger.error(f"API call failed: {e}")
return "", ["# API call failed"], logs, {}
logs["plan_result"] = response_text
# Extract memory block
memory_match = re.search(r"```memory\n(.*?)```", response_text, re.DOTALL)
if memory_match:
self.memory_block_text = memory_match.group(1).strip()
# Extract decision block
decision_match = re.search(r"```decision\n(.*?)```", response_text, re.DOTALL)
if decision_match:
decision = decision_match.group(1).strip().upper()
if "DONE" in decision:
self.prev_actions.append("DONE")
return "", ["DONE"], logs, {}
elif "FAIL" in decision:
self.prev_actions.append("FAIL")
return "", ["FAIL"], logs, {}
elif "WAIT" in decision:
self.prev_actions.append("WAIT")
return "", ["WAIT"], logs, {}
# Extract Python code block
code_match = re.search(r"```python\n(.*?)```", response_text, re.DOTALL)
if code_match:
code_text = code_match.group(1).strip()
actions = [code_text]
self.prev_actions.append(code_text)
# Store rich history with reasoning (memory + action)
self._add_to_history(
f"Thought: {self.memory_block_text}\nAction: {code_text}"
)
else:
# Try to extract action from response text
action = self._parse_action_from_text(response_text, w, h)
if action:
actions = [action]
self.prev_actions.append(action)
self._add_to_history(
f"Thought: {self.memory_block_text}\nAction: {action}"
)
else:
logger.warning("Could not extract action from response")
actions = ["# Could not parse action"]
# Build computer_update_args (for WAA compatibility)
computer_update_args = {
"rects": [],
"window_rect": [0, 0, w, h],
"screenshot": image,
"scale": (1.0, 1.0),
"clipboard_content": clipboard,
"swap_ctrl_alt": False,
}
return "", actions, logs, computer_update_args
def _call_api(self, screenshot_bytes: bytes, user_prompt: str) -> str:
"""Call the VLM API with screenshot and prompt.
Args:
screenshot_bytes: PNG image bytes.
user_prompt: User prompt text.
Returns:
Response text from the API.
"""
image_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
if self.provider == "anthropic":
content = [
{"type": "text", "text": user_prompt},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": image_b64,
},
},
]
resp = self._client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": content}],
)
# Extract text from response
parts = getattr(resp, "content", [])
texts = [
getattr(p, "text", "")
for p in parts
if getattr(p, "type", "") == "text"
]
return "\n".join([t for t in texts if t]).strip()
elif self.provider == "openai":
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": user_prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
},
],
},
]
resp = self._client.chat.completions.create(
model=self.model,
messages=messages,
max_completion_tokens=self.max_tokens,
temperature=self.temperature,
)
return resp.choices[0].message.content or ""
raise ValueError(f"Unsupported provider: {self.provider}")
def _parse_action_from_text(self, text: str, width: int, height: int) -> str | None:
"""Try to parse an action from free-form text response.
Args:
text: Response text to parse.
width: Screen width.
height: Screen height.
Returns:
Python code string or None if parsing failed.
"""
# Try to find click coordinates
click_match = re.search(r"click.*?(\d+)\s*,\s*(\d+)", text, re.IGNORECASE)
if click_match:
x, y = int(click_match.group(1)), int(click_match.group(2))
return f"computer.click({x}, {y})"
# Try to find type text
type_match = re.search(r'type[:\s]+["\'](.+?)["\']', text, re.IGNORECASE)
if type_match:
text_to_type = type_match.group(1)
return f'computer.type("{text_to_type}")'
# Try to find key press
key_match = re.search(r"press[:\s]+(\w+)", text, re.IGNORECASE)
if key_match:
key = key_match.group(1).lower()
return f'computer.press("{key}")'
# Try to find hotkey
hotkey_match = re.search(r"hotkey[:\s]+(\w+)\s*\+\s*(\w+)", text, re.IGNORECASE)
if hotkey_match:
key1, key2 = hotkey_match.group(1).lower(), hotkey_match.group(2).lower()
return f'computer.hotkey("{key1}", "{key2}")'
return None
def _add_to_history(self, entry: str) -> None:
"""Add an entry to the rich history (reasoning + action)."""
self.history.append(entry)
def set_demo(self, demo: str) -> None:
"""Set or update the demo trajectory.
This allows setting the demo after initialization,
useful for dynamic demo retrieval.
"""
self.demo = demo
logger.info(f"Demo set ({len(demo)} chars) - will persist across all steps")
def reset(self) -> None:
"""Reset agent state between tasks."""
self.prev_actions = []
self.history = [] # Clear rich history too
self.memory_block_text = "# empty memory block"
self.step_counter = 0
# Note: demo is NOT reset - it persists across resets if set
logger.info("ApiAgent reset")