Files
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

270 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
GenericAgent - 通用 JSON 格式 Agent,支持任意 VLM 模型。
输出格式为标准 JSON,适配大多数视觉语言模型。
"""
from __future__ import annotations
import json
from typing import Any, ClassVar, Optional
from bench_env.agent.base import BaseAgent, AgentConfig, ActionMapping, AgentStepRecord
from bench_env.env.base import Action, ActionType, Observation
from bench_env.llm import LLMClient
class GenericAgent(BaseAgent):
"""
通用 JSON 格式 Agent。
适用于任意支持视觉的 LLM 模型(如 GPT-4o、Gemini、Qwen-VL 等)。
模型输出标准 JSON 格式,易于解析和调试。
"""
SYSTEM_PROMPT: ClassVar[str] = """你是一个手机 GUI-Agent 操作专家。你需要根据用户下发的任务、手机屏幕截图以及历史操作记录,输出一个动作来与手机交互,从而完成任务。
坐标系:左上角为原点,x 向右,y 向下,取值范围均为 0-1000(归一化坐标)。
你必须**只输出一个 JSON 对象**(不要输出任何额外文本/Markdown/代码块),schema 如下:
{
"action": "CLICK|TYPE|SWIPE|LONGPRESS|BACK|AWAKE|WAIT|INFO|COMPLETE|ABORT",
"thought": "你的思考过程(可选)",
"explain": "简短解释(可选)",
"point": [x, y], // CLICK/LONGPRESS 需要
"point1": [x1, y1], "point2": [x2, y2], // SWIPE/SLIDE 需要(SLIDE 会被映射到 SWIPE
"value": "..." , // TYPE/WAIT/AWAKE/INFO/ABORT 需要
"clear": true, // TYPE 可选:先清空输入框再输入(默认 false,追加到已有文本后面)
"return": "..." // COMPLETE 需要
}
要求:
- 坐标必须为数字,范围 0-1000
- 如果 action=WAITvalue 为秒数(数字)
- 如果 action=AWAKEvalue 为应用名称
- 如果 action=COMPLETEreturn 为任务完成的说明
- 如果 action=INFOvalue 为要询问用户的问题
"""
ACTION_MAP: ActionMapping = {
"CLICK": (ActionType.CLICK, lambda p: {"point": p.get("point")}),
"TAP": (ActionType.CLICK, lambda p: {"point": p.get("point")}),
"LONGPRESS": (ActionType.LONG_PRESS, lambda p: {"point": p.get("point")}),
"LONG_PRESS": (ActionType.LONG_PRESS, lambda p: {"point": p.get("point")}),
"TYPE": (ActionType.TYPE, lambda p: {"value": p.get("value", p.get("text", "")), "clear": p.get("clear", False)}),
"SLIDE": (ActionType.SWIPE, lambda p: {"point1": p.get("point1", p.get("start")), "point2": p.get("point2", p.get("end"))}),
"SWIPE": (ActionType.SWIPE, lambda p: {"point1": p.get("point1", p.get("start")), "point2": p.get("point2", p.get("end"))}),
"DRAG": (ActionType.DRAG, lambda p: {"point1": p.get("point1", p.get("start")), "point2": p.get("point2", p.get("end"))}),
"BACK": (ActionType.BACK, lambda p: {}),
"HOME": (ActionType.HOME, lambda p: {}),
"RECENT": (ActionType.RECENT, lambda p: {}),
"ENTER": (ActionType.ENTER, lambda p: {}),
"WAIT": (ActionType.WAIT, lambda p: {"value": float(p.get("value", p.get("duration", 1.0)))}),
"AWAKE": (ActionType.AWAKE, lambda p: {"value": p.get("value", p.get("app", ""))}),
"LAUNCH": (ActionType.AWAKE, lambda p: {"value": p.get("value", p.get("app", ""))}),
"INFO": (ActionType.INFO, lambda p: {"value": p.get("value", p.get("question", ""))}),
"COMPLETE": (ActionType.COMPLETE, lambda p: {"return": p.get("return", p.get("message", ""))}),
"FINISH": (ActionType.COMPLETE, lambda p: {"return": p.get("return", p.get("message", ""))}),
"ABORT": (ActionType.ABORT, lambda p: {"value": p.get("value", p.get("reason", ""))}),
}
DEFAULT_MODEL_ARGS: ClassVar[dict[str, Any]] = {
"temperature": 0.1,
"top_p": 0.95,
"frequency_penalty": 0.0,
"max_tokens": 4096,
}
# ==================== 初始化 ====================
def __init__(self, llm: LLMClient, config: Optional[AgentConfig] = None):
super().__init__(config)
self.llm = llm
self._pending_comment: str = ""
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 "GenericAgent"
def reset(self, task: str) -> None:
self._task = task
self._history = []
self._pending_comment = ""
def add_user_comment(self, comment: str) -> None:
"""添加用户回复(用于 INFO 动作)"""
self._pending_comment = str(comment or "")
# ==================== 响应解析 ====================
@staticmethod
def _extract_first_json(text: str) -> Optional[str]:
"""从文本中提取第一个 JSON 对象"""
s = text
start = s.find("{")
if start < 0:
return None
in_str = False
esc = False
depth = 0
for i in range(start, len(s)):
ch = s[i]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return s[start : i + 1]
return None
def _parse_llm_output(self, response_text: str) -> dict[str, Any]:
"""解析 LLM 输出为 dict"""
raw = str(response_text or "").strip()
if not raw:
return {"_error": "empty_response"}
parsed: Any = None
try:
parsed = json.loads(raw)
except Exception:
extracted = self._extract_first_json(raw)
if extracted:
try:
parsed = json.loads(extracted)
except Exception:
pass
if not isinstance(parsed, dict):
return {"_error": "invalid_json", "raw": raw}
return parsed
def parse_response(self, response_text: str) -> Action:
"""解析 LLM 响应为 Action"""
parsed = self._parse_llm_output(response_text)
if "_error" in parsed:
return Action(
action_type=ActionType.ABORT,
data={"value": parsed.get("_error", "parse_error")},
raw_response=response_text,
)
action_name = str(parsed.get("action") or parsed.get("action_type") or "").strip().upper()
thought = str(parsed.get("thought", parsed.get("cot", "")) or "")
explain = str(parsed.get("explain", "") or "")
return self.parse_action(
action_name,
parsed,
thought=thought,
explain=explain,
raw_response=response_text,
)
# ==================== 消息构建 ====================
def _format_history(self, max_items: int = 6) -> str:
"""格式化历史操作"""
if not self._history:
return "[]"
items = self._history[-max_items:]
out = []
for s in items:
out.append({
"step": s.step_idx,
"action": s.action.action_type,
"data": s.action.data,
})
return json.dumps(out, ensure_ascii=False)
def build_messages(self, obs: Observation) -> list[dict]:
"""构建发送给 LLM 的消息"""
route_app = obs.current_app or ""
route_path = obs.route.get("path", "") if obs.route else ""
user_comment = ""
if self._pending_comment:
user_comment = f"\n\n[用户补充信息]\n{self._pending_comment}\n"
text = f"""[任务]
{self._task}{user_comment}
[当前路由]
app={route_app} path={route_path}
[历史操作]
{self._format_history()}
[当前截图]
"""
return [
{"role": "system", "content": self.SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {"url": obs.image_data_url}},
{"type": "text", "text": "\n请只输出 JSON。"},
],
},
]
# ==================== 核心逻辑 ====================
def act(self, obs: Observation) -> Action:
"""生成动作"""
messages = self.build_messages(obs)
if self.config.verbose:
print(f"\n[GenericAgent] 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 and self.config.verbose,
},
)
if self.config.verbose and not self.config.stream:
print(f"\n[LLM Response]\n{response.content}\n")
action = self.parse_response(response.content)
self._history.append(AgentStepRecord(
step_idx=obs.step_idx,
observation=obs,
action=action,
llm_response=response.content,
llm_prompt=messages,
user_comment=self._pending_comment,
))
# 内存瘦身:历史仅用文本,保留最近 2 条完整记录
self._evict_old_records(keep_recent=2)
self._pending_comment = ""
if self.config.verbose:
print(f"[GenericAgent] Action: {action.action_type}, Data: {action.data}")
return action