init
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# AgentScope Skills Demo
|
||||
|
||||
这是一个最小可运行示例,演示如何:
|
||||
|
||||
- 使用 llama.cpp 的 OpenAI 兼容接口连接 `OpenAIChatModel`
|
||||
- 为 `ReActAgent` 注册多个本地 skill
|
||||
- 让智能体在任务中优先使用 skill,并通过 Python 工具执行代码
|
||||
- 在 Windows 桌面上执行截图、点击、输入等 GUI 自动化操作
|
||||
|
||||
## 文件结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── demo_with_skills.py
|
||||
├── requirements.txt
|
||||
└── skills/
|
||||
├── python_hello_skill/
|
||||
│ └── SKILL.md
|
||||
└── windows_gui_operator/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
运行 GUI skill 后,截图会保存在 `artifacts/screenshots/` 目录。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 运行示例
|
||||
|
||||
```bash
|
||||
python demo_with_skills.py
|
||||
```
|
||||
|
||||
默认会进入交互式对话模式,你可以在终端中持续输入消息与模型对话:
|
||||
|
||||
```text
|
||||
user> 你好
|
||||
user> 请用 Python 打印 Hello World
|
||||
user> /exit
|
||||
```
|
||||
|
||||
脚本默认会隐藏模型的 thinking 过程,只显示最终可见回复。如果你想调试 thinking 输出,可以运行:
|
||||
|
||||
```bash
|
||||
python demo_with_skills.py --show-thinking
|
||||
```
|
||||
|
||||
默认模式下,工具调用过程中的 `tool_use` 和 `tool_result` JSON 也会被隐藏,只保留最终自然语言回复。
|
||||
|
||||
当前版本会以可读形式打印 ReAct 过程,例如 `Action` 和 `Observation`,但不会直接输出原始 thinking 块和原始 JSON。
|
||||
|
||||
如果你想检查模型每一轮 assistant 原始返回了什么 block,可以运行:
|
||||
|
||||
```bash
|
||||
python demo_with_skills.py --debug-model-output
|
||||
```
|
||||
|
||||
这会额外打印每轮模型的原始 content blocks,便于确认截图返回后模型是否真的在消费图片相关内容。
|
||||
|
||||
## Windows GUI Skill
|
||||
|
||||
新增的 `windows_gui_operator` skill 会要求智能体按 ReAct 方式执行 GUI 操作:
|
||||
|
||||
- 先获取当前活动窗口并截图
|
||||
- 再执行鼠标或键盘动作
|
||||
- 关键步骤后再次截图确认
|
||||
- 对输入类操作,必要时通过剪贴板做结果确认
|
||||
|
||||
当前脚本已注册以下 GUI 工具:
|
||||
|
||||
- `save_desktop_screenshot`
|
||||
- `get_mouse_position`
|
||||
- `left_click`
|
||||
- `type_text`
|
||||
- `press_keys`
|
||||
- `hotkey`
|
||||
- `wait_seconds`
|
||||
- `get_active_window_title`
|
||||
- `get_clipboard_text`
|
||||
|
||||
如果当前模型支持多模态,`save_desktop_screenshot` 会把本地截图文件同时作为图片结果返回给模型,模型可以直接基于截图继续判断下一步动作。
|
||||
|
||||
GUI skill 默认采用“观察 -> 执行一个最小动作 -> 复查 -> 必要时重试”的单步闭环,而不是预先规划固定长步骤后一路执行。
|
||||
|
||||
当前版本对 Windows GUI 的约束是:桌面任务必须通过键盘和鼠标完成,不提供直接启动应用或直接打开网址的非 GUI 工具。
|
||||
|
||||
如果你只想发送一条消息并立即退出,可以运行:
|
||||
|
||||
```bash
|
||||
python demo_with_skills.py --message "请用 Python 打印 Hello World"
|
||||
```
|
||||
|
||||
如果你只想确认 skill 注册是否成功,而不立即调用远程模型,可以运行:
|
||||
|
||||
```bash
|
||||
python demo_with_skills.py --skip-agent-run
|
||||
```
|
||||
|
||||
如果你需要替换 llama.cpp 服务地址或模型名,可以通过环境变量覆盖:
|
||||
|
||||
```bash
|
||||
set LLAMA_CPP_BASE_URL=http://127.0.0.1:8000/v1
|
||||
set LLAMA_CPP_MODEL_NAME=your-model-name
|
||||
set LLAMA_CPP_API_KEY=EMPTY
|
||||
python demo_with_skills.py
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
示例脚本默认使用以下 llama.cpp OpenAI 兼容接口配置,并支持通过环境变量覆盖:
|
||||
|
||||
- Base URL: `http://yyplab.site:8033/v1`
|
||||
- Model: `Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf`
|
||||
- API Key: `EMPTY`
|
||||
|
||||
如果你本地的 AgentScope 版本中,文件读取工具名称与脚本里的候选名称不同,请在 `demo_with_skills.py` 中补充对应函数名。
|
||||
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 223 KiB |
BIN
artifacts/screenshots/20260313_160434_436113_verification.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
BIN
artifacts/screenshots/20260313_164459_060540_initial_state.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 618 KiB |
BIN
artifacts/screenshots/20260313_170818_906226_initial_state.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 426 KiB |
BIN
artifacts/screenshots/20260313_172605_878486_initial_state.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 195 KiB |
BIN
artifacts/screenshots/20260313_174750_800070_initial_state.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
artifacts/screenshots/20260313_174828_851736_after_win_key.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 422 KiB |
BIN
artifacts/screenshots/20260313_184636_815085_observe_initial.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 256 KiB |
BIN
artifacts/screenshots/20260313_191915_091004_observe_initial.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
artifacts/screenshots/20260313_192008_351790_after_win_key.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 204 KiB |
BIN
artifacts/screenshots/20260313_192553_073176_after_ctrl_l.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
artifacts/screenshots/20260313_193517_133400_observe_initial.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
artifacts/screenshots/20260313_194217_277472_观察当前桌面状态.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 88 KiB |
BIN
artifacts/screenshots/20260313_194350_239941_观察当前浏览器状态.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 189 KiB |
904
demo_with_skills.py
Normal file
@@ -0,0 +1,904 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""使用 llama.cpp 的 OpenAI 兼容接口创建带有 skills 的 ReActAgent 示例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
from agentscope.agent import ReActAgent
|
||||
from agentscope.formatter import OpenAIChatFormatter
|
||||
from agentscope.memory import InMemoryMemory
|
||||
from agentscope.message import ImageBlock, Msg, TextBlock, URLSource
|
||||
from agentscope.model import OpenAIChatModel
|
||||
from agentscope.tool import ToolResponse, Toolkit, execute_python_code
|
||||
|
||||
LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://yyplab.site:8033/v1")
|
||||
LLAMA_CPP_MODEL_NAME = os.getenv(
|
||||
"LLAMA_CPP_MODEL_NAME",
|
||||
"Qwen3.5-35B-A3B-UD-Q8_K_XL.gguf",
|
||||
)
|
||||
LLAMA_CPP_API_KEY = os.getenv("LLAMA_CPP_API_KEY", "EMPTY")
|
||||
WORKSPACE_DIR = Path(__file__).parent.resolve()
|
||||
SKILLS_DIR = Path(__file__).parent / "skills"
|
||||
SCREENSHOT_DIR = Path(__file__).parent / "artifacts" / "screenshots"
|
||||
DEFAULT_GUI_MAX_STEPS = 60
|
||||
|
||||
|
||||
def _load_pyautogui():
|
||||
"""延迟导入 pyautogui,避免缺依赖时脚本启动失败。"""
|
||||
try:
|
||||
import pyautogui # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"缺少 pyautogui,请先执行 `pip install -r requirements.txt`。",
|
||||
) from exc
|
||||
|
||||
pyautogui.FAILSAFE = True
|
||||
pyautogui.PAUSE = 0.15
|
||||
return pyautogui
|
||||
|
||||
|
||||
def _ensure_screenshot_dir() -> Path:
|
||||
"""确保截图目录存在。"""
|
||||
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return SCREENSHOT_DIR
|
||||
|
||||
|
||||
def normalize_text(value: Any, default: str = "") -> str:
|
||||
"""将模型传入的参数规范化为字符串。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return stripped if stripped else default
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_float(value: Any, default: float) -> float:
|
||||
"""将模型传入的参数规范化为浮点数。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return default
|
||||
return float(stripped)
|
||||
return float(value)
|
||||
|
||||
|
||||
def normalize_int(value: Any, default: int) -> int:
|
||||
"""将模型传入的参数规范化为整数。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return default
|
||||
return int(float(stripped))
|
||||
return int(value)
|
||||
|
||||
|
||||
def normalize_keys(value: Any) -> list[str]:
|
||||
"""将模型传入的按键参数规范化为字符串列表。"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return [stripped] if stripped else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [normalize_text(item) for item in value if normalize_text(item)]
|
||||
normalized = normalize_text(value)
|
||||
return [normalized] if normalized else []
|
||||
|
||||
|
||||
def make_tool_response(text: str, metadata: dict[str, Any] | None = None) -> ToolResponse:
|
||||
"""构造符合 AgentScope 要求的工具响应。"""
|
||||
return ToolResponse(
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=text,
|
||||
),
|
||||
],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def make_success_response(summary: str, **metadata: Any) -> ToolResponse:
|
||||
"""构造成功响应。"""
|
||||
payload = "\n".join(
|
||||
[summary, *(f"{key}={value}" for key, value in metadata.items())],
|
||||
)
|
||||
return make_tool_response(payload, metadata=metadata or None)
|
||||
|
||||
|
||||
def make_error_response(action: str, exc: Exception) -> ToolResponse:
|
||||
"""构造失败响应,避免工具异常直接打断会话。"""
|
||||
return make_tool_response(
|
||||
f"action={action}\nstatus=error\nerror={type(exc).__name__}: {exc}",
|
||||
metadata={
|
||||
"action": action,
|
||||
"status": "error",
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_multimodal_response(
|
||||
summary: str,
|
||||
image_path: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ToolResponse:
|
||||
"""构造包含截图图片块的工具响应,供多模态模型直接观察。"""
|
||||
return ToolResponse(
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=summary,
|
||||
),
|
||||
ImageBlock(
|
||||
type="image",
|
||||
source=URLSource(
|
||||
type="url",
|
||||
url=image_path,
|
||||
),
|
||||
),
|
||||
],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def read_local_text_file(path: str) -> ToolResponse:
|
||||
"""读取工作区内的文本文件,供 agent 查看 skill 说明。"""
|
||||
try:
|
||||
requested_path = Path(normalize_text(path)).expanduser().resolve()
|
||||
if WORKSPACE_DIR not in requested_path.parents and requested_path != WORKSPACE_DIR:
|
||||
return make_tool_response(
|
||||
"action=read_local_text_file\nstatus=error\nerror=Path is outside the workspace.",
|
||||
metadata={
|
||||
"action": "read_local_text_file",
|
||||
"status": "error",
|
||||
"path": str(requested_path),
|
||||
},
|
||||
)
|
||||
|
||||
if not requested_path.is_file():
|
||||
return make_tool_response(
|
||||
"action=read_local_text_file\nstatus=error\nerror=File does not exist.",
|
||||
metadata={
|
||||
"action": "read_local_text_file",
|
||||
"status": "error",
|
||||
"path": str(requested_path),
|
||||
},
|
||||
)
|
||||
|
||||
content = requested_path.read_text(encoding="utf-8")
|
||||
return make_tool_response(
|
||||
"\n".join(
|
||||
[
|
||||
"action=read_local_text_file",
|
||||
"status=ok",
|
||||
f"path={requested_path}",
|
||||
content,
|
||||
],
|
||||
),
|
||||
metadata={
|
||||
"action": "read_local_text_file",
|
||||
"status": "ok",
|
||||
"path": str(requested_path),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("read_local_text_file", exc)
|
||||
|
||||
|
||||
def save_desktop_screenshot(label: str = "observe") -> ToolResponse:
|
||||
"""截取当前桌面并保存到本地,用于 GUI 操作前后的观察与确认。"""
|
||||
try:
|
||||
pyautogui = _load_pyautogui()
|
||||
screenshot_dir = _ensure_screenshot_dir()
|
||||
label = normalize_text(label, default="observe")
|
||||
safe_label = "".join(
|
||||
char if char.isalnum() or char in "-_" else "_" for char in label
|
||||
)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
screenshot_path = screenshot_dir / f"{timestamp}_{safe_label}.png"
|
||||
pyautogui.screenshot(str(screenshot_path))
|
||||
return make_multimodal_response(
|
||||
summary=(
|
||||
"action=save_desktop_screenshot\n"
|
||||
"status=ok\n"
|
||||
f"path={screenshot_path}\n"
|
||||
f"label={label}\n"
|
||||
"note=The attached image is the latest desktop screenshot."
|
||||
),
|
||||
image_path=str(screenshot_path),
|
||||
metadata={
|
||||
"action": "save_desktop_screenshot",
|
||||
"status": "ok",
|
||||
"path": str(screenshot_path),
|
||||
"label": label,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("save_desktop_screenshot", exc)
|
||||
|
||||
|
||||
def get_mouse_position() -> ToolResponse:
|
||||
"""获取当前鼠标坐标。"""
|
||||
try:
|
||||
pyautogui = _load_pyautogui()
|
||||
x_pos, y_pos = pyautogui.position()
|
||||
return make_success_response(
|
||||
"action=get_mouse_position\nstatus=ok",
|
||||
x=x_pos,
|
||||
y=y_pos,
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("get_mouse_position", exc)
|
||||
|
||||
|
||||
def left_click(x: int, y: int, clicks: int = 1, interval: float = 0.2) -> ToolResponse:
|
||||
"""在指定坐标执行鼠标左键单击。"""
|
||||
try:
|
||||
x = normalize_int(x, default=0)
|
||||
y = normalize_int(y, default=0)
|
||||
clicks = normalize_int(clicks, default=1)
|
||||
interval = normalize_float(interval, default=0.2)
|
||||
pyautogui = _load_pyautogui()
|
||||
pyautogui.click(x=x, y=y, clicks=clicks, interval=interval, button="left")
|
||||
return make_success_response(
|
||||
"action=left_click\nstatus=ok",
|
||||
x=x,
|
||||
y=y,
|
||||
clicks=clicks,
|
||||
interval=interval,
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("left_click", exc)
|
||||
|
||||
|
||||
def type_text(text: str, interval: float = 0.03) -> ToolResponse:
|
||||
"""向当前焦点控件输入文本。"""
|
||||
try:
|
||||
text = normalize_text(text, default="")
|
||||
if not text:
|
||||
return make_tool_response(
|
||||
"action=type_text\nstatus=error\nerror=Empty text is not allowed.",
|
||||
metadata={
|
||||
"action": "type_text",
|
||||
"status": "error",
|
||||
"error": "Empty text is not allowed.",
|
||||
},
|
||||
)
|
||||
interval = normalize_float(interval, default=0.03)
|
||||
pyautogui = _load_pyautogui()
|
||||
pyautogui.write(text, interval=interval)
|
||||
return make_success_response(
|
||||
"action=type_text\nstatus=ok",
|
||||
length=len(text),
|
||||
interval=interval,
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("type_text", exc)
|
||||
|
||||
|
||||
def press_keys(keys: list[str], interval: float = 0.1) -> ToolResponse:
|
||||
"""依次按下多个按键。"""
|
||||
try:
|
||||
keys = normalize_keys(keys)
|
||||
if not keys:
|
||||
return make_tool_response(
|
||||
"action=press_keys\nstatus=error\nerror=At least one key is required.",
|
||||
metadata={
|
||||
"action": "press_keys",
|
||||
"status": "error",
|
||||
"error": "At least one key is required.",
|
||||
},
|
||||
)
|
||||
interval = normalize_float(interval, default=0.1)
|
||||
pyautogui = _load_pyautogui()
|
||||
pyautogui.press(keys, interval=interval)
|
||||
return make_success_response(
|
||||
"action=press_keys\nstatus=ok",
|
||||
keys=keys,
|
||||
interval=interval,
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("press_keys", exc)
|
||||
|
||||
|
||||
def hotkey(*keys: str) -> ToolResponse:
|
||||
"""按组合键,例如 Ctrl+A、Ctrl+C。"""
|
||||
try:
|
||||
keys = tuple(normalize_keys(keys))
|
||||
if not keys:
|
||||
return make_tool_response(
|
||||
"action=hotkey\nstatus=error\nerror=At least one key is required.",
|
||||
metadata={
|
||||
"action": "hotkey",
|
||||
"status": "error",
|
||||
"error": "At least one key is required.",
|
||||
},
|
||||
)
|
||||
pyautogui = _load_pyautogui()
|
||||
pyautogui.hotkey(*keys)
|
||||
return make_success_response(
|
||||
"action=hotkey\nstatus=ok",
|
||||
keys=list(keys),
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("hotkey", exc)
|
||||
|
||||
|
||||
def wait_seconds(seconds: float = 1.0) -> ToolResponse:
|
||||
"""等待指定秒数,给界面渲染或响应留出时间。"""
|
||||
try:
|
||||
seconds = normalize_float(seconds, default=1.0)
|
||||
time.sleep(seconds)
|
||||
return make_success_response(
|
||||
"action=wait_seconds\nstatus=ok",
|
||||
seconds=seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("wait_seconds", exc)
|
||||
|
||||
|
||||
def get_active_window_title() -> ToolResponse:
|
||||
"""获取当前活动窗口标题。"""
|
||||
try:
|
||||
pyautogui = _load_pyautogui()
|
||||
title = ""
|
||||
|
||||
get_title = getattr(pyautogui, "getActiveWindowTitle", None)
|
||||
if callable(get_title):
|
||||
title = get_title() or ""
|
||||
|
||||
return make_success_response(
|
||||
"action=get_active_window_title\nstatus=ok",
|
||||
title=title,
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("get_active_window_title", exc)
|
||||
|
||||
|
||||
def get_clipboard_text() -> ToolResponse:
|
||||
"""读取 Windows 剪贴板文本,用于确认输入结果。"""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", "Get-Clipboard"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
encoding="utf-8",
|
||||
)
|
||||
status = "ok" if completed.returncode == 0 else "error"
|
||||
return make_tool_response(
|
||||
"\n".join(
|
||||
[
|
||||
"action=get_clipboard_text",
|
||||
f"status={status}",
|
||||
f"text={completed.stdout.strip()}",
|
||||
f"stderr={completed.stderr.strip()}",
|
||||
],
|
||||
),
|
||||
metadata={
|
||||
"action": "get_clipboard_text",
|
||||
"status": status,
|
||||
"text": completed.stdout.strip(),
|
||||
"stderr": completed.stderr.strip(),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("get_clipboard_text", exc)
|
||||
|
||||
|
||||
def paste_text(text: str) -> ToolResponse:
|
||||
"""通过剪贴板粘贴文本,降低输入法对 ASCII 输入的干扰。"""
|
||||
try:
|
||||
text = normalize_text(text, default="")
|
||||
if not text:
|
||||
return make_tool_response(
|
||||
"action=paste_text\nstatus=error\nerror=Empty text is not allowed.",
|
||||
metadata={
|
||||
"action": "paste_text",
|
||||
"status": "error",
|
||||
"error": "Empty text is not allowed.",
|
||||
},
|
||||
)
|
||||
|
||||
set_clipboard = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", "Set-Clipboard -Value @'\n" + text + "\n'@"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if set_clipboard.returncode != 0:
|
||||
return make_tool_response(
|
||||
"\n".join(
|
||||
[
|
||||
"action=paste_text",
|
||||
"status=error",
|
||||
f"stderr={set_clipboard.stderr.strip()}",
|
||||
],
|
||||
),
|
||||
metadata={
|
||||
"action": "paste_text",
|
||||
"status": "error",
|
||||
"stderr": set_clipboard.stderr.strip(),
|
||||
},
|
||||
)
|
||||
|
||||
pyautogui = _load_pyautogui()
|
||||
pyautogui.hotkey("ctrl", "v")
|
||||
return make_success_response(
|
||||
"action=paste_text\nstatus=ok",
|
||||
length=len(text),
|
||||
)
|
||||
except Exception as exc:
|
||||
return make_error_response("paste_text", exc)
|
||||
|
||||
|
||||
def register_gui_tools(toolkit: Toolkit) -> None:
|
||||
"""注册本地 Windows GUI 操作工具。"""
|
||||
gui_tools = [
|
||||
save_desktop_screenshot,
|
||||
get_mouse_position,
|
||||
left_click,
|
||||
type_text,
|
||||
paste_text,
|
||||
press_keys,
|
||||
hotkey,
|
||||
wait_seconds,
|
||||
get_active_window_title,
|
||||
get_clipboard_text,
|
||||
]
|
||||
|
||||
for tool_func in gui_tools:
|
||||
toolkit.register_tool_function(tool_func)
|
||||
|
||||
|
||||
def register_local_skills(toolkit: Toolkit) -> None:
|
||||
"""自动注册 skills 目录下的全部本地 skills。"""
|
||||
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
||||
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||
toolkit.register_agent_skill(str(skill_dir))
|
||||
|
||||
|
||||
def strip_thinking_from_msg(msg: Msg) -> Msg:
|
||||
"""移除消息中的 thinking block。"""
|
||||
all_blocks = msg.get_content_blocks()
|
||||
visible_blocks = [
|
||||
block for block in all_blocks if block.get("type") != "thinking"
|
||||
]
|
||||
|
||||
if len(visible_blocks) == len(all_blocks):
|
||||
return msg
|
||||
|
||||
filtered_msg_dict = msg.to_dict()
|
||||
filtered_msg_dict["content"] = visible_blocks
|
||||
return Msg.from_dict(filtered_msg_dict)
|
||||
|
||||
|
||||
def keep_visible_text_blocks(msg: Msg) -> Msg | None:
|
||||
"""仅保留适合展示给用户的自然语言文本块。"""
|
||||
visible_blocks = []
|
||||
|
||||
for block in msg.get_content_blocks():
|
||||
if block.get("type") != "text":
|
||||
continue
|
||||
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
visible_blocks.append(block)
|
||||
|
||||
if not visible_blocks:
|
||||
return None
|
||||
|
||||
filtered_msg_dict = msg.to_dict()
|
||||
filtered_msg_dict["content"] = visible_blocks
|
||||
return Msg.from_dict(filtered_msg_dict)
|
||||
|
||||
|
||||
class SilentThinkingFormatter(OpenAIChatFormatter):
|
||||
"""在发给 OpenAI 兼容接口前移除 thinking block。"""
|
||||
|
||||
async def format(self, msgs: list[Msg], **kwargs: Any) -> list[dict[str, Any]]:
|
||||
sanitized_msgs = [strip_thinking_from_msg(msg) for msg in msgs]
|
||||
return await super().format(sanitized_msgs, **kwargs)
|
||||
|
||||
|
||||
class SilentThinkingReActAgent(ReActAgent):
|
||||
"""仅打印可见文本,隐藏 ThinkingBlock 内容。"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.hide_thinking = kwargs.pop("hide_thinking", True)
|
||||
self.debug_model_output = kwargs.pop("debug_model_output", False)
|
||||
super().__init__(*args, **kwargs)
|
||||
self._react_action_cache: dict[str, str] = {}
|
||||
self._react_observation_cache: dict[str, str] = {}
|
||||
self._suppress_max_iter_summary = False
|
||||
|
||||
@staticmethod
|
||||
def _summarize_text(text: str, limit: int = 240) -> str:
|
||||
"""压缩长文本,避免控制台输出过长。"""
|
||||
normalized = " ".join(text.split())
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return f"{normalized[:limit]}..."
|
||||
|
||||
def _format_tool_input(self, tool_input: Any) -> str:
|
||||
"""将工具输入格式化为简短可读文本。"""
|
||||
try:
|
||||
serialized = json.dumps(tool_input, ensure_ascii=False)
|
||||
except TypeError:
|
||||
serialized = str(tool_input)
|
||||
return self._summarize_text(serialized)
|
||||
|
||||
def _format_tool_output(self, output: Any) -> str:
|
||||
"""将工具输出格式化为简短可读文本。"""
|
||||
if isinstance(output, list):
|
||||
text_parts = []
|
||||
for item in output:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
else:
|
||||
text_parts.append(str(item))
|
||||
return self._summarize_text("\n".join(text_parts))
|
||||
|
||||
return self._summarize_text(str(output))
|
||||
|
||||
def _print_raw_model_output(self, msg: Msg) -> None:
|
||||
"""打印模型原始返回内容,便于调试多模态观察链路。"""
|
||||
if msg.role != "assistant":
|
||||
return
|
||||
|
||||
try:
|
||||
raw_content = json.dumps(
|
||||
msg.get_content_blocks(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
except TypeError:
|
||||
raw_content = str(msg.get_content_blocks())
|
||||
|
||||
print("===== Raw Model Output =====")
|
||||
print(raw_content)
|
||||
print("===== End Raw Model Output =====")
|
||||
|
||||
async def _print_react_process(self, msg: Msg, last: bool) -> None:
|
||||
"""打印 ReAct 中的 Action / Observation 步骤。"""
|
||||
if not last:
|
||||
return
|
||||
|
||||
for block in msg.get_content_blocks():
|
||||
block_type = block.get("type")
|
||||
|
||||
if block_type == "tool_use":
|
||||
tool_name = block.get("name", "unknown_tool")
|
||||
tool_input = self._format_tool_input(block.get("input", {}))
|
||||
cache_key = f"{msg.id}:{block.get('id', tool_name)}"
|
||||
cache_value = f"{tool_name} {tool_input}"
|
||||
if self._react_action_cache.get(cache_key) == cache_value:
|
||||
continue
|
||||
self._react_action_cache[cache_key] = cache_value
|
||||
print(f"{msg.name}[Action]: {cache_value}")
|
||||
|
||||
elif block_type == "tool_result":
|
||||
tool_name = block.get("name", "unknown_tool")
|
||||
tool_output = self._format_tool_output(block.get("output", ""))
|
||||
cache_key = f"{msg.id}:{block.get('id', tool_name)}"
|
||||
cache_value = f"{tool_name} -> {tool_output}"
|
||||
if self._react_observation_cache.get(cache_key) == cache_value:
|
||||
continue
|
||||
self._react_observation_cache[cache_key] = cache_value
|
||||
print(f"{msg.name}[Observation]: {cache_value}")
|
||||
|
||||
async def print(self, msg: Msg, last: bool = True, speech=None) -> None:
|
||||
"""展示自然语言回复,并打印可读的 ReAct 过程。"""
|
||||
await self._print_react_process(msg, last=last)
|
||||
|
||||
if self.debug_model_output and last:
|
||||
self._print_raw_model_output(msg)
|
||||
|
||||
filtered_msg = msg
|
||||
if self.hide_thinking:
|
||||
filtered_msg = strip_thinking_from_msg(filtered_msg)
|
||||
|
||||
filtered_msg = keep_visible_text_blocks(filtered_msg)
|
||||
if filtered_msg is None:
|
||||
return
|
||||
|
||||
await super().print(filtered_msg, last=last, speech=speech)
|
||||
|
||||
async def _summarizing(self) -> Msg:
|
||||
"""在 GUI 单步模式下,达到上限时不要生成误导性总结。"""
|
||||
if self._suppress_max_iter_summary:
|
||||
return Msg(self.name, "", "assistant")
|
||||
return await super()._summarizing()
|
||||
|
||||
|
||||
def looks_like_gui_task(text: str) -> bool:
|
||||
"""粗略判断用户请求是否属于 GUI 自动化任务。"""
|
||||
gui_keywords = [
|
||||
"打开",
|
||||
"浏览器",
|
||||
"chrome",
|
||||
"edge",
|
||||
"bing",
|
||||
"窗口",
|
||||
"桌面",
|
||||
"点击",
|
||||
"输入",
|
||||
"回车",
|
||||
"搜索",
|
||||
"gui",
|
||||
]
|
||||
lowered = text.lower()
|
||||
return any(keyword in text or keyword in lowered for keyword in gui_keywords)
|
||||
|
||||
|
||||
def should_auto_continue_gui(reply: Msg | None) -> bool:
|
||||
"""判断 GUI 任务回复是否表现为中途暂停而非真正完成。"""
|
||||
if reply is None:
|
||||
return False
|
||||
|
||||
text = (reply.get_text_content() or "").strip()
|
||||
if not text:
|
||||
return True
|
||||
|
||||
stop_markers = [
|
||||
"是否需要我继续",
|
||||
"是否需要我继续执行",
|
||||
"下一步计划",
|
||||
"当前任务状态总结",
|
||||
"当前问题",
|
||||
"我将",
|
||||
"准备继续",
|
||||
"让我尝试",
|
||||
"继续执行",
|
||||
"尚未成功",
|
||||
"未成功",
|
||||
"仍然是",
|
||||
"仍为",
|
||||
"当前活动窗口是",
|
||||
"需要先打开",
|
||||
"尝试打开",
|
||||
"尝试关闭",
|
||||
"回到桌面",
|
||||
"<tool_call>",
|
||||
]
|
||||
done_markers = [
|
||||
"任务已完成",
|
||||
"已经完成",
|
||||
"已成功",
|
||||
"搜索结果",
|
||||
"已打开 bing",
|
||||
"已输入test",
|
||||
"已回车搜索",
|
||||
"明确无法继续",
|
||||
"无法继续",
|
||||
]
|
||||
blocked_markers = [
|
||||
"需要用户",
|
||||
"请用户",
|
||||
"权限不足",
|
||||
"无法定位",
|
||||
"无法识别",
|
||||
"无法访问",
|
||||
"明确受阻",
|
||||
]
|
||||
|
||||
if any(marker in text for marker in done_markers):
|
||||
return False
|
||||
|
||||
if any(marker in text for marker in blocked_markers):
|
||||
return False
|
||||
|
||||
if any(marker in text for marker in stop_markers):
|
||||
return True
|
||||
|
||||
# For GUI tasks, default to continue unless the reply clearly indicates
|
||||
# success or a real blocker. This prevents the agent from stopping after
|
||||
# an intermediate summary.
|
||||
return True
|
||||
|
||||
|
||||
def register_optional_skill_access_tools(toolkit: Toolkit) -> None:
|
||||
"""注册受限的本地文本读取工具,避免 agent 使用 shell 绕开 GUI。"""
|
||||
toolkit.register_tool_function(read_local_text_file)
|
||||
print("已注册可选工具: read_local_text_file")
|
||||
|
||||
|
||||
def build_agent(show_thinking: bool, debug_model_output: bool) -> ReActAgent:
|
||||
"""创建一个带有示例 skill 的 ReActAgent。"""
|
||||
toolkit = Toolkit()
|
||||
toolkit.register_tool_function(execute_python_code)
|
||||
register_optional_skill_access_tools(toolkit)
|
||||
register_gui_tools(toolkit)
|
||||
register_local_skills(toolkit)
|
||||
|
||||
agent_cls = SilentThinkingReActAgent
|
||||
formatter = (
|
||||
OpenAIChatFormatter(promote_tool_result_images=True)
|
||||
if show_thinking
|
||||
else SilentThinkingFormatter(promote_tool_result_images=True)
|
||||
)
|
||||
|
||||
agent = agent_cls(
|
||||
name="Jarvis",
|
||||
sys_prompt=(
|
||||
"你是一个名为 Jarvis 的助手。请优先使用已注册的 skill 完成任务。"
|
||||
"对于 GUI 自动化任务,不要在中途停下来询问用户是否继续;"
|
||||
"你应当持续执行观察、操作、复查循环,直到任务完成或明确无法继续。"
|
||||
"每一轮只做一个最小必要动作,并在动作后立刻重新观察。"
|
||||
"不要预设固定的长操作顺序,不要在旧假设上连续执行多步。"
|
||||
),
|
||||
model=OpenAIChatModel(
|
||||
model_name=LLAMA_CPP_MODEL_NAME,
|
||||
api_key=LLAMA_CPP_API_KEY,
|
||||
stream=True,
|
||||
client_kwargs={
|
||||
"base_url": LLAMA_CPP_BASE_URL,
|
||||
"timeout": 60,
|
||||
},
|
||||
generate_kwargs={
|
||||
"temperature": 0.7,
|
||||
},
|
||||
),
|
||||
max_iters=8,
|
||||
hide_thinking=not show_thinking,
|
||||
debug_model_output=debug_model_output,
|
||||
formatter=formatter,
|
||||
toolkit=toolkit,
|
||||
memory=InMemoryMemory(),
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""解析命令行参数。"""
|
||||
parser = argparse.ArgumentParser(description="AgentScope skills demo")
|
||||
parser.add_argument(
|
||||
"--skip-agent-run",
|
||||
action="store_true",
|
||||
help="只打印 skill 注册结果,不调用远程模型。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--message",
|
||||
help="发送给 ReActAgent 的单轮消息;不传时进入交互式对话。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-thinking",
|
||||
action="store_true",
|
||||
help="在控制台显示模型 thinking 内容。默认隐藏。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-name",
|
||||
default="user",
|
||||
help="交互式对话中使用的用户名。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug-model-output",
|
||||
action="store_true",
|
||||
help="打印每轮 assistant 的原始模型返回内容,便于调试截图观察链路。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def chat_once(agent: ReActAgent, user_name: str, user_input: str) -> None:
|
||||
"""发送一条用户消息。"""
|
||||
msg = Msg(
|
||||
name=user_name,
|
||||
content=user_input,
|
||||
role="user",
|
||||
)
|
||||
|
||||
is_gui_task = looks_like_gui_task(user_input)
|
||||
original_max_iters = agent.max_iters
|
||||
original_suppress = getattr(agent, "_suppress_max_iter_summary", False)
|
||||
|
||||
if is_gui_task:
|
||||
agent.max_iters = 1
|
||||
if hasattr(agent, "_suppress_max_iter_summary"):
|
||||
agent._suppress_max_iter_summary = True
|
||||
|
||||
try:
|
||||
reply = await agent(msg)
|
||||
|
||||
if not is_gui_task:
|
||||
return
|
||||
|
||||
for _ in range(DEFAULT_GUI_MAX_STEPS):
|
||||
if not should_auto_continue_gui(reply):
|
||||
return
|
||||
|
||||
follow_up = Msg(
|
||||
name=user_name,
|
||||
content=(
|
||||
"继续执行当前 GUI 任务,不要停下来询问是否继续。"
|
||||
"请继续按照观察、操作、复查的闭环执行,直到任务完成或明确无法继续。"
|
||||
"如果你刚才输出的是计划、状态总结,或原样的 <tool_call> 文本,请立即把它落实为真实工具调用并继续。"
|
||||
"下一轮只做一个最小必要动作,并在动作后立刻截图复查。"
|
||||
"这一轮最多只允许一次真实工具动作,不要连续做多步。"
|
||||
),
|
||||
role="user",
|
||||
)
|
||||
reply = await agent(follow_up)
|
||||
finally:
|
||||
agent.max_iters = original_max_iters
|
||||
if hasattr(agent, "_suppress_max_iter_summary"):
|
||||
agent._suppress_max_iter_summary = original_suppress
|
||||
|
||||
|
||||
async def chat_loop(agent: ReActAgent, user_name: str) -> None:
|
||||
"""通过命令行输入与模型持续对话。"""
|
||||
print("\n===== 交互式对话已启动 =====")
|
||||
print("输入 /exit 结束对话,输入 /help 查看提示。")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = await asyncio.to_thread(input, f"\n{user_name}> ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n对话结束。")
|
||||
return
|
||||
|
||||
user_input = user_input.strip()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input in {"/exit", "/quit"}:
|
||||
print("对话结束。")
|
||||
return
|
||||
|
||||
if user_input == "/help":
|
||||
print("直接输入消息即可和模型对话。使用 /exit 或 /quit 退出。")
|
||||
continue
|
||||
|
||||
try:
|
||||
await chat_once(agent, user_name, user_input)
|
||||
except Exception as exc:
|
||||
print(f"本轮对话执行失败:{type(exc).__name__}: {exc}")
|
||||
print("你可以继续输入下一条消息,或输入 /exit 退出。")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
args = parse_args()
|
||||
agent = build_agent(
|
||||
show_thinking=args.show_thinking,
|
||||
debug_model_output=args.debug_model_output,
|
||||
)
|
||||
|
||||
print("===== 已注册 skill 提示词 =====")
|
||||
print(agent.toolkit.get_agent_skill_prompt())
|
||||
|
||||
print("\n===== 当前模型配置 =====")
|
||||
print(f"base_url={LLAMA_CPP_BASE_URL}")
|
||||
print(f"model_name={LLAMA_CPP_MODEL_NAME}")
|
||||
print(f"show_thinking={args.show_thinking}")
|
||||
print(f"debug_model_output={args.debug_model_output}")
|
||||
|
||||
if args.skip_agent_run:
|
||||
print("\n===== 已跳过远程模型调用 =====")
|
||||
return
|
||||
|
||||
if args.message:
|
||||
print("\n===== 单轮对话模式 =====")
|
||||
await chat_once(agent, args.user_name, args.message)
|
||||
return
|
||||
|
||||
await chat_loop(agent, args.user_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
agentscope
|
||||
openai
|
||||
pyautogui
|
||||
pillow
|
||||
31
skills/python_hello_skill/SKILL.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: python_hello_skill
|
||||
description: 当用户希望用 Python 执行一个简单示例、打印文本或快速验证代码时,优先使用这个技能。
|
||||
---
|
||||
|
||||
# Python Hello Skill
|
||||
|
||||
当用户要求你用 Python 输出一段文本、演示最小代码示例或确认 Python 工具是否可用时,按下面步骤执行:
|
||||
|
||||
1. 识别用户是否需要真正运行 Python,而不是只给出代码。
|
||||
2. 如果需要运行,优先调用 Python 执行工具,而不是只描述做法。
|
||||
3. 对于最小示例,默认使用下面这段代码:
|
||||
|
||||
```python
|
||||
print("Hello from skill demo!")
|
||||
```
|
||||
|
||||
4. 运行完成后,简要总结执行结果。
|
||||
5. 如果用户没有明确指定输出内容,可以使用上面的默认文本。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户说“用 Python 打个招呼”
|
||||
- 用户说“帮我运行一个 Hello World”
|
||||
- 用户说“验证一下 Python 工具能不能用”
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 除非用户要求复杂逻辑,否则保持代码最小化。
|
||||
- 优先直接执行,不要只返回伪代码。
|
||||
- 如果工具执行失败,说明失败原因并给出可复现的 Python 代码。
|
||||
115
skills/windows_gui_operator/SKILL.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: windows_gui_operator
|
||||
description: 在 Windows 桌面环境中执行 GUI 自动化。优先把截图直接提供给多模态模型观察,并按“观察-操作-复查-必要时重试”的闭环执行。
|
||||
---
|
||||
|
||||
# Windows GUI Operator
|
||||
|
||||
当用户要求你操作 Windows 图形界面时,必须严格遵循下面的 ReAct 工作流。
|
||||
|
||||
这个 skill 假设当前模型具备多模态图像理解能力,因此你应该把截图结果当作“观察输入”的核心证据,而不是只依赖文字描述。
|
||||
|
||||
## 核心原则
|
||||
|
||||
- 必须先观察,再操作,再复查;如果没有达到目标,继续下一轮观察和调整。
|
||||
- 每个关键步骤都要保留本地截图,截图既要保存到本地,也要作为图片结果交给模型判断。
|
||||
- 所有桌面操作都只能通过 GUI 完成,不允许通过 shell、命令行、直接启动 URL 或直接启动应用来绕过界面。
|
||||
- 不要盲点、盲输;在缺少坐标或上下文时,先获取更多观察信息。
|
||||
- 每次完成关键动作后,都要做确认,而不是假设动作已经生效。
|
||||
- 若当前动作未达到效果,必须继续循环,直到完成目标或明确受阻。
|
||||
- 不要在任务中途向用户提问“是否继续”。除非已经明确受阻,否则应自动进入下一轮观察和操作。
|
||||
- 如果你输出的只是计划、状态总结、解释文字,或者原样的 `<tool_call>` 文本,这都不算完成;你必须继续把它落实为真实工具调用。
|
||||
- 一次只做一个最小必要动作。不要预先规划一长串固定操作并连续执行。
|
||||
- 下一步动作必须基于“刚刚最新的一张截图和最新观察结果”,而不是基于上一个假设继续硬做。
|
||||
- 在决定下一步动作前,先明确描述最新截图里的关键界面状态,例如开始菜单是否打开、浏览器是否已经出现、地址栏是否聚焦。
|
||||
|
||||
## 单步闭环工作流
|
||||
|
||||
每一轮都按下面顺序进行:
|
||||
|
||||
1. `Observe`:
|
||||
- 调用 `get_active_window_title`
|
||||
- 调用 `save_desktop_screenshot`
|
||||
- 读取截图对应的图片内容,先简短描述你看到了什么,再判断当前界面状态和下一步动作
|
||||
2. `Act`:
|
||||
- 只选择一个最小必要动作执行
|
||||
- 可用工具包括:`left_click`、`type_text`、`press_keys`、`hotkey`、`wait_seconds`
|
||||
3. `Verify`:
|
||||
- 再次调用 `save_desktop_screenshot`
|
||||
- 结合新截图、活动窗口标题、工具返回文本进行判断
|
||||
- 对输入任务,优先额外用 `get_clipboard_text` 做校验
|
||||
4. `Decide`:
|
||||
- 如果目标已达成,结束
|
||||
- 如果目标未达成但可继续,基于最新截图重新判断,再进入下一轮 Observe
|
||||
- 如果无法继续,明确说明阻塞点和建议
|
||||
|
||||
不要只执行单轮。默认至少假设需要多轮迭代,直到确认成功。
|
||||
不要在中途仅输出状态总结后停止等待用户确认。
|
||||
如果当前轮没有真正完成目标,就继续下一轮,而不是把控制权交还给用户。
|
||||
|
||||
## 决策要求
|
||||
|
||||
- 每一轮只能提交一个动作决策,例如“按 Win 键”或“点击地址栏”或“输入文本”。
|
||||
- 每一轮最多只允许一次真实工具动作;动作完成后必须先复查,不能在同一轮继续第二个 GUI 动作。
|
||||
- 不要在同一轮里把“按 Win -> 输入 chrome -> 回车 -> 打开地址栏 -> 输入网址 -> 回车”当作一个固定脚本整体执行。
|
||||
- 如果某一步出现偏差,例如焦点不对、窗口没切换、输入框没获得焦点,就必须停下来重新观察,而不是继续执行原计划。
|
||||
- 如果你不确定按钮、输入框或窗口状态,就先截图再决定,不要赌测。
|
||||
|
||||
## 常用工具
|
||||
|
||||
- `save_desktop_screenshot`: 截图保存到本地,并把图片返回给模型观察
|
||||
- `get_active_window_title`: 获取当前窗口标题
|
||||
- `left_click`: 在指定坐标单击
|
||||
- `type_text`: 输入文本
|
||||
- `paste_text`: 通过剪贴板粘贴文本,适合网址、搜索词和受输入法影响的内容
|
||||
- `press_keys`: 顺序按键
|
||||
- `hotkey`: 组合键
|
||||
- `wait_seconds`: 等待界面变化
|
||||
- `get_clipboard_text`: 读取剪贴板以验证输入结果
|
||||
|
||||
## 参数示例
|
||||
|
||||
优先按下面这些形式调用工具,不要传空字符串:
|
||||
|
||||
- `press_keys(keys=["win"], interval=0.1)`
|
||||
- `type_text(text="chrome", interval=0.03)`
|
||||
- `press_keys(keys=["enter"], interval=0.1)`
|
||||
- `hotkey("ctrl", "l")`
|
||||
- `paste_text(text="https://www.bing.com")`
|
||||
- `wait_seconds(seconds=1.0)`
|
||||
- `left_click(x=300, y=120, clicks=1, interval=0.2)`
|
||||
- `paste_text(text="test")`
|
||||
- `press_keys(keys=["enter"], interval=0.1)`
|
||||
- `hotkey("ctrl", "l")`
|
||||
|
||||
如果你暂时不确定具体参数,也要使用合理默认值,而不是传空字符串。
|
||||
对网址、英文搜索词和命令词,优先使用 `paste_text`,不要依赖当前输入法状态。
|
||||
|
||||
## 确认策略
|
||||
|
||||
当前模型具备多模态能力时,你应当把截图内容纳入判断依据。
|
||||
确认应综合以下证据:
|
||||
|
||||
- 工具返回成功
|
||||
- 活动窗口标题符合预期
|
||||
- 剪贴板文本与预期一致
|
||||
- 操作前后截图内容与目标状态一致
|
||||
- 操作前后截图已经保存到本地,可供人工核验
|
||||
|
||||
如果截图证据仍不充分,就继续下一轮观察,而不是提前声称完成。
|
||||
|
||||
## 失败与重试策略
|
||||
|
||||
- 每次动作失败后,先截图复查,再决定下一步。
|
||||
- 不要连续盲目重复同一个点击动作超过 3 次。
|
||||
- 如果无法定位目标控件,先通过截图重新判断布局,再进行更小步的操作。
|
||||
- 如果应用未启动成功,优先通过开始菜单、任务栏或桌面图标继续用 GUI 方式尝试。
|
||||
- 如果经过多轮仍不能完成,给出清晰的阻塞原因。
|
||||
- 如果某个动作连续两轮都没有带来预期变化,优先改变策略,而不是机械重复原动作链。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 坐标点击前,优先让用户提供窗口状态、分辨率或目标位置描述。
|
||||
- 涉及敏感操作时,先复述将要执行的动作,再执行。
|
||||
- 如果动作可能产生副作用,拆成更小步骤,并在每一步后确认。
|
||||
- 若界面未响应,先 `wait_seconds`,再重新截图,不要连续重复点击。
|
||||