Files
simular-ai--agent-s/gui_agents/s3/agents/grounding.py
T
wehub-resource-sync c8c954c85d
lint / build (3.10) (push) Failing after 1s
lint / build (3.11) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:23:35 +08:00

668 lines
27 KiB
Python

import re
from collections import defaultdict
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple
import pytesseract
from PIL import Image
from pytesseract import Output
from gui_agents.s3.memory.procedural_memory import PROCEDURAL_MEMORY
from gui_agents.s3.core.mllm import LMMAgent
from gui_agents.s3.utils.common_utils import call_llm_safe
from gui_agents.s3.agents.code_agent import CodeAgent
import logging
logger = logging.getLogger("desktopenv.agent")
class ACI:
def __init__(self):
self.notes: List[str] = []
# Agent action decorator
def agent_action(func):
func.is_agent_action = True
return func
UBUNTU_APP_SETUP = f"""import subprocess;
import difflib;
import pyautogui;
import time;
pyautogui.press('escape');
time.sleep(0.5);
output = subprocess.check_output(['wmctrl', '-lx']);
output = output.decode('utf-8').splitlines();
window_titles = [line.split(None, 4)[2] for line in output];
closest_matches = difflib.get_close_matches('APP_NAME', window_titles, n=1, cutoff=0.1);
if closest_matches:
closest_match = closest_matches[0];
for line in output:
if closest_match in line:
window_id = line.split()[0]
break;
subprocess.run(['wmctrl', '-ia', window_id])
subprocess.run(['wmctrl', '-ir', window_id, '-b', 'add,maximized_vert,maximized_horz'])
"""
SET_CELL_VALUES_CMD = """import uno
import subprocess
import unicodedata, json
def identify_document_type(component):
if component.supportsService("com.sun.star.sheet.SpreadsheetDocument"):
return "Calc"
if component.supportsService("com.sun.star.text.TextDocument"):
return "Writer"
if component.supportsService("com.sun.star.sheet.PresentationDocument"):
return "Impress"
return None
def _norm_name(s: str | None) -> str | None:
if s is None:
return None
if "\\\\u" in s or "\\\\U" in s or "\\\\x" in s:
try:
# json.loads handles all the escape forms safely
s = json.loads(f"{{s}}")
except Exception:
# fallback: best-effort
try:
s = s.encode("utf-8").decode("unicode_escape")
except Exception:
pass
# Normalize (NFC works well across platforms)
return unicodedata.normalize("NFC", s)
def cell_ref_to_indices(cell_ref):
column_letters = ''.join(filter(str.isalpha, cell_ref))
row_number = ''.join(filter(str.isdigit, cell_ref))
col = sum((ord(char.upper()) - ord('A') + 1) * (26**idx) for idx, char in enumerate(reversed(column_letters))) - 1
row = int(row_number) - 1
return col, row
def set_cell_values(new_cell_values: dict[str, str], app_name: str = "Untitled 1", sheet_name: str = "Sheet1"):
app_name = _norm_name(app_name)
sheet_name = _norm_name(sheet_name)
new_cell_values_idx = {{}}
for k, v in new_cell_values.items():
try:
col, row = cell_ref_to_indices(k)
except:
col = row = None
if col is not None and row is not None:
new_cell_values_idx[(col, row)] = v
# Clean up previous TCP connections.
subprocess.run(
'echo \"osworld-public-evaluation\" | sudo -S ss --kill --tcp state TIME-WAIT sport = :2002',
shell=True,
check=True,
text=True,
capture_output=True
)
# Dynamically allow soffice to listen on port 2002.
subprocess.run(
[
"soffice",
"--accept=socket,host=localhost,port=2002;urp;StarOffice.Service"
]
)
local_context = uno.getComponentContext()
resolver = local_context.ServiceManager.createInstanceWithContext(
"com.sun.star.bridge.UnoUrlResolver", local_context
)
context = resolver.resolve(
f"uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext"
)
desktop = context.ServiceManager.createInstanceWithContext(
"com.sun.star.frame.Desktop", context
)
# Collect all LibreOffice-related opened windows.
documents = []
for i, component in enumerate(desktop.Components):
title = component.Title
doc_type = identify_document_type(component)
documents.append((i, component, title, doc_type))
# Find the LibreOffice Calc app and the sheet of interest.
spreadsheet = [doc for doc in documents if doc[3] == "Calc"]
selected_spreadsheet = [doc for doc in spreadsheet if doc[2] == app_name]
if spreadsheet:
try:
if selected_spreadsheet:
spreadsheet = selected_spreadsheet[0][1]
else:
spreadsheet = spreadsheet[0][1]
sheet = spreadsheet.Sheets.getByName(sheet_name)
except:
raise ValueError(f"Could not find sheet {{sheet_name}} in {{app_name}}.")
for (col, row), value in new_cell_values_idx.items():
cell = sheet.getCellByPosition(col, row)
# Set the cell value.
if isinstance(value, (int, float)):
cell.Value = value
elif isinstance(value, str):
if value.startswith("="):
cell.Formula = value
else:
cell.String = value
elif isinstance(value, bool):
cell.Value = 1 if value else 0
elif value is None:
cell.clearContents(0)
else:
raise ValueError(f"Unsupported cell value type: {{type(value)}}")
else:
raise ValueError(f"Could not find LibreOffice Calc app corresponding to {{app_name}}.")
set_cell_values(new_cell_values={cell_values}, app_name="{app_name}", sheet_name="{sheet_name}")
"""
# ACI primitives are parameterized by description, and coordinate generation uses a pretrained grounding model
class OSWorldACI(ACI):
def __init__(
self,
env,
platform: str,
engine_params_for_generation: Dict,
engine_params_for_grounding: Dict,
width: int = 1920,
height: int = 1080,
code_agent_budget: int = 20,
code_agent_engine_params: Dict = None,
):
super().__init__()
self.env = env
self.platform = (
platform # Dictates how the switch_applications agent action works.
)
# Configure scaling
self.width = width
self.height = height
# Maintain state for save_to_knowledge
self.notes = []
# Screenshot used during ACI execution
self.obs = None
# Configure the visual grounding model responsible for coordinate generation
self.grounding_model = LMMAgent(engine_params_for_grounding)
self.engine_params_for_grounding = engine_params_for_grounding
# Configure text grounding agent
self.text_span_agent = LMMAgent(
engine_params=engine_params_for_generation,
system_prompt=PROCEDURAL_MEMORY.PHRASE_TO_WORD_COORDS_PROMPT,
)
# Configure code agent
code_agent_engine_params = (
code_agent_engine_params or engine_params_for_generation
)
self.code_agent = CodeAgent(code_agent_engine_params, code_agent_budget)
# Store task instruction for code agent
self.current_task_instruction = None
self.last_code_agent_result = None
# Given the state and worker's referring expression, use the grounding model to generate (x,y)
def generate_coords(self, ref_expr: str, obs: Dict) -> List[int]:
# Reset the grounding model state
self.grounding_model.reset()
# Configure the context, UI-TARS demo does not use system prompt
prompt = f"Query:{ref_expr}\nOutput only the coordinate of one point in your response.\n"
self.grounding_model.add_message(
text_content=prompt, image_content=obs["screenshot"], put_text_last=True
)
# Generate and parse coordinates
response = call_llm_safe(self.grounding_model)
print("RAW GROUNDING MODEL RESPONSE:", response)
numericals = re.findall(r"\d+", response)
assert len(numericals) >= 2
return [int(numericals[0]), int(numericals[1])]
# Calls pytesseract to generate word level bounding boxes for text grounding
def get_ocr_elements(self, b64_image_data: str) -> Tuple[str, List]:
image = Image.open(BytesIO(b64_image_data))
image_data = pytesseract.image_to_data(image, output_type=Output.DICT)
# Clean text by removing leading and trailing spaces and non-alphabetical characters, but keeping punctuation
for i, word in enumerate(image_data["text"]):
image_data["text"][i] = re.sub(
r"^[^a-zA-Z\s.,!?;:\-\+]+|[^a-zA-Z\s.,!?;:\-\+]+$", "", word
)
ocr_elements = []
ocr_table = "Text Table:\nWord id\tText\n"
# Obtain the <id, text, group number, word number> for each valid element
grouping_map = defaultdict(list)
ocr_id = 0
for i in range(len(image_data["text"])):
block_num = image_data["block_num"][i]
if image_data["text"][i]:
grouping_map[block_num].append(image_data["text"][i])
ocr_table += f"{ocr_id}\t{image_data['text'][i]}\n"
ocr_elements.append(
{
"id": ocr_id,
"text": image_data["text"][i],
"group_num": block_num,
"word_num": len(grouping_map[block_num]),
"left": image_data["left"][i],
"top": image_data["top"][i],
"width": image_data["width"][i],
"height": image_data["height"][i],
}
)
ocr_id += 1
return ocr_table, ocr_elements
# Given the state and worker's text phrase, generate the coords of the first/last word in the phrase
def generate_text_coords(
self, phrase: str, obs: Dict, alignment: str = ""
) -> List[int]:
ocr_table, ocr_elements = self.get_ocr_elements(obs["screenshot"])
alignment_prompt = ""
if alignment == "start":
alignment_prompt = "**Important**: Output the word id of the FIRST word in the provided phrase.\n"
elif alignment == "end":
alignment_prompt = "**Important**: Output the word id of the LAST word in the provided phrase.\n"
# Load LLM prompt
self.text_span_agent.reset()
self.text_span_agent.add_message(
alignment_prompt + "Phrase: " + phrase + "\n" + ocr_table, role="user"
)
self.text_span_agent.add_message(
"Screenshot:\n", image_content=obs["screenshot"], role="user"
)
# Obtain the target element
response = call_llm_safe(self.text_span_agent)
print("TEXT SPAN AGENT RESPONSE:", response)
numericals = re.findall(r"\d+", response)
if len(numericals) > 0:
text_id = int(numericals[-1])
else:
text_id = 0
elem = ocr_elements[text_id]
# Compute the element coordinates
if alignment == "start":
coords = [elem["left"], elem["top"] + (elem["height"] // 2)]
elif alignment == "end":
coords = [elem["left"] + elem["width"], elem["top"] + (elem["height"] // 2)]
else:
coords = [
elem["left"] + (elem["width"] // 2),
elem["top"] + (elem["height"] // 2),
]
return coords
def assign_screenshot(self, obs: Dict):
self.obs = obs
def set_task_instruction(self, task_instruction: str):
"""Set the current task instruction for the code agent."""
self.current_task_instruction = task_instruction
# Resize from grounding model dim into OSWorld dim (1920 * 1080)
def resize_coordinates(self, coordinates: List[int]) -> List[int]:
grounding_width = self.engine_params_for_grounding["grounding_width"]
grounding_height = self.engine_params_for_grounding["grounding_height"]
return [
round(coordinates[0] * self.width / grounding_width),
round(coordinates[1] * self.height / grounding_height),
]
@agent_action
def click(
self,
element_description: str,
num_clicks: int = 1,
button_type: str = "left",
hold_keys: List = [],
):
"""Click on the element
Args:
element_description:str, a detailed descriptions of which element to click on. This description should be at least a full sentence.
num_clicks:int, number of times to click the element
button_type:str, which mouse button to press can be "left", "middle", or "right"
hold_keys:List, list of keys to hold while clicking
"""
coords1 = self.generate_coords(element_description, self.obs)
x, y = self.resize_coordinates(coords1)
command = "import pyautogui; "
# TODO: specified duration?
for k in hold_keys:
command += f"pyautogui.keyDown({repr(k)}); "
command += f"""import pyautogui; pyautogui.click({x}, {y}, clicks={num_clicks}, button={repr(button_type)}); """
for k in hold_keys:
command += f"pyautogui.keyUp({repr(k)}); "
# Return pyautoguicode to click on the element
return command
@agent_action
def switch_applications(self, app_code):
"""Switch to a different application that is already open
Args:
app_code:str the code name of the application to switch to from the provided list of open applications
"""
if self.platform == "darwin":
return f"import pyautogui; import time; pyautogui.hotkey('command', 'space', interval=0.5); pyautogui.typewrite({repr(app_code)}); pyautogui.press('enter'); time.sleep(1.0)"
elif self.platform == "linux":
return UBUNTU_APP_SETUP.replace("APP_NAME", app_code)
elif self.platform == "windows":
return f"import pyautogui; import time; pyautogui.hotkey('win', 'd', interval=0.5); pyautogui.typewrite({repr(app_code)}); pyautogui.press('enter'); time.sleep(1.0)"
else:
assert (
False
), f"Unsupported platform: {self.platform}. Supported platforms are: darwin, linux, windows."
@agent_action
def open(self, app_or_filename: str):
"""Open any application or file with name app_or_filename. Use this action to open applications or files on the desktop, do not open manually.
Args:
app_or_filename:str, the name of the application or filename to open
"""
if self.platform == "linux":
return f"import pyautogui; import time; pyautogui.hotkey('win'); time.sleep(0.5); pyautogui.write({repr(app_or_filename)}); time.sleep(1.0); pyautogui.hotkey('enter'); time.sleep(0.5)"
elif self.platform == "darwin":
return f"import pyautogui; import time; pyautogui.hotkey('command', 'space', interval=0.5); pyautogui.typewrite({repr(app_or_filename)}); pyautogui.press('enter'); time.sleep(1.0)"
elif self.platform == "windows":
return (
"import pyautogui; import time; "
"pyautogui.hotkey('win'); time.sleep(0.5); "
f"pyautogui.write({repr(app_or_filename)}); time.sleep(1.0); "
"pyautogui.press('enter'); time.sleep(0.5)"
)
else:
assert (
False
), f"Unsupported platform: {self.platform}. Supported platforms are: darwin, linux, windows."
@agent_action
def type(
self,
element_description: Optional[str] = None,
text: str = "",
overwrite: bool = False,
enter: bool = False,
):
"""Type text/unicode into a specific element
Args:
element_description:str, a detailed description of which element to enter text in. This description should be at least a full sentence.
text:str, the text to type
overwrite:bool, Assign it to True if the text should overwrite the existing text, otherwise assign it to False. Using this argument clears all text in an element.
enter:bool, Assign it to True if the enter key should be pressed after typing the text, otherwise assign it to False.
"""
command = "import pyautogui; "
command += (
"\ntry:\n"
" import pyperclip\n"
"except ImportError:\n"
" import subprocess\n"
" subprocess.run('echo \"osworld-public-evaluation\" | sudo -S apt-get install -y xclip xsel', shell=True, check=True)\n"
" subprocess.check_call([subprocess.sys.executable, '-m', 'pip', 'install', 'pyperclip'])\n"
" import pyperclip\n\n"
)
if element_description is not None:
coords1 = self.generate_coords(element_description, self.obs)
x, y = self.resize_coordinates(coords1)
command += f"pyautogui.click({x}, {y}); "
if overwrite:
command += (
f"pyautogui.hotkey({repr('command' if self.platform == 'darwin' else 'ctrl')}, 'a'); "
"pyautogui.press('backspace'); "
)
# Check if text contains Unicode characters that pyautogui.write() can't handle
has_unicode = any(ord(char) > 127 for char in text)
if has_unicode:
# Use clipboard method for Unicode characters
command += f"pyperclip.copy({repr(text)}); "
command += f"pyautogui.hotkey({repr('command' if self.platform == 'darwin' else 'ctrl')}, 'v'); "
else:
# Use regular pyautogui.write() for ASCII text
command += f"pyautogui.write({repr(text)}); "
if enter:
command += "pyautogui.press('enter'); "
return command
@agent_action
def save_to_knowledge(self, text: List[str]):
"""Save facts, elements, texts, etc. to a long-term knowledge bank for reuse during this task. Can be used for copy-pasting text, saving elements, etc.
Args:
text:List[str] the text to save to the knowledge
"""
self.notes.extend(text)
return """WAIT"""
@agent_action
def drag_and_drop(
self, starting_description: str, ending_description: str, hold_keys: List = []
):
"""Drag from the starting description to the ending description
Args:
starting_description:str, a very detailed description of where to start the drag action. This description should be at least a full sentence.
ending_description:str, a very detailed description of where to end the drag action. This description should be at least a full sentence.
hold_keys:List list of keys to hold while dragging
"""
coords1 = self.generate_coords(starting_description, self.obs)
coords2 = self.generate_coords(ending_description, self.obs)
x1, y1 = self.resize_coordinates(coords1)
x2, y2 = self.resize_coordinates(coords2)
command = "import pyautogui; "
command += f"pyautogui.moveTo({x1}, {y1}); "
# TODO: specified duration?
for k in hold_keys:
command += f"pyautogui.keyDown({repr(k)}); "
command += f"pyautogui.dragTo({x2}, {y2}, duration=1., button='left'); pyautogui.mouseUp(); "
for k in hold_keys:
command += f"pyautogui.keyUp({repr(k)}); "
# Return pyautoguicode to drag and drop the elements
return command
@agent_action
def highlight_text_span(
self, starting_phrase: str, ending_phrase: str, button: str = "left"
):
"""Highlight a text span between a provided starting phrase and ending phrase. Use this to highlight words, lines, and paragraphs.
Args:
starting_phrase:str, the phrase that denotes the start of the text span you want to highlight. If you only want to highlight one word, just pass in that single word.
ending_phrase:str, the phrase that denotes the end of the text span you want to highlight. If you only want to highlight one word, just pass in that single word.
button:str, the button to use to highlight the text span. Defaults to "left". Can be "left", "right", or "middle".
"""
coords1 = self.generate_text_coords(
starting_phrase, self.obs, alignment="start"
)
coords2 = self.generate_text_coords(ending_phrase, self.obs, alignment="end")
x1, y1 = coords1
x2, y2 = coords2
command = "import pyautogui; "
command += f"pyautogui.moveTo({x1}, {y1}); "
command += f"pyautogui.dragTo({x2}, {y2}, duration=1., button='{button}'); pyautogui.mouseUp(); "
# Return pyautoguicode to drag and drop the elements
return command
@agent_action
def set_cell_values(
self, cell_values: Dict[str, Any], app_name: str, sheet_name: str
):
"""Use this to set individual cell values in a spreadsheet. For example, setting A2 to "hello" would be done by passing {"A2": "hello"} as cell_values. The sheet must be opened before this command can be used.
Args:
cell_values: Dict[str, Any], A dictionary of cell values to set in the spreadsheet. The keys are the cell coordinates in the format "A1", "B2", etc.
Supported value types include: float, int, string, bool, formulas.
app_name: str, The name of the spreadsheet application. For example, "Some_sheet.xlsx".
sheet_name: str, The name of the sheet in the spreadsheet. For example, "Sheet1".
"""
return SET_CELL_VALUES_CMD.format(
cell_values=cell_values, app_name=app_name, sheet_name=sheet_name
)
@agent_action
def call_code_agent(self, task: str = None):
"""Call the code agent to execute code for tasks or subtasks that can be completed solely with coding.
Args:
task: str, the task or subtask to execute. If None, uses the current full task instruction.
**🚨 CRITICAL GUIDELINES:**
- **ONLY pass a task parameter for SPECIFIC subtasks** (e.g., "Calculate sum of column B", "Filter data by date")
- **NEVER pass a task parameter for full tasks** - let it default to the original task instruction
- **NEVER rephrase or modify the original task** - this prevents hallucination corruption
- **If unsure, omit the task parameter entirely** to use the original task instruction
Use this for tasks that can be fully accomplished through code execution, particularly for:
- Spreadsheet applications (LibreOffice Calc, Excel): data processing, filtering, sorting, calculations, formulas, data analysis
- Document editors (LibreOffice Writer, Word): text processing, content editing, formatting, document manipulation
- Code editors (VS Code, text editors): code editing, file processing, text manipulation, configuration
- Data analysis tools: statistical analysis, data transformation, reporting
- File management: bulk operations, file processing, content extraction
- System utilities: configuration, setup, automation
"""
logger.info("=" * 50)
logger.info("GROUNDING AGENT: Calling Code Agent")
logger.info("=" * 50)
# **CRITICAL**: Only use provided task for specific subtasks, otherwise use original task instruction
if task is not None:
# This is a subtask - use the provided task
task_to_execute = task
logger.info(f"Executing SUBTASK: {task_to_execute}")
else:
# This is a full task - use the original task instruction to prevent hallucination
task_to_execute = self.current_task_instruction
logger.info(f"Executing FULL TASK: {task_to_execute}")
if task_to_execute:
print("obs keys: ", self.obs.keys())
screenshot = self.obs.get("screenshot", "") if self.obs else ""
logger.info(f"Screenshot available: {'Yes' if screenshot else 'No'}")
logger.info("Executing code agent...")
result = self.code_agent.execute(
task_to_execute, screenshot, self.env.controller
)
# Store the result for the worker to access
self.last_code_agent_result = result
logger.info("Code agent execution completed")
logger.info(f"Result - Completion reason: {result['completion_reason']}")
logger.info(f"Steps executed: {result['steps_executed']}")
logger.info(f"Summary: {result['summary']}")
logger.info("=" * 50)
logger.info("GROUNDING AGENT: Code Agent Call Finished")
logger.info("=" * 50)
# Return code to be executed in the environment
return "import time; time.sleep(2.222)"
else:
logger.warning("No task instruction available for code agent call")
return "import time; time.sleep(1.111)"
@agent_action
def scroll(self, element_description: str, clicks: int, shift: bool = False):
"""Scroll the element in the specified direction
Args:
element_description:str, a very detailed description of which element to enter scroll in. This description should be at least a full sentence.
clicks:int, the number of clicks to scroll can be positive (up) or negative (down).
shift:bool, whether to use shift+scroll for horizontal scrolling
"""
coords1 = self.generate_coords(element_description, self.obs)
x, y = self.resize_coordinates(coords1)
if shift:
return f"import pyautogui; import time; pyautogui.moveTo({x}, {y}); time.sleep(0.5); pyautogui.hscroll({clicks})"
else:
return f"import pyautogui; import time; pyautogui.moveTo({x}, {y}); time.sleep(0.5); pyautogui.vscroll({clicks})"
@agent_action
def hotkey(self, keys: List):
"""Press a hotkey combination
Args:
keys:List the keys to press in combination in a list format (e.g. ['ctrl', 'c'])
"""
# add quotes around the keys
keys = [f"'{key}'" for key in keys]
return f"import pyautogui; pyautogui.hotkey({', '.join(keys)})"
@agent_action
def hold_and_press(self, hold_keys: List, press_keys: List):
"""Hold a list of keys and press a list of keys
Args:
hold_keys:List, list of keys to hold
press_keys:List, list of keys to press in a sequence
"""
press_keys_str = "[" + ", ".join([f"'{key}'" for key in press_keys]) + "]"
command = "import pyautogui; "
for k in hold_keys:
command += f"pyautogui.keyDown({repr(k)}); "
command += f"pyautogui.press({press_keys_str}); "
for k in hold_keys:
command += f"pyautogui.keyUp({repr(k)}); "
return command
@agent_action
def wait(self, time: float):
"""Wait for a specified amount of time
Args:
time:float the amount of time to wait in seconds
"""
return f"""import time; time.sleep({time})"""
@agent_action
def done(
self,
):
"""End the current task with a success. Use this when you believe the entire task has been fully completed."""
return """DONE"""
@agent_action
def fail(self):
"""End the current task with a failure. Use this when you believe the entire task is impossible to complete."""
return """FAIL"""