项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

559 行
22 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
GUI-Owl 1.5 Agent - GUI-Owl 1.5 style agent implementation.
"""
from __future__ import annotations
import json
import re
from typing import Any, Optional
from bench_env.agent.base import AgentConfig, BaseAgent, AgentStepRecord
from bench_env.env.base import Action, ActionType, Observation
from bench_env.llm import LLMClient
# ---------------------------------------------------------------------------
# 官方 SYSTEM_PROMPT
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = '''# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name_for_human": "mobile_use", "name": "mobile_use", "description": "Use a touchscreen to interact with a mobile device, and take screenshots.
* This is an interface to a mobile device with touchscreen. You can perform actions like clicking, typing, swiping, etc.
* Some applications may take time to start or process actions, so you may need to wait and take successive screenshots to see the results of your actions.
* The screen's resolution is 1000x1000.
* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.", "parameters": {"properties": {"action": {"description": "The action to perform. The available actions are:
* `key`: Perform a key event on the mobile device.
- This supports adb's `keyevent` syntax.
- Examples: \"volume_up\", \"volume_down\", \"power\", \"camera\", \"clear\".
* `click`: Click the point on the screen with coordinate (x, y).
* `long_press`: Press the point on the screen with coordinate (x, y) for specified seconds.
* `swipe`: Swipe from the starting point with coordinate (x, y) to the end point with coordinates2 (x2, y2).
* `type`: Input the specified text into the activated input box.
* `system_button`: Press the system button.
* `open`: Open an app on the device.
* `wait`: Wait specified seconds for the change to happen.
* `answer`: Terminate the current task and output the answer.
* `terminate`: Terminate the current task and report its completion status.", "enum": ["key", "click", "long_press", "swipe", "type", "system_button", "open", "wait", "answer", "terminate"], "type": "string"}, "coordinate": {"description": "(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates to move the mouse to. Required only by `action=click`, `action=long_press`, and `action=swipe`.", "type": "array"}, "coordinate2": {"description": "(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates to move the mouse to. Required only by `action=swipe`.", "type": "array"}, "text": {"description": "Required only by `action=key`, `action=type`, `action=open`, `action=answer`.", "type": "string"}, "time": {"description": "The seconds to wait. Required only by `action=long_press` and `action=wait`.", "type": "number"}, "button": {"description": "Back means returning to the previous interface, Home means returning to the desktop, Menu means opening the application background menu, and Enter means pressing the enter. Required only by `action=system_button`", "enum": ["Back", "Home", "Menu", "Enter"], "type": "string"}, "status": {"description": "The status of the task. Required only by `action=terminate`.", "type": "string", "enum": ["success", "failure"]}}, "required": ["action"], "type": "object"}, "args_format": "Format the arguments as a JSON object."}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
# Response format
Response format for every step:
1) Action: a short imperative describing what to do in the UI.
2) A single <tool_call>...</tool_call> block containing only the JSON: {"name": <function-name>, "arguments": <args-json-object>}.
Rules:
- Output exactly in the order: Action, <tool_call>.
- Be brief: one for Action.
- Do not output anything else outside those two parts.
- If finishing, use action=terminate in the tool call.'''
class GUIOwl15Agent(BaseAgent):
"""
GUI-Owl 1.5 style agent.
严格对齐官方 utils.py + run_gui_owl_1_5_for_mobile.py
- SYSTEM_PROMPT:官方原文
- 输出格式:<tool_call> JSON,name=mobile_use,arguments 含 action 字段
- 动作名(action 字段值):click / long_press / swipe / type /
system_button / open / wait / answer /
interact / terminate / key
- 坐标参数:coordinate / coordinate2(官方参数名)
- build_messageshistory_n=5 滑动窗口 + 早期文字摘要(对齐官方 last_image=5
- 历史摘要:取 "Action:" 到 "<tool_call>" 之间的文字
"""
SYSTEM_PROMPT = SYSTEM_PROMPT
# ==================== 动作映射 ====================
ACTION_MAP = {
"click": (
ActionType.CLICK,
lambda p: {"point": GUIOwl15Agent._parse_coordinate(p.get("coordinate"))},
),
"long_press": (
ActionType.LONG_PRESS,
lambda p: {"point": GUIOwl15Agent._parse_coordinate(p.get("coordinate"))},
),
"swipe": (
ActionType.SWIPE,
lambda p: {
"point1": GUIOwl15Agent._parse_coordinate(p.get("coordinate")),
"point2": GUIOwl15Agent._parse_coordinate(p.get("coordinate2")),
"duration": 800, # 对齐官方 AdbTools.slide 默认 800ms
},
),
"type": (
ActionType.TYPE,
lambda p: {"value": p.get("text", "")},
),
"system_button": ( # 死代码:parse_response 拦截后按 button 值分派 BACK/HOME/ENTER/RECENT
ActionType.BACK,
lambda p: {},
),
"open": (
ActionType.AWAKE,
lambda p: {"value": p.get("text", "")},
),
"wait": (
ActionType.WAIT,
lambda p: {"value": float(p.get("time", 2))},
),
"answer": (
ActionType.ANSWER,
lambda p: {"value": p.get("text", "")},
),
"interact": (
ActionType.INFO,
lambda p: {"value": p.get("text", "")},
),
"terminate": (
ActionType.COMPLETE,
lambda p: {"return": p.get("status", "success")},
),
"key": (
ActionType.NOOP,
lambda p: {"action": "key", "text": p.get("text", "")},
),
# ---- 容错别名 ----
"scroll": ( # 来自日常版 run_gui_owl_1_5_for_mobile.py L224AndroidWorld 版无此别名)
ActionType.SWIPE,
lambda p: {
"point1": GUIOwl15Agent._parse_coordinate(p.get("coordinate")),
"point2": GUIOwl15Agent._parse_coordinate(p.get("coordinate2")),
"duration": 800, # 对齐官方 AdbTools.slide 默认 800ms
},
),
"open_app": ( # 来自 AndroidWorld 版 mobile_agent_utils_new.py L199
ActionType.AWAKE,
lambda p: {"value": p.get("text", "")},
),
}
DEFAULT_MODEL_ARGS = {
"temperature": 0.0,
# "top_p": 1.0,
# "max_tokens": 4096,
}
HISTORY_N = 5
# ==================== 初始化 ====================
def __init__(self, llm: LLMClient, config: Optional[AgentConfig] = None):
super().__init__(config)
self.llm = llm
self._pending_comment = ""
merged_args = dict(self.DEFAULT_MODEL_ARGS)
merged_args.update(self.config.model_args or {})
self.config.model_args = merged_args
@property
def name(self) -> str:
return "GUIOwl15Agent"
def reset(self, task: str) -> None:
self._task = task
self._history = []
self._pending_comment = ""
def add_user_comment(self, comment: str) -> None:
"""添加用户回复(用于 interact 动作)"""
self._pending_comment = str(comment or "")
@staticmethod
def _parse_coordinate(raw: Any) -> Optional[list[int]]:
"""
GUI-Owl 1.5 输出 0-1000 相对坐标(system prompt: 1000x1000)。
环境使用 0-999 坐标空间,直接透传并 clamp。
"""
if raw is None:
return None
if not isinstance(raw, (list, tuple)) or len(raw) < 2:
return None
try:
x = float(raw[0])
y = float(raw[1])
except (TypeError, ValueError):
return None
x = max(0, min(999, int(round(x))))
y = max(0, min(999, int(round(y))))
return [x, y]
@staticmethod
def _extract_display_summary(output_text: str) -> str:
"""提取适合命令行展示的简短动作描述。"""
text = str(output_text or "").strip()
if not text:
return ""
match = re.search(r"Action:\s*(.+?)(?=<tool_call>|$)", text, re.DOTALL | re.IGNORECASE)
if match:
return " ".join(match.group(1).split())
parsed_call = GUIOwl15Agent._parse_tool_call(text)
arguments = parsed_call.get("arguments", {}) if isinstance(parsed_call, dict) else {}
if not isinstance(arguments, dict):
return ""
action = str(arguments.get("action", "")).strip().lower()
if not action:
return ""
if action in {"click", "long_press"}:
return f"{action} {arguments.get('coordinate')}"
if action == "swipe":
return f"swipe {arguments.get('coordinate')} -> {arguments.get('coordinate2')}"
if action in {"open", "type", "answer", "interact"}:
payload = arguments.get("text")
return f"{action} {payload!r}"
if action == "wait":
return f"wait {arguments.get('time', 2)}s"
if action == "system_button":
return f"system_button {arguments.get('button', 'Back')}"
if action == "terminate":
return f"terminate {arguments.get('status', 'success')}"
return action
# ==================== 响应解析 ====================
@staticmethod
def _parse_tool_call(output_text: str) -> dict[str, Any]:
"""
从模型输出中提取 <tool_call> JSON。
官方原始逻辑(run_gui_owl_1_5_for_mobile.py parse_action):
tool_call_block = output_text.split("<tool_call>\n")[1]
json_str = tool_call_block.split("}}\n")[0] + "}}"
return json.loads(json_str)
官方 tool call 结构:
{"name": "mobile_use", "arguments": {"action": "click", "coordinate": [500, 300]}}
"""
if "<tool_call>" not in output_text:
return {}
try:
tool_call_block = output_text.split("<tool_call>\n")[1]
json_str = tool_call_block.split("}}\n")[0] + "}}"
parsed = json.loads(json_str)
if "name" in parsed:
return parsed
except (IndexError, json.JSONDecodeError):
pass
tag_match = re.search(
r"<tool_call>\s*(.*?)\s*(?:</tool_call>|$)",
output_text, re.DOTALL | re.IGNORECASE
)
if tag_match:
json_str = tag_match.group(1).strip()
if json_str.count("{") < json_str.count("}"):
json_str = re.sub(r"}}\s*$", "}", json_str)
try:
parsed = json.loads(json_str)
if "name" in parsed:
return parsed
except json.JSONDecodeError:
pass
return {}
def _parse_llm_output(self, response_text: str) -> dict[str, Any]:
"""
解析 LLM 输出为结构化字典。
官方模型输出格式:
Action: <短描述>\n
<tool_call>\n
{"name": "mobile_use", "arguments": {"action": "click", "coordinate": [500, 300]}}\n
</tool_call>
Returns:
{
"action": str, # arguments.action 字段值(小写)
"params": dict, # arguments 完整字典
"raw_action": str, # 原始 JSON 字符串
}
"""
raw = str(response_text or "").strip()
if not raw:
return {
"action": "interact",
"params": {"action": "interact", "text": "empty_response"},
"raw_action": "",
"summary": "",
}
parsed_call = self._parse_tool_call(raw)
action_summary = self._extract_display_summary(raw)
if not parsed_call or "arguments" not in parsed_call:
return {
"action": "interact",
"params": {"action": "interact", "text": f"无法解析模型输出: {raw[:200]}"},
"raw_action": "",
"summary": action_summary,
}
arguments = parsed_call.get("arguments", {})
if not isinstance(arguments, dict):
arguments = {}
action_name = str(arguments.get("action", "")).strip().lower()
if not action_name:
return {
"action": "interact",
"params": {"action": "interact", "text": "action 字段为空"},
"raw_action": json.dumps(parsed_call, ensure_ascii=False),
"summary": action_summary,
}
return {
"action": action_name,
"params": arguments,
"raw_action": json.dumps(parsed_call, ensure_ascii=False),
"summary": action_summary,
}
def parse_response(self, response_text: str) -> Action:
"""解析 LLM 响应为 Action"""
parsed = self._parse_llm_output(response_text)
action_name = str(parsed.get("action", "interact")).strip().lower()
# 来自 AndroidWorld 版 agents/gui_owl.py L317: tap → click 别名
if action_name == "tap":
action_name = "click"
params = parsed.get("params", {})
if action_name == "system_button":
button = str(params.get("button", "")).strip()
if button == "Home":
return Action(
action_type=ActionType.HOME,
data={},
thought="",
raw_response=response_text,
)
elif button == "Enter":
return Action(
action_type=ActionType.ENTER,
data={},
thought="",
raw_response=response_text,
)
elif button == "Menu":
return Action(
action_type=ActionType.RECENT,
data={},
thought="",
raw_response=response_text,
)
else:
# Back(默认)
return Action(
action_type=ActionType.BACK,
data={},
thought="",
raw_response=response_text,
)
return self.parse_action(
action_name,
params,
thought="",
summary=parsed.get("summary", ""),
raw_response=response_text,
)
# ==================== 历史摘要提取 ====================
@staticmethod
def _extract_action_summary(output_text: str, model_name: str = "") -> str:
"""
从模型输出中提取动作摘要文本。
官方逻辑(build_messages 中):
if model_name.endswith(".mem"):
text = text.split("<tool_call>")[0].strip()
else:
if "Action:" in text and "<tool_call>" in text:
text = text.split("Action:")[1].split("<tool_call>")[0].strip()
"""
text = str(output_text or "").strip()
if model_name.endswith(".mem"):
if "<tool_call>" in text:
text = text.split("<tool_call>")[0].strip()
else:
if "Action:" in text and "<tool_call>" in text:
text = text.split("Action:")[-1].split("<tool_call>")[0].strip()
return text
# ==================== 消息构建 ====================
def build_messages(self, obs: Observation) -> list[dict[str, Any]]:
"""
构建发送给 LLM 的消息。
完全对齐官方 build_messages 逻辑:
1. history_n=4:只保留最近 4 步的截图
2. 早于 history_n 的步骤:只提取动作摘要文字(不含截图)
3. instruction_prompt:包含日期、任务、早期动作摘要
4. 消息结构:
- system:官方 SYSTEM_PROMPT
- history 窗口第一条:instruction_prompt + 该步截图
- history 窗口后续:只有截图
- 每条 history 后跟 assistant 消息(完整模型输出)
- 最后:当前截图(无 instruction_prompt
- 若无 historyinstruction_prompt + 当前截图
5. 消息格式:OpenAI image_url 格式(base64 data URL
"""
n = len(self._history)
# 官方 last_image=5 包含当前截图,历史窗口实际为 HISTORY_N - 1
max_history_images = self.HISTORY_N - 1
history_start_idx = max(0, n - max_history_images)
model_name = getattr(self.llm, "model", "")
# 窗口外步骤:只需 llm_responseobservation 可能已被瘦身清空)
previous_actions = []
for i in range(history_start_idx):
r = self._history[i]
summary = self._extract_action_summary(r.llm_response, model_name)
previous_actions.append(f"Step{i + 1}: {summary}")
previous_actions_str = "\n".join(previous_actions) if previous_actions else "No previous action."
instruction_prompt = (
f"Please generate the next move according to the UI screenshot, "
f"instruction and previous actions.\n\n"
f"Instruction: {self._task}\n\n"
f"Previous actions:\n{previous_actions_str}"
)
messages: list[dict[str, Any]] = [
{
"role": "system",
"content": self.SYSTEM_PROMPT,
}
]
# 窗口内步骤:需要截图
history_window = [
{"output": r.llm_response, "image_data_url": r.observation.image_data_url}
for r in self._history[history_start_idx:]
]
history_len = len(history_window)
if history_len > 0:
for idx, item in enumerate(history_window):
if idx == 0:
user_content: list[dict[str, Any]] = [
{"type": "text", "text": instruction_prompt},
{"type": "image_url", "image_url": {"url": item["image_data_url"]}},
]
else:
user_content = [
{"type": "image_url", "image_url": {"url": item["image_data_url"]}},
]
messages.append({"role": "user", "content": user_content})
messages.append({
"role": "assistant",
"content": item["output"],
})
current_content: list[dict[str, Any]] = [
{"type": "image_url", "image_url": {"url": obs.image_data_url}},
]
else:
current_content = [
{"type": "text", "text": instruction_prompt},
{"type": "image_url", "image_url": {"url": obs.image_data_url}},
]
if self._pending_comment:
current_content.append({
"type": "text",
"text": f"\n[User Reply] {self._pending_comment}",
})
messages.append({"role": "user", "content": current_content})
return messages
# ==================== 核心逻辑 ====================
def act(self, obs: Observation) -> Action:
"""生成动作"""
# # 官方行为:上一步为 answer 时,自动发 terminate(success)
# if self._history and self._history[-1].action.action_type == ActionType.ANSWER:
# action = Action.complete(
# message="success",
# raw_response="[auto-terminate after answer]",
# )
# self._history.append(AgentStepRecord(
# step_idx=obs.step_idx,
# observation=obs,
# action=action,
# llm_response="[auto-terminate after answer]",
# llm_prompt=[],
# user_comment="",
# ))
# if self.config.verbose:
# print(f"[GUIOwl15Agent] Auto-terminate after ANSWER")
# print(f"[GUIOwl15Agent] Action: {action.action_type}, Data: {action.data}")
# return action
messages = self.build_messages(obs)
if self.config.verbose:
print(f"\n[GUIOwl15Agent] Step {obs.step_idx}, sending prompt...")
response = self.llm.chat(
messages=messages,
args={
**self.config.model_args,
"stream": self.config.stream,
"stream_print": self.config.stream,
},
)
llm_output = response.content
if self.config.verbose and not self.config.stream:
print(f"\n[LLM Response]\n{llm_output}\n")
action = self.parse_response(llm_output)
self._history.append(AgentStepRecord(
step_idx=obs.step_idx,
observation=obs,
action=action,
llm_response=llm_output,
llm_prompt=messages,
user_comment=self._pending_comment,
))
# 内存瘦身:窗口外旧记录清空截图和 prompt
self._evict_old_records(keep_recent=self.HISTORY_N + 1)
self._pending_comment = ""
if self.config.verbose:
if action.summary:
print(f"[GUIOwl15Agent] Intent: {action.summary}")
print(f"[GUIOwl15Agent] Action: {action.action_type}, Data: {action.data}")
return action