第 3 课

写一个会自我核对的 Python 循环,再接上会话追踪和编程智能体

用 Python 标准库写一个计算器智能体,按官方规则只在 retry 为 safe 时重试;亲眼看到连按同一个键 6 次错 2 次,改成每按一个键就核对后 6 次全对;认识 INVALID_ARGS、WINDOW_NOT_FOUND 等错误码;用会话和追踪统计一次运行做了什么;最后用 skills get、AGENTS.md 和 npx skills add 交给编程智能体。

图文25 分钟agent-desktop 官方文档 ↗

课程目录第 3 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课对照的官方页面:The observe, act, verify loop 、Errors and recovery 、Sessions 、Skills 、Linux 。测试环境同课时 1(agent-desktop 1.2.0,Python 3.13.5,只用标准库)。下面所有「对照结果」都是这台机器的真实输出。

官方说 agent-desktop 是工具,不是智能体,循环要由调用方来跑。真正接入 AI 智能体之前,先用一个几十行的 Python 脚本把循环跑通很有好处:哪些规则必须遵守、哪些错误可以重试,都能在不花一分钱 API 费用的情况下看清楚。这一课写一个「计算器智能体」:给它一条算式,它自己找按钮、按下去、核对结果。

第 1 步:写第一版循环脚本

脚本做四件事:调用 agent-desktop-run 并解析 JSON;按角色和名字找引用;点击时遇到可以安全重试的错误就重新找一次引用;按完 = 后用 wait 核对结果。新建 calc_agent_v1.py:

import json
import subprocess
import sys

APP = "gnome-calculator"
KEYS = {"*": "×", "/": "÷", "-": "−"}
RETRY_CODES = {"STALE_REF", "SNAPSHOT_NOT_FOUND", "WINDOW_NOT_FOUND"}


def ad(*args):
    out = subprocess.run(["agent-desktop-run", *args],
                         capture_output=True, text=True).stdout
    return json.loads(out)


def find(role, name=None):
    args = ["find", "--app", APP, "--role", role, "--first"]
    if name is not None:
        args += ["--name", name, "--exact"]
    res = ad(*args)
    if not res["ok"]:
        raise RuntimeError(res["error"]["code"])
    return res["data"]["match"]["ref_id"]


def press(label):
    for attempt in range(2):
        res = ad("click", find("button", label))
        if res["ok"]:
            return res["data"]["disposition"]["delivery"]
        err = res["error"]
        if err["code"] in RETRY_CODES and err["disposition"]["retry"] == "safe":
            continue
        raise RuntimeError(f"{label}: {err['code']}")
    raise RuntimeError(f"{label}: gave up")


def main(expr):
    expected = str(eval(expr, {"__builtins__": {}}))
    screen = find("textfield")
    for ch in expr:
        print(ch, press(KEYS.get(ch, ch)))
    press("=")
    res = ad("wait", "--element", screen, "--predicate", "value",
             "--value", expected, "--timeout", "5000")
    print("expected", expected, "verified", res["ok"])
    value = ad("get", screen, "--property", "value")["data"]["value"]
    print("display shows", value)
    press("C")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "12*(3+4)")

几处设计都来自官方的恢复规则:只按 error.code 和 error.disposition 分支,不看错误文字;STALE_REF、SNAPSHOT_NOT_FOUND 这类错误的意思是「重新观察,用新引用」;只有 disposition.retry 为 safe 时才重发。eval 只用来算出预期答案,输入是你自己给的算式。运行:

python3 calc_agent_v1.py "12*(3+4)"

对照结果:

1 delivered_unverified
2 delivered_unverified
* delivered_unverified
( delivered_unverified
3 delivered_unverified
+ delivered_unverified
4 delivered_unverified
) delivered_unverified
expected 84 verified True
display shows 84

第 2 步:连按同一个键,看循环怎么出错

换一条有重复数字的算式,连跑 6 次:

for i in 1 2 3 4 5 6; do python3 calc_agent_v1.py "11+22" | tail -1; done

对照结果:

display shows 33
display shows 23
display shows 33
display shows 33
display shows 33
display shows 13

6 次里错了 2 次:一次少按了一个 1(1+22=23),一次少按了一个 2(11+2=13)。每次点击都返回 delivered_unverified,没有任何报错。原因在官方 Linux 页面的排错表里:GTK4 会把间隔不到约 250 毫秒的两次点击当成一次。好在脚本最后有 wait 核对,出错时打印的是 verified False,错误没有被悄悄放过。

第 3 步:每按一个键就核对一次

修法不是加 sleep,而是照官方说的「等前一次的效果出现再按下一次」。计算器显示屏会实时显示已输入的算式,所以每按一个键,就等显示屏变成「目前应该显示的内容」。把 calc_agent_v1.py 复制一份为 calc_agent.py,把 main() 里从 screen = find("textfield") 到 press("=") 的这几行换成:

    screen = find("textfield")
    typed = ""
    for ch in expr:
        label = KEYS.get(ch, ch)
        press(label)
        typed += label
        res = ad("wait", "--element", screen, "--predicate", "value",
                 "--value", typed, "--timeout", "3000")
        print(ch, "->", typed, "ok" if res["ok"] else "NOT SEEN")
    press("=")

运行:

python3 calc_agent.py "11+22"
for i in 1 2 3 4 5 6; do python3 calc_agent.py "11+22" | tail -1; done

对照结果:

1 -> 1 ok
1 -> 11 ok
+ -> 11+ ok
2 -> 11+2 ok
2 -> 11+22 ok
expected 33 verified True
display shows 33
display shows 33
display shows 33
display shows 33
display shows 33
display shows 33
display shows 33

再跑 6 次全部是 33。代价是每个键多一次读取:测试机上各跑 3 次 11+22,第一版每次约 2.6 秒,新版约 3.9 秒。这就是官方说的「每一步都以一次读取结束」,交给 AI 智能体时也一样。

第 4 步:认识几种常见错误码

新建 err_demo.sh,故意制造错误:传不带快照 ID 的引用;关掉计算器再打开,然后拿旧引用去点:

OLD=$(agent-desktop-run find --app gnome-calculator --role button --name 7 --exact \
  --first | jq -r '.data.match.ref_id')
echo "OLD=$OLD"
agent-desktop-run click @e1 | jq -c '.error | {code, message}'
agent-desktop-run close-app gnome-calculator | jq -c '{ok}'
agent-desktop-run launch gnome-calculator | jq -c '{ok}'
agent-desktop-run click "$OLD" | jq -c '.error | {code, retry: .disposition.retry}'

运行:

bash err_demo.sh

对照结果:

OLD=@s3d363tld993aj:e1
{"code":"INVALID_ARGS","message":"Invalid ref_id '@e1': expected @<snapshot_id>:e<N>"}
{"ok":true}
{"ok":true}
{"code":"WINDOW_NOT_FOUND","retry":"safe"}

@e1 这种裸引用从 0.10.0 起就被拒绝了,必须带上快照 ID。程序重开之后窗口换了,旧引用指向的窗口已经不存在,返回 WINDOW_NOT_FOUND,retry 是 safe:什么都没发出去,可以放心重新观察。界面变化但窗口还在时,常见的是 STALE_REF;有多个相同窗口或元素时是 AMBIGUOUS_TARGET,要用 --window-id、find --exact 或 --root 缩小范围。官方一共定义了 16 个错误码,完整的恢复办法在 Errors and recovery 页面。第 1 步脚本里的 RETRY_CODES 就是按这张表写的。

第 5 步:用会话隔离并留下追踪记录

一台机器上同时跑几个智能体,或者想事后查清一次运行做了什么,就用会话。会话有自己的快照命名空间,默认还会记录追踪(trace)。新建 session_demo.sh:

SID=$(agent-desktop-run session start --name calc-demo | jq -r '.data.session_id')
echo "SID=$SID"
export AGENT_DESKTOP_SESSION=$SID
python3 calc_agent.py "6*7" | tail -2
agent-desktop-run session end | jq -c '.data'
unset AGENT_DESKTOP_SESSION
ls ~/.agent-desktop/sessions/$SID/trace | wc -l
cat ~/.agent-desktop/sessions/$SID/trace/*.jsonl \
  | jq -r 'select(.event == "command.end") | .command' | sort | uniq -c

运行:

bash session_demo.sh

对照结果:

SID=run-1794500881531-3057791-0
expected 42 verified True
display shows 42
{"ended_at":1794500884717,"session_id":"run-1794500881531-3057791-0"}
17
      5 click
      6 find
      1 get
      1 session
      4 wait

AGENT_DESKTOP_SESSION 环境变量让脚本里的每条命令都落在这个会话里,Python 子进程会继承它。追踪目录里一条命令一个 JSONL 文件,按 command.end 事件统计,正好是这次运行的 6 次查找、5 次点击、4 次等待和 1 次读取,再加上结束会话那一条。会话结束后再用它执行命令会返回 INVALID_ARGS(session_ended),清理已结束的会话用 agent-desktop session gc --ended。

第 6 步:把它交给编程智能体

到这里,循环里的每条规则你都亲手验证过了。要让 Claude Code、Codex 这类编程智能体自己操作桌面,官方给的方法是让它先读内置的技能说明。说明编译在二进制里,和你装的版本永远一致,不需要桌面会话:

agent-desktop skills list | jq -r '.data.skills[].name'
agent-desktop skills get desktop | jq -r '.data.content' | sed -n '18,20p'

对照结果:

agent-desktop
agent-desktop-macos
agent-desktop-windows
agent-desktop-linux
agent-desktop-ffi
jev-desktop
# agent-desktop

agent-desktop is a CLI that reads and operates desktop apps through the OS accessibility
tree. Every command prints one JSON envelope (`ok`, `data` or `error`). It is a tool,
not an agent: the observe, act and verify loop runs in you.

最后一段是节选,原文是一整行,这里按宽度折了行。

官方建议在项目的 AGENTS.md(或你所用智能体的同类说明文件)里写上下面两句,让智能体在第一次使用前自己读取说明:

Use `agent-desktop` to see and operate desktop apps. Before the first use, run
`agent-desktop skills get desktop` and `agent-desktop skills get platform`,
and follow them.

支持技能目录的智能体也可以装一份静态副本,但升级 CLI 后副本可能过时,官方更推荐上面的 skills get:

npx skills add https://github.com/vercel-labs/agent-desktop --skill agent-desktop

如果用的是本课的无头桌面,记得在说明里让智能体通过 agent-desktop-run 执行命令。本课没有实测由 AI 智能体驱动的完整会话(需要付费模型),只验证了技能命令的输出。

常见坑

  • 只在最后核对一次:中间某一步丢了点击,最后只能知道「错了」,不知道错在哪。每步一读,出错的位置一目了然。
  • 按错误信息的文字分支:文字会随版本变化。只看 error.code 和 disposition.retry。
  • retry 是 unsafe 也重发:点击类操作的 retry 通常是 unsafe,重发可能多按一次。先读界面,确认没生效再操作。
  • 环境变量没传进子进程:用 export 设置 AGENT_DESKTOP_SESSION,否则 Python 里调用的命令会落回全局命名空间。
  • 用了过时的静态技能:npx skills add 装的是当时的副本。升级 agent-desktop 后要重装,或者干脆改用 skills get。
  • 把追踪和截图传到公开场合:追踪、快照和截图都可能记录桌面上的私人内容,官方提醒不要放进公开日志。

小结

这一课用不到 70 行的 Python 写出了一个会自我核对的桌面循环:第一版在连按同一个键时 6 次错 2 次,改成每按一个键就核对之后 6 次全对;认识了 INVALID_ARGS、WINDOW_NOT_FOUND 等错误码和「只有 retry 为 safe 才重发」的规则;用会话和追踪记录下一次完整运行;最后看到了让编程智能体接手的两种方式。到这里,你已经能在任何一台 Linux 机器上,让脚本或 AI 智能体可靠地操作桌面程序了。