chore: import upstream snapshot with attribution
lint / build (3.10) (push) Failing after 1s
lint / build (3.11) (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:35 +08:00
commit c8c954c85d
127 changed files with 22519 additions and 0 deletions
View File
View File
+93
View File
@@ -0,0 +1,93 @@
import logging
import platform
from typing import Dict, List, Tuple
from gui_agents.s2_5.agents.grounding import ACI
from gui_agents.s2_5.agents.worker import Worker
logger = logging.getLogger("desktopenv.agent")
class UIAgent:
"""Base class for UI automation agents"""
def __init__(
self,
engine_params: Dict,
grounding_agent: ACI,
platform: str = platform.system().lower(),
):
"""Initialize UIAgent
Args:
engine_params: Configuration parameters for the LLM engine
grounding_agent: Instance of ACI class for UI interaction
platform: Operating system platform (macos, linux, windows)
"""
self.engine_params = engine_params
self.grounding_agent = grounding_agent
self.platform = platform
def reset(self) -> None:
"""Reset agent state"""
pass
def predict(self, instruction: str, observation: Dict) -> Tuple[Dict, List[str]]:
"""Generate next action prediction
Args:
instruction: Natural language instruction
observation: Current UI state observation
Returns:
Tuple containing agent info dictionary and list of actions
"""
pass
class AgentS2_5(UIAgent):
"""Agent that uses no hierarchy for less inference time"""
def __init__(
self,
engine_params: Dict,
grounding_agent: ACI,
platform: str = platform.system().lower(),
max_trajectory_length: int = 8,
enable_reflection: bool = True,
):
"""Initialize a minimalist AgentS2 without hierarchy
Args:
engine_params: Configuration parameters for the LLM engine
grounding_agent: Instance of ACI class for UI interaction
platform: Operating system platform (darwin, linux, windows)
max_trajectory_length: Maximum number of image turns to keep
enable_reflection: Creates a reflection agent to assist the worker agent
"""
super().__init__(engine_params, grounding_agent, platform)
self.max_trajectory_length = max_trajectory_length
self.enable_reflection = enable_reflection
self.reset()
def reset(self) -> None:
"""Reset agent state and initialize components"""
self.executor = Worker(
engine_params=self.engine_params,
grounding_agent=self.grounding_agent,
platform=self.platform,
max_trajectory_length=self.max_trajectory_length,
enable_reflection=self.enable_reflection,
)
def predict(self, instruction: str, observation: Dict) -> Tuple[Dict, List[str]]:
# Initialize the three info dictionaries
executor_info, actions = self.executor.generate_next_action(
instruction=instruction, obs=observation
)
# concatenate the three info dictionaries
info = {**{k: v for d in [executor_info or {}] for k, v in d.items()}}
return info, actions
+617
View File
@@ -0,0 +1,617 @@
import ast
import re
from collections import defaultdict
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple, Union
import pytesseract
from PIL import Image
from pytesseract import Output
from gui_agents.s2_5.memory.procedural_memory import PROCEDURAL_MEMORY
from gui_agents.s2_5.core.mllm import LMMAgent
from gui_agents.s2_5.utils.common_utils import (
call_llm_safe,
parse_single_code_from_string,
)
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;
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
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 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"):
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,
platform: str,
engine_params_for_generation: Dict,
engine_params_for_grounding: Dict,
width: int = 1920,
height: int = 1080,
):
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 = []
# Coordinates used during ACI execution
self.coords1 = None
self.coords2 = 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,
)
# 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
# Takes a description based action and assigns the coordinates for any coordinate based action
# Raises an error if function can't be parsed
def assign_coordinates(self, plan: str, obs: Dict):
# Reset coords from previous action generation
self.coords1, self.coords2 = None, None
try:
# Extract the function name and args
action = parse_single_code_from_string(plan.split("Grounded Action")[-1])
function_name = re.match(r"(\w+\.\w+)\(", action).group(1)
args = self.parse_function_args(action)
except Exception as e:
raise RuntimeError(f"Error in parsing grounded action: {e}") from e
# arg0 is a description
if (
function_name in ["agent.click", "agent.type", "agent.scroll"]
and len(args) >= 1
and args[0] != None
):
self.coords1 = self.generate_coords(args[0], obs)
# arg0 and arg1 are descriptions
elif function_name == "agent.drag_and_drop" and len(args) >= 2:
self.coords1 = self.generate_coords(args[0], obs)
self.coords2 = self.generate_coords(args[1], obs)
# arg0 and arg1 are text phrases
elif function_name == "agent.highlight_text_span" and len(args) >= 2:
self.coords1 = self.generate_text_coords(args[0], obs, alignment="start")
self.coords2 = self.generate_text_coords(args[1], obs, alignment="end")
# 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),
]
# Given a generated ACI function, returns a list of argument values, where descriptions are at the front of the list
def parse_function_args(self, function: str) -> List[str]:
tree = ast.parse(function)
call_node = tree.body[0].value
def safe_eval(node):
if isinstance(
node, ast.Constant
): # Handles literals like numbers, strings, etc.
return node.value
else:
return ast.unparse(node) # Return as a string if not a literal
positional_args = [safe_eval(arg) for arg in call_node.args]
keyword_args = {kw.arg: safe_eval(kw.value) for kw in call_node.keywords}
res = []
for key, val in keyword_args.items():
if "description" in key:
res.append(val)
for arg in positional_args:
res.append(arg)
return res
@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
"""
x, y = self.resize_coordinates(self.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)"
@agent_action
def open(self, app_or_filename: str):
"""Open any application or file with name app_or_filename. This action should be used on Linux/Darwin systems instead of opening the file manually. Do not use on Windows.
Args:
app_or_filename:str, the name of the application or filename to open
"""
if self.platform == "linux":
return f"import pyautogui; 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)"
@agent_action
def type(
self,
element_description: Optional[str] = None,
text: str = "",
overwrite: bool = False,
enter: bool = False,
):
"""Type text 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.
"""
select_mod = "command" if self.platform == "darwin" else "ctrl"
if self.coords1 is not None:
# If a node is found, retrieve its coordinates and size
# Start typing at the center of the element
x, y = self.resize_coordinates(self.coords1)
command = "import pyautogui; "
command += f"pyautogui.click({x}, {y}); "
if overwrite:
command += (
f"pyautogui.hotkey({repr(select_mod)}, 'a'); "
"pyautogui.press('backspace'); "
)
command += f"pyautogui.write({repr(text)}); "
if enter:
command += "pyautogui.press('enter'); "
else:
# If no element is found, start typing at the current cursor location
command = "import pyautogui; "
if overwrite:
command += (
f"pyautogui.hotkey({repr(select_mod)}, 'a'); "
"pyautogui.press('backspace'); "
)
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
"""
x1, y1 = self.resize_coordinates(self.coords1)
x2, y2 = self.resize_coordinates(self.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".
"""
x1, y1 = self.coords1
x2, y2 = self.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 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
"""
x, y = self.resize_coordinates(self.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,
return_value: Optional[Union[Dict, str, List, Tuple, int, float, bool]] = None,
):
"""End the current task with a success and the required return value"""
self.returned_info = return_value
return """DONE"""
@agent_action
def fail(self):
"""End the current task with a failure, and replan the whole task."""
return """FAIL"""
# ACI that supports the worker-only mode: done() and fail() become task scoped instead
class OSWorldWorkerOnlyACI(OSWorldACI):
@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"""
+204
View File
@@ -0,0 +1,204 @@
import logging
import textwrap
from typing import Dict, List, Tuple
from gui_agents.s2_5.agents.grounding import ACI
from gui_agents.s2_5.core.module import BaseModule
from gui_agents.s2_5.memory.procedural_memory import PROCEDURAL_MEMORY
from gui_agents.s2_5.utils.common_utils import (
call_llm_safe,
extract_first_agent_function,
parse_single_code_from_string,
sanitize_code,
split_thinking_response,
)
logger = logging.getLogger("desktopenv.agent")
class Worker(BaseModule):
def __init__(
self,
engine_params: Dict,
grounding_agent: ACI,
platform: str = "ubuntu",
max_trajectory_length: int = 8,
enable_reflection: bool = True,
):
"""
Worker receives the main task and generates actions, without the need of hierarchical planning
Args:
engine_params: Dict
Parameters for the multimodal engine
grounding_agent: Agent
The grounding agent to use
platform: str
OS platform the agent runs on (darwin, linux, windows)
max_trajectory_length: int
The amount of images turns to keep
enable_reflection: bool
Whether to enable reflection
"""
super().__init__(engine_params, platform)
self.grounding_agent = grounding_agent
self.max_trajectory_length = max_trajectory_length
self.enable_reflection = enable_reflection
self.temperature = engine_params.get("temperature", 0.0)
self.use_thinking = engine_params.get("model", "") in [
"claude-3-7-sonnet-20250219"
]
self.reset()
def reset(self):
if self.platform != "linux":
skipped_actions = ["set_cell_values"]
else:
skipped_actions = []
sys_prompt = PROCEDURAL_MEMORY.construct_simple_worker_procedural_memory(
type(self.grounding_agent), skipped_actions=skipped_actions
).replace("CURRENT_OS", self.platform)
self.generator_agent = self._create_agent(sys_prompt)
self.reflection_agent = self._create_agent(
PROCEDURAL_MEMORY.REFLECTION_ON_TRAJECTORY
)
self.turn_count = 0
self.worker_history = []
self.reflections = []
self.cost_this_turn = 0
self.screenshot_inputs = []
# Flushing strategy dependant on model context limits
def flush_messages(self):
engine_type = self.engine_params.get("engine_type", "")
# Flush strategy for long-context models: keep all text, only keep latest images
if engine_type in ["anthropic", "openai", "gemini"]:
max_images = self.max_trajectory_length
for agent in [self.generator_agent, self.reflection_agent]:
# keep latest k images
img_count = 0
for i in range(len(agent.messages) - 1, -1, -1):
for j in range(len(agent.messages[i]["content"])):
if "image" in agent.messages[i]["content"][j].get("type", ""):
img_count += 1
if img_count > max_images:
del agent.messages[i]["content"][j]
# Flush strategy for non-long-context models: drop full turns
else:
# generator msgs are alternating [user, assistant], so 2 per round
if len(self.generator_agent.messages) > 2 * self.max_trajectory_length + 1:
self.generator_agent.messages.pop(1)
self.generator_agent.messages.pop(1)
# reflector msgs are all [(user text, user image)], so 1 per round
if len(self.reflection_agent.messages) > self.max_trajectory_length + 1:
self.reflection_agent.messages.pop(1)
def generate_next_action(
self,
instruction: str,
obs: Dict,
) -> Tuple[Dict, List]:
"""
Predict the next action(s) based on the current observation.
"""
agent = self.grounding_agent
generator_message = (
""
if self.turn_count > 0
else "The initial screen is provided. No action has been taken yet."
)
# Load the task into the system prompt
if self.turn_count == 0:
self.generator_agent.add_system_prompt(
self.generator_agent.system_prompt.replace(
"TASK_DESCRIPTION", instruction
)
)
# Get the per-step reflection
reflection = None
reflection_thoughts = None
if self.enable_reflection:
# Load the initial message
if self.turn_count == 0:
text_content = textwrap.dedent(f"""
Task Description: {instruction}
Current Trajectory below:
""")
updated_sys_prompt = (
self.reflection_agent.system_prompt + "\n" + text_content
)
self.reflection_agent.add_system_prompt(updated_sys_prompt)
self.reflection_agent.add_message(
text_content="The initial screen is provided. No action has been taken yet.",
image_content=obs["screenshot"],
role="user",
)
# Load the latest action
else:
self.reflection_agent.add_message(
text_content=self.worker_history[-1],
image_content=obs["screenshot"],
role="user",
)
full_reflection = call_llm_safe(
self.reflection_agent,
temperature=self.temperature,
use_thinking=self.use_thinking,
)
reflection, reflection_thoughts = split_thinking_response(
full_reflection
)
self.reflections.append(reflection)
generator_message += f"REFLECTION: You may use this reflection on the previous action and overall trajectory:\n{reflection}\n"
logger.info("REFLECTION: %s", reflection)
# Add finalized message to conversation
generator_message += f"\nCurrent Text Buffer = [{','.join(agent.notes)}]\n"
self.generator_agent.add_message(
generator_message, image_content=obs["screenshot"], role="user"
)
full_plan = call_llm_safe(
self.generator_agent,
temperature=self.temperature,
use_thinking=self.use_thinking,
)
plan, plan_thoughts = split_thinking_response(full_plan)
# NOTE: currently dropping thinking tokens from context
self.worker_history.append(plan)
logger.info("FULL PLAN:\n %s", full_plan)
self.generator_agent.add_message(plan, role="assistant")
# Use the grounding agent to convert agent_action("desc") into agent_action([x, y])
try:
agent.assign_coordinates(plan, obs)
plan_code = parse_single_code_from_string(plan.split("Grounded Action")[-1])
plan_code = sanitize_code(plan_code)
plan_code = extract_first_agent_function(plan_code)
exec_code = eval(plan_code)
except Exception as e:
logger.error("Error in parsing plan code: %s", e)
plan_code = "agent.wait(1.0)"
exec_code = eval(plan_code)
executor_info = {
"full_plan": full_plan,
"executor_plan": plan,
"plan_thoughts": plan_thoughts,
"plan_code": plan_code,
"reflection": reflection,
"reflection_thoughts": reflection_thoughts,
}
self.turn_count += 1
self.screenshot_inputs.append(obs["screenshot"])
self.flush_messages()
return executor_info, [exec_code]
+369
View File
@@ -0,0 +1,369 @@
import argparse
import datetime
import io
import logging
import os
import platform
import pyautogui
import signal
import sys
import time
from PIL import Image
from gui_agents.s2_5.agents.grounding import OSWorldACI
from gui_agents.s2_5.agents.agent_s import AgentS2_5
current_platform = platform.system().lower()
# Global flag to track pause state for debugging
paused = False
def get_char():
"""Get a single character from stdin without pressing Enter"""
try:
# Import termios and tty on Unix-like systems
if platform.system() in ["Darwin", "Linux"]:
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
else:
# Windows fallback
import msvcrt
return msvcrt.getch().decode("utf-8", errors="ignore")
except:
return input() # Fallback for non-terminal environments
def signal_handler(signum, frame):
"""Handle Ctrl+C signal for debugging during agent execution"""
global paused
if not paused:
print("\n\n🔸 Agent-S Workflow Paused 🔸")
print("=" * 50)
print("Options:")
print(" • Press Ctrl+C again to quit")
print(" • Press Esc to resume workflow")
print("=" * 50)
paused = True
while paused:
try:
print("\n[PAUSED] Waiting for input... ", end="", flush=True)
char = get_char()
if ord(char) == 3: # Ctrl+C
print("\n\n🛑 Exiting Agent-S...")
sys.exit(0)
elif ord(char) == 27: # Esc
print("\n\n▶️ Resuming Agent-S workflow...")
paused = False
break
else:
print(f"\n Unknown command: '{char}' (ord: {ord(char)})")
except KeyboardInterrupt:
print("\n\n🛑 Exiting Agent-S...")
sys.exit(0)
else:
# Already paused, second Ctrl+C means quit
print("\n\n🛑 Exiting Agent-S...")
sys.exit(0)
# Set up signal handler for Ctrl+C
signal.signal(signal.SIGINT, signal_handler)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
datetime_str: str = datetime.datetime.now().strftime("%Y%m%d@%H%M%S")
log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)
file_handler = logging.FileHandler(
os.path.join("logs", "normal-{:}.log".format(datetime_str)), encoding="utf-8"
)
debug_handler = logging.FileHandler(
os.path.join("logs", "debug-{:}.log".format(datetime_str)), encoding="utf-8"
)
stdout_handler = logging.StreamHandler(sys.stdout)
sdebug_handler = logging.FileHandler(
os.path.join("logs", "sdebug-{:}.log".format(datetime_str)), encoding="utf-8"
)
file_handler.setLevel(logging.INFO)
debug_handler.setLevel(logging.DEBUG)
stdout_handler.setLevel(logging.INFO)
sdebug_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
fmt="\x1b[1;33m[%(asctime)s \x1b[31m%(levelname)s \x1b[32m%(module)s/%(lineno)d-%(processName)s\x1b[1;33m] \x1b[0m%(message)s"
)
file_handler.setFormatter(formatter)
debug_handler.setFormatter(formatter)
stdout_handler.setFormatter(formatter)
sdebug_handler.setFormatter(formatter)
stdout_handler.addFilter(logging.Filter("desktopenv"))
sdebug_handler.addFilter(logging.Filter("desktopenv"))
logger.addHandler(file_handler)
logger.addHandler(debug_handler)
logger.addHandler(stdout_handler)
logger.addHandler(sdebug_handler)
platform_os = platform.system()
def show_permission_dialog(code: str, action_description: str):
"""Show a platform-specific permission dialog and return True if approved."""
if platform.system() == "Darwin":
result = os.system(
f'osascript -e \'display dialog "Do you want to execute this action?\n\n{code} which will try to {action_description}" with title "Action Permission" buttons {{"Cancel", "OK"}} default button "OK" cancel button "Cancel"\''
)
return result == 0
elif platform.system() == "Linux":
result = os.system(
f'zenity --question --title="Action Permission" --text="Do you want to execute this action?\n\n{code}" --width=400 --height=200'
)
return result == 0
return False
def scale_screen_dimensions(width: int, height: int, max_dim_size: int):
scale_factor = min(max_dim_size / width, max_dim_size / height, 1)
safe_width = int(width * scale_factor)
safe_height = int(height * scale_factor)
return safe_width, safe_height
def run_agent(agent, instruction: str, scaled_width: int, scaled_height: int):
global paused
obs = {}
traj = "Task:\n" + instruction
subtask_traj = ""
for step in range(15):
# Check if we're in paused state and wait
while paused:
time.sleep(0.1)
# Get screen shot using pyautogui
screenshot = pyautogui.screenshot()
screenshot = screenshot.resize((scaled_width, scaled_height), Image.LANCZOS)
# Save the screenshot to a BytesIO object
buffered = io.BytesIO()
screenshot.save(buffered, format="PNG")
# Get the byte value of the screenshot
screenshot_bytes = buffered.getvalue()
# Convert to base64 string.
obs["screenshot"] = screenshot_bytes
# Check again for pause state before prediction
while paused:
time.sleep(0.1)
print(f"\n🔄 Step {step + 1}/15: Getting next action from agent...")
# Get next action code from the agent
info, code = agent.predict(instruction=instruction, observation=obs)
if "done" in code[0].lower() or "fail" in code[0].lower():
if platform.system() == "Darwin":
os.system(
f'osascript -e \'display dialog "Task Completed" with title "OpenACI Agent" buttons "OK" default button "OK"\''
)
elif platform.system() == "Linux":
os.system(
f'zenity --info --title="OpenACI Agent" --text="Task Completed" --width=200 --height=100'
)
break
if "next" in code[0].lower():
continue
if "wait" in code[0].lower():
print("⏳ Agent requested wait...")
time.sleep(5)
continue
else:
time.sleep(1.0)
print("EXECUTING CODE:", code[0])
# Check for pause state before execution
while paused:
time.sleep(0.1)
# Ask for permission before executing
exec(code[0])
time.sleep(1.0)
# Update task and subtask trajectories
if "reflection" in info and "executor_plan" in info:
traj += (
"\n\nReflection:\n"
+ str(info["reflection"])
+ "\n\n----------------------\n\nPlan:\n"
+ info["executor_plan"]
)
def main():
parser = argparse.ArgumentParser(description="Run AgentS2_5 with specified model.")
parser.add_argument(
"--provider",
type=str,
default="openai",
help="Specify the provider to use (e.g., openai, anthropic, etc.)",
)
parser.add_argument(
"--model",
type=str,
default="gpt-5-2025-08-07",
help="Specify the model to use (e.g., gpt-5-2025-08-07)",
)
parser.add_argument(
"--model_url",
type=str,
default="",
help="The URL of the main generation model API.",
)
parser.add_argument(
"--model_api_key",
type=str,
default="",
help="The API key of the main generation model.",
)
parser.add_argument(
"--model_temperature",
type=float,
default=None,
help="Temperature to fix the generation model at (e.g. o3 can only be run with 1.0)",
)
# Grounding model config: Self-hosted endpoint based (required)
parser.add_argument(
"--ground_provider",
type=str,
required=True,
help="The provider for the grounding model",
)
parser.add_argument(
"--ground_url",
type=str,
required=True,
help="The URL of the grounding model",
)
parser.add_argument(
"--ground_api_key",
type=str,
default="",
help="The API key of the grounding model.",
)
parser.add_argument(
"--ground_model",
type=str,
required=True,
help="The model name for the grounding model",
)
parser.add_argument(
"--grounding_width",
type=int,
required=True,
help="Width of screenshot image after processor rescaling",
)
parser.add_argument(
"--grounding_height",
type=int,
required=True,
help="Height of screenshot image after processor rescaling",
)
# AgentS2_5 specific arguments
parser.add_argument(
"--max_trajectory_length",
type=int,
default=8,
help="Maximum number of image turns to keep in trajectory",
)
parser.add_argument(
"--enable_reflection",
action="store_true",
default=True,
help="Enable reflection agent to assist the worker agent",
)
args = parser.parse_args()
# Re-scales screenshot size to ensure it fits in UI-TARS context limit
screen_width, screen_height = pyautogui.size()
scaled_width, scaled_height = scale_screen_dimensions(
screen_width, screen_height, max_dim_size=2400
)
# Load the general engine params
engine_params = {
"engine_type": args.provider,
"model": args.model,
"base_url": args.model_url,
"api_key": args.model_api_key,
"temperature": getattr(args, "model_temperature", None),
}
# Load the grounding engine from a custom endpoint
engine_params_for_grounding = {
"engine_type": args.ground_provider,
"model": args.ground_model,
"base_url": args.ground_url,
"api_key": args.ground_api_key,
"grounding_width": args.grounding_width,
"grounding_height": args.grounding_height,
}
grounding_agent = OSWorldACI(
platform=current_platform,
engine_params_for_generation=engine_params,
engine_params_for_grounding=engine_params_for_grounding,
width=screen_width,
height=screen_height,
)
agent = AgentS2_5(
engine_params,
grounding_agent,
platform=current_platform,
max_trajectory_length=args.max_trajectory_length,
enable_reflection=args.enable_reflection,
)
while True:
query = input("Query: ")
agent.reset()
# Run the agent on your own device
run_agent(agent, query, scaled_width, scaled_height)
response = input("Would you like to provide another query? (y/n): ")
if response.lower() != "y":
break
if __name__ == "__main__":
main()
View File
+441
View File
@@ -0,0 +1,441 @@
import os
import backoff
from anthropic import Anthropic
from openai import (
AzureOpenAI,
APIConnectionError,
APIError,
AzureOpenAI,
OpenAI,
RateLimitError,
)
class LMMEngine:
pass
class LMMEngineOpenAI(LMMEngine):
def __init__(
self,
base_url=None,
api_key=None,
model=None,
rate_limit=-1,
temperature=None,
organization=None,
**kwargs,
):
assert model is not None, "model must be provided"
self.model = model
self.base_url = base_url
self.api_key = api_key
self.organization = organization
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
self.temperature = temperature # Can force temperature to be the same (in the case of o3 requiring temperature to be 1)
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("OPENAI_API_KEY")
if api_key is None:
raise ValueError(
"An API Key needs to be provided in either the api_key parameter or as an environment variable named OPENAI_API_KEY"
)
organization = self.organization or os.getenv("OPENAI_ORG_ID")
if not self.llm_client:
if not self.base_url:
self.llm_client = OpenAI(api_key=api_key, organization=organization)
else:
self.llm_client = OpenAI(
base_url=self.base_url, api_key=api_key, organization=organization
)
return (
self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_completion_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=(
temperature if self.temperature is None else self.temperature
),
**kwargs,
)
.choices[0]
.message.content
)
class LMMEngineAnthropic(LMMEngine):
def __init__(
self,
base_url=None,
api_key=None,
model=None,
thinking=False,
temperature=None,
**kwargs,
):
assert model is not None, "model must be provided"
self.model = model
self.thinking = thinking
self.api_key = api_key
self.llm_client = None
self.temperature = temperature
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("ANTHROPIC_API_KEY")
if api_key is None:
raise ValueError(
"An API Key needs to be provided in either the api_key parameter or as an environment variable named ANTHROPIC_API_KEY"
)
if not self.llm_client:
self.llm_client = Anthropic(api_key=api_key)
# Use the instance temperature if not specified in the call
temp = self.temperature if temperature is None else temperature
if self.thinking:
full_response = self.llm_client.messages.create(
system=messages[0]["content"][0]["text"],
model=self.model,
messages=messages[1:],
max_tokens=8192,
thinking={"type": "enabled", "budget_tokens": 4096},
**kwargs,
)
thoughts = full_response.content[0].thinking
return full_response.content[1].text
return (
self.llm_client.messages.create(
system=messages[0]["content"][0]["text"],
model=self.model,
messages=messages[1:],
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temp,
**kwargs,
)
.content[0]
.text
)
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
# Compatible with Claude-3.7 Sonnet thinking mode
def generate_with_thinking(
self, messages, temperature=0.0, max_new_tokens=None, **kwargs
):
"""Generate the next message based on previous messages, and keeps the thinking tokens"""
full_response = self.llm_client.messages.create(
system=messages[0]["content"][0]["text"],
model=self.model,
messages=messages[1:],
max_tokens=8192,
thinking={"type": "enabled", "budget_tokens": 4096},
**kwargs,
)
thoughts = full_response.content[0].thinking
answer = full_response.content[1].text
full_response = (
f"<thoughts>\n{thoughts}\n</thoughts>\n\n<answer>\n{answer}\n</answer>\n"
)
return full_response
class LMMEngineGemini(LMMEngine):
def __init__(
self,
base_url=None,
api_key=None,
model=None,
rate_limit=-1,
temperature=None,
**kwargs,
):
assert model is not None, "model must be provided"
self.model = model
self.base_url = base_url
self.api_key = api_key
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
self.temperature = temperature
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("GEMINI_API_KEY")
if api_key is None:
raise ValueError(
"An API Key needs to be provided in either the api_key parameter or as an environment variable named GEMINI_API_KEY"
)
base_url = self.base_url or os.getenv("GEMINI_ENDPOINT_URL")
if base_url is None:
raise ValueError(
"An endpoint URL needs to be provided in either the endpoint_url parameter or as an environment variable named GEMINI_ENDPOINT_URL"
)
if not self.llm_client:
self.llm_client = OpenAI(base_url=base_url, api_key=api_key)
# Use the temperature passed to generate, otherwise use the instance's temperature, otherwise default to 0.0
temp = self.temperature if temperature is None else temperature
return (
self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temp,
**kwargs,
)
.choices[0]
.message.content
)
class LMMEngineOpenRouter(LMMEngine):
def __init__(
self,
base_url=None,
api_key=None,
model=None,
rate_limit=-1,
temperature=None,
**kwargs,
):
assert model is not None, "model must be provided"
self.model = model
self.base_url = base_url
self.api_key = api_key
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
self.temperature = temperature
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("OPENROUTER_API_KEY")
if api_key is None:
raise ValueError(
"An API Key needs to be provided in either the api_key parameter or as an environment variable named OPENROUTER_API_KEY"
)
base_url = self.base_url or os.getenv("OPEN_ROUTER_ENDPOINT_URL")
if base_url is None:
raise ValueError(
"An endpoint URL needs to be provided in either the endpoint_url parameter or as an environment variable named OPEN_ROUTER_ENDPOINT_URL"
)
if not self.llm_client:
self.llm_client = OpenAI(base_url=base_url, api_key=api_key)
# Use self.temperature if set, otherwise use the temperature argument
temp = self.temperature if self.temperature is not None else temperature
return (
self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temp,
**kwargs,
)
.choices[0]
.message.content
)
class LMMEngineAzureOpenAI(LMMEngine):
def __init__(
self,
base_url=None,
api_key=None,
azure_endpoint=None,
model=None,
api_version=None,
rate_limit=-1,
temperature=None,
**kwargs,
):
assert model is not None, "model must be provided"
self.model = model
self.api_version = api_version
self.api_key = api_key
self.azure_endpoint = azure_endpoint
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
self.cost = 0.0
self.temperature = temperature
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("AZURE_OPENAI_API_KEY")
if api_key is None:
raise ValueError(
"An API Key needs to be provided in either the api_key parameter or as an environment variable named AZURE_OPENAI_API_KEY"
)
api_version = self.api_version or os.getenv("OPENAI_API_VERSION")
if api_version is None:
raise ValueError(
"api_version must be provided either as a parameter or as an environment variable named OPENAI_API_VERSION"
)
azure_endpoint = self.azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT")
if azure_endpoint is None:
raise ValueError(
"An Azure API endpoint needs to be provided in either the azure_endpoint parameter or as an environment variable named AZURE_OPENAI_ENDPOINT"
)
if not self.llm_client:
self.llm_client = AzureOpenAI(
azure_endpoint=azure_endpoint,
api_key=api_key,
api_version=api_version,
)
# Use self.temperature if set, otherwise use the temperature argument
temp = self.temperature if self.temperature is not None else temperature
completion = self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temp,
**kwargs,
)
total_tokens = completion.usage.total_tokens
self.cost += 0.02 * ((total_tokens + 500) / 1000)
return completion.choices[0].message.content
class LMMEnginevLLM(LMMEngine):
def __init__(
self,
base_url=None,
api_key=None,
model=None,
rate_limit=-1,
temperature=None,
**kwargs,
):
assert model is not None, "model must be provided"
self.model = model
self.api_key = api_key
self.base_url = base_url
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
self.temperature = temperature
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(
self,
messages,
temperature=0.0,
top_p=0.8,
repetition_penalty=1.05,
max_new_tokens=512,
**kwargs,
):
api_key = self.api_key or os.getenv("vLLM_API_KEY")
if api_key is None:
raise ValueError(
"A vLLM API key needs to be provided in either the api_key parameter or as an environment variable named vLLM_API_KEY"
)
base_url = self.base_url or os.getenv("vLLM_ENDPOINT_URL")
if base_url is None:
raise ValueError(
"An endpoint URL needs to be provided in either the endpoint_url parameter or as an environment variable named vLLM_ENDPOINT_URL"
)
if not self.llm_client:
self.llm_client = OpenAI(base_url=base_url, api_key=api_key)
# Use self.temperature if set, otherwise use the temperature argument
temp = self.temperature if self.temperature is not None else temperature
completion = self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temp,
top_p=top_p,
extra_body={"repetition_penalty": repetition_penalty},
)
return completion.choices[0].message.content
class LMMEngineHuggingFace(LMMEngine):
def __init__(self, base_url=None, api_key=None, rate_limit=-1, **kwargs):
self.base_url = base_url
self.api_key = api_key
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("HF_TOKEN")
if api_key is None:
raise ValueError(
"A HuggingFace token needs to be provided in either the api_key parameter or as an environment variable named HF_TOKEN"
)
base_url = self.base_url or os.getenv("HF_ENDPOINT_URL")
if base_url is None:
raise ValueError(
"HuggingFace endpoint must be provided as base_url parameter or as an environment variable named HF_ENDPOINT_URL."
)
if not self.llm_client:
self.llm_client = OpenAI(base_url=base_url, api_key=api_key)
return (
self.llm_client.chat.completions.create(
model="tgi",
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temperature,
**kwargs,
)
.choices[0]
.message.content
)
class LMMEngineParasail(LMMEngine):
def __init__(
self, base_url=None, api_key=None, model=None, rate_limit=-1, **kwargs
):
assert model is not None, "Parasail model id must be provided"
self.base_url = base_url
self.model = model
self.api_key = api_key
self.request_interval = 0 if rate_limit == -1 else 60.0 / rate_limit
self.llm_client = None
@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)
def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
api_key = self.api_key or os.getenv("PARASAIL_API_KEY")
if api_key is None:
raise ValueError(
"A Parasail API key needs to be provided in either the api_key parameter or as an environment variable named PARASAIL_API_KEY"
)
base_url = self.base_url
if base_url is None:
raise ValueError(
"Parasail endpoint must be provided as base_url parameter or as an environment variable named PARASAIL_ENDPOINT_URL"
)
if not self.llm_client:
self.llm_client = OpenAI(
base_url=base_url if base_url else "https://api.parasail.io/v1",
api_key=api_key,
)
return (
self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temperature,
**kwargs,
)
.choices[0]
.message.content
)
+306
View File
@@ -0,0 +1,306 @@
import base64
import numpy as np
from gui_agents.s2_5.core.engine import (
LMMEngineAnthropic,
LMMEngineAzureOpenAI,
LMMEngineHuggingFace,
LMMEngineOpenAI,
LMMEngineOpenRouter,
LMMEngineParasail,
LMMEnginevLLM,
LMMEngineGemini,
)
class LMMAgent:
def __init__(self, engine_params=None, system_prompt=None, engine=None):
if engine is None:
if engine_params is not None:
engine_type = engine_params.get("engine_type")
if engine_type == "openai":
self.engine = LMMEngineOpenAI(**engine_params)
elif engine_type == "anthropic":
self.engine = LMMEngineAnthropic(**engine_params)
elif engine_type == "azure":
self.engine = LMMEngineAzureOpenAI(**engine_params)
elif engine_type == "vllm":
self.engine = LMMEnginevLLM(**engine_params)
elif engine_type == "huggingface":
self.engine = LMMEngineHuggingFace(**engine_params)
elif engine_type == "gemini":
self.engine = LMMEngineGemini(**engine_params)
elif engine_type == "open_router":
self.engine = LMMEngineOpenRouter(**engine_params)
elif engine_type == "parasail":
self.engine = LMMEngineParasail(**engine_params)
else:
raise ValueError("engine_type is not supported")
else:
raise ValueError("engine_params must be provided")
else:
self.engine = engine
self.messages = [] # Empty messages
if system_prompt:
self.add_system_prompt(system_prompt)
else:
self.add_system_prompt("You are a helpful assistant.")
def encode_image(self, image_content):
# if image_content is a path to an image file, check type of the image_content to verify
if isinstance(image_content, str):
with open(image_content, "rb") as image_file:
return base64.b64encode(image_file.read()).decode("utf-8")
else:
return base64.b64encode(image_content).decode("utf-8")
def reset(
self,
):
self.messages = [
{
"role": "system",
"content": [{"type": "text", "text": self.system_prompt}],
}
]
def add_system_prompt(self, system_prompt):
self.system_prompt = system_prompt
if len(self.messages) > 0:
self.messages[0] = {
"role": "system",
"content": [{"type": "text", "text": self.system_prompt}],
}
else:
self.messages.append(
{
"role": "system",
"content": [{"type": "text", "text": self.system_prompt}],
}
)
def remove_message_at(self, index):
"""Remove a message at a given index"""
if index < len(self.messages):
self.messages.pop(index)
def replace_message_at(
self, index, text_content, image_content=None, image_detail="high"
):
"""Replace a message at a given index"""
if index < len(self.messages):
self.messages[index] = {
"role": self.messages[index]["role"],
"content": [{"type": "text", "text": text_content}],
}
if image_content:
base64_image = self.encode_image(image_content)
self.messages[index]["content"].append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}",
"detail": image_detail,
},
}
)
def add_message(
self,
text_content,
image_content=None,
role=None,
image_detail="high",
put_text_last=False,
):
"""Add a new message to the list of messages"""
# API-style inference from OpenAI and AzureOpenAI
if isinstance(
self.engine,
(
LMMEngineOpenAI,
LMMEngineAzureOpenAI,
LMMEngineHuggingFace,
LMMEngineGemini,
LMMEngineOpenRouter,
LMMEngineParasail,
),
):
# infer role from previous message
if role != "user":
if self.messages[-1]["role"] == "system":
role = "user"
elif self.messages[-1]["role"] == "user":
role = "assistant"
elif self.messages[-1]["role"] == "assistant":
role = "user"
message = {
"role": role,
"content": [{"type": "text", "text": text_content}],
}
if isinstance(image_content, np.ndarray) or image_content:
# Check if image_content is a list or a single image
if isinstance(image_content, list):
# If image_content is a list of images, loop through each image
for image in image_content:
base64_image = self.encode_image(image)
message["content"].append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}",
"detail": image_detail,
},
}
)
else:
# If image_content is a single image, handle it directly
base64_image = self.encode_image(image_content)
message["content"].append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}",
"detail": image_detail,
},
}
)
# Rotate text to be the last message if desired
if put_text_last:
text_content = message["content"].pop(0)
message["content"].append(text_content)
self.messages.append(message)
# For API-style inference from Anthropic
elif isinstance(self.engine, LMMEngineAnthropic):
# infer role from previous message
if role != "user":
if self.messages[-1]["role"] == "system":
role = "user"
elif self.messages[-1]["role"] == "user":
role = "assistant"
elif self.messages[-1]["role"] == "assistant":
role = "user"
message = {
"role": role,
"content": [{"type": "text", "text": text_content}],
}
if image_content:
# Check if image_content is a list or a single image
if isinstance(image_content, list):
# If image_content is a list of images, loop through each image
for image in image_content:
base64_image = self.encode_image(image)
message["content"].append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64_image,
},
}
)
else:
# If image_content is a single image, handle it directly
base64_image = self.encode_image(image_content)
message["content"].append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64_image,
},
}
)
self.messages.append(message)
# Locally hosted vLLM model inference
elif isinstance(self.engine, LMMEnginevLLM):
# infer role from previous message
if role != "user":
if self.messages[-1]["role"] == "system":
role = "user"
elif self.messages[-1]["role"] == "user":
role = "assistant"
elif self.messages[-1]["role"] == "assistant":
role = "user"
message = {
"role": role,
"content": [{"type": "text", "text": text_content}],
}
if image_content:
# Check if image_content is a list or a single image
if isinstance(image_content, list):
# If image_content is a list of images, loop through each image
for image in image_content:
base64_image = self.encode_image(image)
message["content"].append(
{
"type": "image_url",
"image_url": {
"url": f"data:image;base64,{base64_image}"
},
}
)
else:
# If image_content is a single image, handle it directly
base64_image = self.encode_image(image_content)
message["content"].append(
{
"type": "image_url",
"image_url": {"url": f"data:image;base64,{base64_image}"},
}
)
self.messages.append(message)
else:
raise ValueError("engine_type is not supported")
def get_response(
self,
user_message=None,
messages=None,
temperature=0.0,
max_new_tokens=None,
use_thinking=False,
**kwargs,
):
"""Generate the next response based on previous messages"""
if messages is None:
messages = self.messages
if user_message:
messages.append(
{"role": "user", "content": [{"type": "text", "text": user_message}]}
)
# Thinking enabled for Claude Sonnet 3.7 and Gemini 2.5 Pro
if use_thinking:
return self.engine.generate_with_thinking(
messages,
temperature=temperature,
max_new_tokens=max_new_tokens,
**kwargs,
)
# Regular generation
return self.engine.generate(
messages,
temperature=temperature,
max_new_tokens=max_new_tokens,
**kwargs,
)
+17
View File
@@ -0,0 +1,17 @@
from typing import Dict, Optional
from gui_agents.s2_5.core.mllm import LMMAgent
class BaseModule:
def __init__(self, engine_params: Dict, platform: str):
self.engine_params = engine_params
self.platform = platform
def _create_agent(
self, system_prompt: str = None, engine_params: Optional[Dict] = None
) -> LMMAgent:
"""Create a new LMMAgent instance"""
agent = LMMAgent(engine_params or self.engine_params)
if system_prompt:
agent.add_system_prompt(system_prompt)
return agent
View File
@@ -0,0 +1,90 @@
import inspect
import textwrap
class PROCEDURAL_MEMORY:
@staticmethod
def construct_simple_worker_procedural_memory(agent_class, skipped_actions):
procedural_memory = textwrap.dedent(f"""\
You are an expert in graphical user interfaces and Python code. You are responsible for executing the task: `TASK_DESCRIPTION`.
You are working in CURRENT_OS.
You are provided with:
1. A screenshot of the current time step.
2. The history of your previous interactions with the UI.
3. Access to the following class and methods to interact with the UI:
class Agent:
""")
for attr_name in dir(agent_class):
if attr_name in skipped_actions:
continue
attr = getattr(agent_class, attr_name)
if callable(attr) and hasattr(attr, "is_agent_action"):
# Use inspect to get the full function signature
signature = inspect.signature(attr)
procedural_memory += f"""
def {attr_name}{signature}:
'''{attr.__doc__}'''
"""
procedural_memory += textwrap.dedent("""
Your response should be formatted like this:
(Previous action verification)
Carefully analyze based on the screenshot if the previous action was successful. If the previous action was not successful, provide a reason for the failure.
(Screenshot Analysis)
Closely examine and describe the current state of the desktop along with the currently open applications.
(Next Action)
Based on the current screenshot and the history of your previous interaction with the UI, decide on the next action in natural language to accomplish the given task.
(Grounded Action)
Translate the next action into code using the provided API methods. Format the code like this:
```python
agent.click("The menu button at the top right of the window", 1, "left")
```
Note for the code:
1. Only perform one action at a time.
2. Do not put anything other than python code in the block. You can only use one function call at a time. Do not put more than one function call in the block.
3. You must use only the available methods provided above to interact with the UI, do not invent new methods.
4. Only return one code block every time. There must be a single line of code in the code block.
5. Do not do anything other than the exact specified task. Return with `agent.done()` immediately after the subtask is completed or `agent.fail()` if it cannot be completed.
6. Whenever possible, your grounded action should use hot-keys with the agent.hotkey() action instead of clicking or dragging.
7. My computer's password is 'osworld-public-evaluation', feel free to use it when you need sudo rights.
8. Generate agent.fail() as your grounded action if you get exhaustively stuck on the task and believe it is impossible.
9. Generate agent.done() as your grounded action when your believe the task is fully complete.
10. Do not use the "command" + "tab" hotkey on MacOS.
""")
return procedural_memory.strip()
# For reflection agent, post-action verification mainly for cycle detection
REFLECTION_ON_TRAJECTORY = textwrap.dedent("""
You are an expert computer use agent designed to reflect on the trajectory of a task and provide feedback on what has happened so far.
You have access to the Task Description and the Current Trajectory of another computer agent. The Current Trajectory is a sequence of a desktop image, chain-of-thought reasoning, and a desktop action for each time step. The last image is the screen's display after the last action.
Your task is to generate a reflection. Your generated reflection must fall under one of the cases listed below:
Case 1. The trajectory is not going according to plan. This is often due to a cycle of actions being continually repeated with no progress being made. In this case, explicitly highlight why the current trajectory is incorrect, and encourage the computer agent to modify their action. However, DO NOT encourage a specific action in particular.
Case 2. The trajectory is going according to plan. In this case, simply tell the agent to continue proceeding as planned. DO NOT encourage a specific action in particular.
Case 3. You believe the current task has been completed. In this case, tell the agent that the task has been successfully completed.
To be successful, you must follow the rules below:
- **Your output MUST be based on one of the case options above**.
- DO NOT suggest any specific future plans or actions. Your only goal is to provide a reflection, not an actual plan or action.
- Any response that falls under Case 1 should explain why the trajectory is not going according to plan. You should especially lookout for cycles of actions that are continually repeated with no progress.
- Any response that falls under Case 2 should be concise, since you just need to affirm the agent to continue with the current trajectory.
""")
PHRASE_TO_WORD_COORDS_PROMPT = textwrap.dedent("""
You are an expert in graphical user interfaces. Your task is to process a phrase of text, and identify the most relevant word on the computer screen.
You are provided with a phrase, a table with all the text on the screen, and a screenshot of the computer screen. You will identify the single word id that is best associated with the provided phrase.
This single word must be displayed on the computer screenshot, and its location on the screen should align with the provided phrase.
Each row in the text table provides 2 pieces of data in the following order. 1st is the unique word id. 2nd is the corresponding word.
To be successful, it is very important to follow all these rules:
1. First, think step by step and generate your reasoning about which word id to click on.
2. Then, output the unique word id. Remember, the word id is the 1st number in each row of the text table.
3. If there are multiple occurrences of the same word, use the surrounding context in the phrase to choose the correct one. Pay very close attention to punctuation and capitalization.
""")
View File
+106
View File
@@ -0,0 +1,106 @@
import re
import time
from typing import Tuple
def call_llm_safe(agent, temperature: float = 0.0, use_thinking: bool = False) -> str:
# Retry if fails
max_retries = 3 # Set the maximum number of retries
attempt = 0
response = ""
while attempt < max_retries:
try:
response = agent.get_response(
temperature=temperature, use_thinking=use_thinking
)
assert response is not None, "Response from agent should not be None"
print("Response success!")
break # If successful, break out of the loop
except Exception as e:
attempt += 1
print(f"Attempt {attempt} failed: {e}")
if attempt == max_retries:
print("Max retries reached. Handling failure.")
time.sleep(1.0)
return response if response is not None else ""
def split_thinking_response(full_response: str) -> Tuple[str, str]:
try:
# Extract thoughts section
thoughts_match = re.search(
r"<thoughts>(.*?)</thoughts>", full_response, re.DOTALL
)
thoughts = thoughts_match.group(1).strip()
# Extract answer section
answer_match = re.search(r"<answer>(.*?)</answer>", full_response, re.DOTALL)
answer = answer_match.group(1).strip()
return answer, thoughts
except Exception as e:
return full_response, ""
def parse_single_code_from_string(input_string):
input_string = input_string.strip()
if input_string.strip() in ["WAIT", "DONE", "FAIL"]:
return input_string.strip()
# This regular expression will match both ```code``` and ```python code```
# and capture the `code` part. It uses a non-greedy match for the content inside.
pattern = r"```(?:\w+\s+)?(.*?)```"
# Find all non-overlapping matches in the string
matches = re.findall(pattern, input_string, re.DOTALL)
# The regex above captures the content inside the triple backticks.
# The `re.DOTALL` flag allows the dot `.` to match newline characters as well,
# so the code inside backticks can span multiple lines.
# matches now contains all the captured code snippets
codes = []
for match in matches:
match = match.strip()
commands = [
"WAIT",
"DONE",
"FAIL",
] # fixme: updates this part when we have more commands
if match in commands:
codes.append(match.strip())
elif match.split("\n")[-1] in commands:
if len(match.split("\n")) > 1:
codes.append("\n".join(match.split("\n")[:-1]))
codes.append(match.split("\n")[-1])
else:
codes.append(match)
if len(codes) <= 0:
return "fail"
return codes[0]
def sanitize_code(code):
# This pattern captures the outermost double-quoted text
if "\n" in code:
pattern = r'(".*?")'
# Find all matches in the text
matches = re.findall(pattern, code, flags=re.DOTALL)
if matches:
# Replace the first occurrence only
first_match = matches[0]
code = code.replace(first_match, f'"""{first_match[1:-1]}"""', 1)
return code
def extract_first_agent_function(code_string):
# Regular expression pattern to match 'agent' functions with any arguments, including nested parentheses
pattern = r'agent\.[a-zA-Z_]+\((?:[^()\'"]|\'[^\']*\'|"[^"]*")*\)'
# Find all matches in the string
matches = re.findall(pattern, code_string)
# Return the first match if found, otherwise return None
return matches[0] if matches else None