agent-desktop 源码解读封面

让 AI 操作桌面,最容易演示的能力是“点击一个按钮”。真正难的却是:界面已经变了,它凭什么知道现在点击的,仍然是刚才观察到的目标?

列表插入一行、弹窗突然出现、应用同时打开两个窗口,都会让“第几个控件”“原来的坐标”变得不可靠。模型把按钮文字读对,并不等于执行层就能安全复用旧定位。

2026 年 11 月 4 日,我查看 GitHub Trending 的 Today 页面时,vercel-labs/agent-desktop 位于榜单中,页面显示 13,583 stars、3,791 stars today。这只是当时页面的观察值,不是性能指标,也不能代表所有桌面 Agent 的发展情况。

2026年11月4日 GitHub Trending 中的 agent-desktop

这个项目提供面向 AI Agent 的桌面操作 CLI 与 C 库,主要通过操作系统的无障碍树读取、定位和操作控件。本文不做命令大全,而是沿着一条源码主线,拆解它如何处理旧引用、重复目标和不完整观测。[1]

阅读基线:v1.1.0,提交 d3dc190afbf34cd2377252398ddd5c30efbd093a。下文源码链接均固定到该提交。本文完成了核心源码与文档阅读,并运行了文末 Python 原理示例;没有安装运行完整 agent-desktop,也没有验证 macOS、Windows、Linux 上的真实桌面点击效果。文档中的 JSON version: 2.6 指输出协议版本,不是软件发布版本。

一、先把“会看界面”和“会执行动作”分开

agent-desktop 的 README 展示了一个很小的循环:

# macOS Finder 示例;ref 必须换成自己本次快照实际返回的值。
agent-desktop snapshot --app Finder -i
agent-desktop click '@s8f3k2p9:e8'
agent-desktop snapshot --app Finder -i

第一条命令观察界面,第二条命令使用观察结果中的引用执行动作,第三条命令再观察实际变化。这里的 -i 用于聚焦交互元素。[1]

工具提供观察与操作能力,选择下一步、判断是否完成的循环仍然在调用它的 Agent 中。因此,接上这个 CLI 不等于获得一个自动理解全部业务流程的桌面助理。

无障碍树也有自己的信息边界:它能提供控件角色、名称以及应用愿意暴露的属性;自绘区域、未暴露语义的控件,不能仅凭“支持无障碍 API”就假设能够完整识别。读取失败、字段未知,都必须进入后续判断。

二、为什么引用里必须带 snapshot_id

在 ref_token.rs 中,公开引用的格式是:

@<snapshot_id>:e<N>

例如 @s8f3k2p9:e8。解析器先拆出快照 ID,再校验元素编号;缺少快照部分的裸 @e8 会被拒绝。[2]

这解决的是一个很基础、却容易被忽视的问题:元素编号必须说明“来自哪一份观察”。

假设第一次快照中 e8 是“保存”,第二次快照中,控件顺序变化后另一个元素也可能获得本地编号 e8。如果执行端只接收编号,调用方很容易把两份观察混在一起。

带上 snapshot_id 后,工具可以先找到对应的保存记录,再依据当时保存的目标身份去解析。它并没有因此承诺“旧引用永远有效”;快照记录找不到、目标消失或身份无法确认,仍然应当失败。

三、路径和元素身份,是两回事

ref_identity.rs 明确区分了真正的元素 token 与保存的位置:core 只把带有 axtok1: 前缀的对应记录当作真正的元素 token。仅保留位置路径或 runtime ID 的平台,不能把它们冒充为对象句柄。[3]

可以用一个两行列表理解:

时刻索引 0索引 1
捕获时保存取消
调整后取消保存

“沿着索引 0 找过去”仍能找到一个按钮,但已经找错了。路径回答的是在哪里,目标身份回答的是找的是谁。

resolve_saved_ref 的入口因此分成两条路径:[4]

  • 携带真正元素 token:交给平台 adapter 严格解析。
  • 没有真正元素 token:走捕获证明与当前完整窗口中的唯一性验证。

在该版本的文档中,macOS 在取得元素地址时使用前一类能力;Windows、Linux,以及 macOS 无法取得地址的元素,则需要后面的证明路径。[7]

这里也不能反过来推导“macOS 的引用永久有效”。元素被销毁、应用重启,或者进程、角色和已记录标识不再符合,仍然会被拒绝。

agent-desktop 保存引用解析流程

四、唯一性必须证明两次:捕获时一次,执行时一次

4.1 不是只比较按钮文字

FullPredicate 定义了捕获和后续解析共同使用的完整谓词:role,加上已经记录的原生标识、稳定名称、描述字段。原生标识包含类型和值;每个被记录的字段都必须符合。[5]

这比“看到同名按钮就点它”严格,但它也不是对全部属性做相等比较。源码明确排除了可变 value 与几何 bounds 作为身份依据,避免把正在编辑的值、会移动的位置直接当成身份。

因此,“两个同名按钮一定不可操作”也不准确:如果它们有不同的可用原生标识,或者具备真正的元素 token,就可能被区分。后文讨论的歧义,是指完整身份谓词无法区分的目标。

4.2 捕获时:完整窗口里必须只有一个匹配

CaptureProof::proves_node 要求:观测来自完整窗口;完整谓词恰好命中一个节点;相关字段不能存在未读造成的不确定性,而且唯一命中的就是当前节点。[6]

这里的“完整窗口”很关键。一个局部区域里只出现一次“删除”,不代表窗口其他区域没有另一个身份字段相同的“删除”。输出经过筛选、只展示一部分控件,也不能据此宣布唯一。

如果捕获时没有证明唯一,后续保存引用解析会直接拒绝;它不会等到点击时再随便找一个最像的控件。

4.3 执行时:重新观察,还必须唯一

对于没有真正元素 token 的保存引用,observe_saved_ref 会找到原来源窗口,再观察窗口并计算当前匹配。[4]

裁决可以概括为:

当前证据处理含义
完整证据下没有匹配原目标已无法按保存身份找到
完整证据下恰好一个匹配可以进入落点解析与再次校验
有多个匹配歧义,不能任选一个
观测不完整,或有影响身份判定的未知字段无法证明,拒绝猜测

这里采用的是 Match / NoMatch / Unknown 三态判断。读不到,不等于不相同,更不等于已经匹配。

这是一种值得借鉴的工程选择:不把证据缺失折叠成一个方便继续执行的布尔值。

五、找到唯一目标后,为什么还要检查落点

完整窗口计数和真正落到元素之间仍有时间差:

  1. 观察时,“保存”唯一,位于某条路径。
  2. 程序准备沿该路径取得可操作句柄。
  3. 应用插入了控件,原路径上变成另一个元素。

如果只相信第 1 步的计数,第 3 步仍可能导致误点。

所以解析器拿到匹配节点的当前位置、取得句柄之后,还会调用 confirm_landed,对实际落到的元素重新比较保存的完整谓词。[4]

它针对的是“检查时成立,使用时变化”的时间窗口。这个额外检查提高了拒绝错目标的能力,但不意味着整个桌面 UI 被锁成一个事务;也不证明按钮背后的业务对象和业务数据始终没有变化。

尤其是无真正元素 token 的路径,身份判断仍然建立在已记录、可读取的字段上。**如果业务上下文变化没有体现在这些字段中,仅凭这套规则无法推出业务语义不变。**这是从匹配机制得出的边界,而不是对项目实际误点率的测量。

六、比“失败就重试”更重要的,是先知道动作有没有发出

该版本的 JSON 文档把动作交付状态与重试判断放进 disposition。例如,命令可以返回成功,但动作只达到 delivered_unverified:已交付,没有证明最终效果。[7]

这对“提交”“删除”“发送”一类动作很重要。如果动作已经到达应用,只是读取结果时超时,直接重复操作可能产生重复副作用。

接入方至少要区分:

  • not_delivered:有证据表明动作未发送,允许依据恢复建议处理。
  • delivered_unverified:动作已交付,先观察实际结果。
  • delivery_uncertain 或 unknown:交付情况不明确,不能当成安全重试。

文档要求结合 disposition.retry 判断,而不是只根据 TIMEOUT 等错误名称重放。即便 retry 是 safe,也不等于“原样重试会成功”:捕获时不唯一的引用,没有发送动作,但重复同一引用仍然无法消除歧义。[7]

还要注意两个“完整性”概念:snapshot 的 complete 描述呈现出来的树是否完整;identity_unproven 则提示无真正元素 token 的引用所需的完整窗口捕获证明没有建立。不能只看到 ok: true,就跳过这两项检查。

七、用 88 行 Python 看懂旧索引为什么危险

下面是一个独立教学模型,保存为 snapshot_ref_demo.py,用 Python 3.10+ 运行即可。它只用标准库、不操作真实桌面,也不是 agent-desktop 的 Python 移植。

为便于理解,模型只按 (role, name) 匹配,省略了原生 token、窗口/进程校验、原生标识、三态字段读取和落点后再次校验。complete、expected_count 都是模拟输入,不能照搬成真实无障碍树完整性的证明方法。

"""快照引用教学模型:不操作桌面,不复制任何平台的原生 token 路径。

这里只按 (role, name) 匹配;complete 和 expected_count 是模拟观测证据,
不意味着真实桌面的完整性或控件身份能仅靠名称、数量得到证明。
"""

from dataclasses import dataclass


@dataclass(frozen=True)
class Node:
    role: str
    name: str


@dataclass(frozen=True)
class Snapshot:
    key: str
    nodes: tuple[Node, ...]
    complete: bool = True
    expected_count: int = 2

    def evidence_ok(self):
        return self.complete and len(self.nodes) == self.expected_count


@dataclass(frozen=True)
class Ref:
    snapshot_key: str
    element_id: int  # e1 是捕获快照中的第一个元素,不是当前树的索引。

    def __str__(self):
        return f"@{self.snapshot_key}:e{self.element_id}"


def resolve(ref, captured, current):
    """返回当前观测中的唯一匹配;任何门禁失败都不执行操作。"""
    if ref.snapshot_key != captured.key:
        raise ValueError("引用与捕获快照不匹配")
    if not captured.evidence_ok():
        raise ValueError("捕获观测不完整")
    if not 1 <= ref.element_id <= len(captured.nodes):
        raise ValueError("捕获引用不存在")
    target = captured.nodes[ref.element_id - 1]
    if captured.nodes.count(target) != 1:
        raise ValueError("捕获时不唯一")
    if not current.evidence_ok():
        raise ValueError("当前观测不完整")
    matches = [i for i, node in enumerate(current.nodes) if node == target]
    if len(matches) != 1:
        raise ValueError(f"当前不唯一或已消失:匹配数={len(matches)}")
    return matches[0]


def rejected(label, ref, captured, current, expected):
    try:
        resolve(ref, captured, current)
    except ValueError as error:
        assert str(error) == expected, (label, str(error))
        print(f"{label}:拒绝,{error}")
    else:
        raise AssertionError(f"{label} 应被拒绝")


def main():
    save, cancel = Node("button", "保存"), Node("button", "取消")
    captured = Snapshot("snapA", (save, cancel))
    ref = Ref("snapA", 1)
    reordered = Snapshot("snapB", (cancel, save))
    print("教学模型;不操作真实桌面,未复制平台原生 token 路径。")
    print(f"捕获引用:{ref}")
    assert reordered.nodes[0] == cancel  # 复用旧索引 0 会误选“取消”。
    index = resolve(ref, captured, reordered)
    assert index == 1 and reordered.nodes[index] == save
    print("案例1:树序改变,旧索引0指向取消;引用重定位到当前索引1(保存)")
    duplicated_capture = Snapshot("snapA", (save, save))
    rejected("案例2", ref, duplicated_capture, reordered, "捕获时不唯一")
    duplicated_now = Snapshot("snapC", (save, save))
    rejected("案例3", ref, captured, duplicated_now, "当前不唯一或已消失:匹配数=2")
    partial_now = Snapshot("snapD", (save,), complete=False)
    rejected("案例4", ref, captured, partial_now, "当前观测不完整")
    partial_capture = Snapshot("snapA", (save,), complete=False)
    rejected("案例5", ref, partial_capture, reordered, "捕获观测不完整")
    print("PASS:5个确定性案例通过 assert 自检;未执行点击。")


if __name__ == "__main__":
    main()

本地运行:

python3 snapshot_ref_demo.py

实际输出:

教学模型;不操作真实桌面,未复制平台原生 token 路径。
捕获引用:@snapA:e1
案例1:树序改变,旧索引0指向取消;引用重定位到当前索引1(保存)
案例2:拒绝,捕获时不唯一
案例3:拒绝,当前不唯一或已消失:匹配数=2
案例4:拒绝,当前观测不完整
案例5:拒绝,捕获观测不完整
PASS:5个确定性案例通过 assert 自检;未执行点击。

这个例子验证的是一个很小的思想:树顺序改变后,旧下标已经不能代表原目标;当唯一性或观测完整性不成立时,拒绝执行比自动选第一个更可控。它不构成完整项目的性能、兼容性或真实桌面安全测试。

八、准备接入时,先核对这些边界

平台能力不能一概而论。 README 要求 macOS 授予无障碍权限;Windows 的能力受应用完整性级别影响;Linux 依赖图形会话和 AT-SPI,X11 与 Wayland 的截图、鼠标等支持也不同。[1][8]

headless 不等于操作一个没有图形会话的远程环境。 在项目中,它约束的是不抢前台、不合成物理输入的执行路径;语义操作路径不可用时会拒绝执行,应用仍需提供相应的无障碍能力。失败后切换 --headed 会改变交互机制,不能当成一个无副作用的万能补救选项。

缩小读取范围,不会自动消除身份歧义。 snapshot --root 用于局部 drill,并更新对应区域的引用映射;它不是前后两次界面的差异算法,也不能把完整窗口内的两个相同目标变成一个唯一目标。[9]

动作验证还要落到业务结果。 按钮被按下、输入框读回新文字,与文章真正发布、文件真正保存、服务器数据持久化,是不同层面的证据。调用方仍应通过可观察结果确认任务完成。

读完这部分源码,我认为 agent-desktop 最值得学习的,是把“能定位到”“有证据确认身份”“动作已交付”“业务已完成”拆开处理。对桌面 Agent 来说,可靠性往往就藏在这些看似重复、实际上验证对象不同的检查里。

参考资料与固定版本源码

以下代码和文档链接固定到本文阅读的提交;只有 Trending 链接是动态页面。

  1. 项目 README:定位、观察—操作—再观察循环、平台支持
  2. ref_token.rs:快照限定引用的解析
  3. ref_identity.rs:元素 token 与位置记录的区别
  4. resolve_saved_ref.rs:保存引用解析、窗口观察与落点确认
  5. ref_full_predicate.rs:完整身份谓词与三态合取
  6. ref_capture_proof.rs:捕获时的唯一性证明
  7. JSON 输出协议:交付状态、重试、v2.6 身份约束
  8. 安装文档:权限、Linux 与其他平台差异
  9. 项目的 Agent 使用文档:局部读取与引用更新规则
  10. GitHub Trending:本文于 2026-11-04 观察的 Today 页面

文中封面与机制图为本文原创示意图;热榜配图为 GitHub 页面截图。本文部分内容由 AI 辅助整理,源码判断与示例运行范围已在文中说明。

Logo

更多推荐