项目文件夹

文件
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 行
9.6 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
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=WAIT,value 为秒数(数字)
- 如果 action=AWAKE,value 为应用名称
- 如果 action=COMPLETE,return 为任务完成的说明
- 如果 action=INFO,value 为要询问用户的问题
"""
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