上个月我写过 Neko,那篇的落点是「只出像素」为什么是个关键设计:把浏览器变成一条视频流,agent 拿到的和你的眼睛拿到的是同一样东西。这篇讲的工具走的是反方向,一个像素都不出。
agent-desktop 是 vercel-labs 开源的桌面操作 CLI,Rust 写的,Apache-2.0,2026 年 2 月建仓,现在 10,019 星、673 个 fork。它的主张可以压成一句话:读操作系统的无障碍树,把每个 agent 能操作的元素变成带稳定 ref 的结构化 JSON,agent 点一个 ref,再读一次界面来验证结果。整个观察(observe)和行动(act)的循环留在调用它的 agent 里,它自己不含模型、没有界面。
我的判断放在前面:这条路的价值不在「省 token」这类容易被说烂的账上,而在于它把「看清界面」从概率问题换成了确定性问题。截图方案的失败模式是「看错了」和「点歪了」,而这两个失败模式在结构化 ref 上根本不存在。代价也很清楚,文章最后会说:拿不到无障碍树的界面,它什么都看不见。
它明确说自己不是什么
这类工具容易在宣传里含糊边界,agent-desktop 反过来,在 AGENTS.md 里把边界写成了排比句。原文:
agent-desktop is a tool that AI agents invoke, not an agent. It is a Rust CLI and C library that reads native accessibility trees and returns structured JSON with ref-based element ids. The observe-act loop lives in the calling agent. It never embeds an LLM and has no GUI or interactive prompt. It does not automate web pages (use agent-browser). It cannot see UIs that expose no accessibility tree.
四句话钉了四条界:不含模型、没有 GUI 和交互提示、不做网页自动化(把这块明确让给同 lab 的 agent-browser)、看不到没有无障碍树的界面。最后那条是这类方案天生的短板,它自己不藏。
架构:一个 trait 劈成四份
调用链是直的。CLI 用单一个 clap 枚举定义全部命令(src/cli/mod.rs:34),src/dispatch/mod.rs 按命令分支调用 crates/core/src/commands/ 里对应的模块,每个命令一个文件。core 只认一个抽象:
pub trait PlatformAdapter: ObservationOps + ActionOps + InputOps + SystemOps {}
这行在 crates/core/src/adapter/mod.rs:39。trait 本身零个方法,靠四个子 trait 组合出来,加起来约 72 个方法,其中 SystemOps 一个就占 35 个。三个平台各写一份实现,各自只在本系统上编译。
代码量的分布值得看一眼,因为和文档给人的印象是反的。src 的 CLI 层只有 12,797 行,core 84,803 行,FFI 15,197 行,而三个平台适配器分别是 Linux 123,842 行、Windows 79,045 行、macOS 43,026 行。README 的平台表第一列是 macOS,快速上手用的是 Finder,文档站的平台页顺序也是 macOS 在前。代码里最大的那个是 Linux。
为什么,答案在 crates/linux 的子模块里:system 56,324 行、tree 29,042 行。Linux 这一侧要同时应付 AT-SPI 的 D-Bus 语义、三个 toolkit(GTK3、GTK4、Qt 6、Chromium)和两个显示服务器(X11、Wayland)。它在 docs/solutions/runtime-errors/ 下留了一篇复盘,标题本身就是结论:《一个 AT-SPI 错误名不是对象已经消失的证据》,它指出把 D-Bus 的 UnknownObject、UnknownMethod、UnknownInterface 当成「对象已销毁」是双向都会错的,得针对四个 toolkit 逐个实测。这 15 篇故障复盘是仓库里最像工程笔记的部分,比 README 好看。
架构的约束不靠自觉。scripts/check-architecture.py 强制三件事:core 不得依赖任何平台或系统 crate,平台之间不得互相依赖,core 里不得出现允许清单之外的系统条件编译。
ref 机制:这套东西的核心
一次 snapshot 返回的 JSON 里,每个可操作元素都带一个 ref_id,形如 @s8f3k2p9:e8。冒号前后是两回事。
后半段 e8 是这个 snapshot 内部按深度优先分配顺序自增的元素号,代码就一行 format!("@e{}", self.counter)。前半段是 snapshot 自己的 id,生成方式是取时间戳纳秒、进程号和进程内计数器三者做哈希,再转 base36 并补上 s 前缀。也就是说 ref 只在它所属的那次 snapshot 里有效,跨 snapshot 复用必然过期。
这个设计的直接后果是裸 @e8 会被拒绝。解析函数要求必须带 @<snapshot_id>: 前缀,错误提示还专门写了建议:A bare @eN ref is not accepted. 看起来是个小气的限制,实际上是这一整套能不能工作的前提:如果允许裸 ref,agent 拿着上一轮的 e8 去点,你根本无法判断它指的是哪个元素。它宁可让你多写几个字符,也不接受一个有歧义的引用。
那谁才配拿到 ref?规则比想象中讲究。表面看是一张 18 个角色的白名单(button、checkbox、combobox、textfield、menuitem 这些),列表在 crates/core/src/tree/roles.rs:6-25。但实际判据是两条取或:
is_ref_able_role_actions(role, actions)
= INTERACTIVE_ROLES.contains(role) || advertises_primary_action(actions)
第二条的意思是:一个元素即使角色不在白名单里,只要它声明的动作里有不属于「通用装饰动作」的,也算可操作。而「通用装饰动作」被定义成一个只有三项的集合:SetFocus、RightClick、ScrollTo。
这里有一段注释解释了为什么必须把这三项剔掉,是我在这一版源码里读到的最实在的一段(crates/core/src/refs/ref_alloc.rs:71-84):Chromium 和 Electron 几乎给每一个节点都声明 RightClick 和 ScrollTo,如果把它们算进去,就会「给每一个惰性容器和文本片段都分配 ref,把 Web 应用上的 refmap 撑到超过大小上限」。反过来,scrollarea 和 disclosure 这类按角色不在白名单里、但实际可操作的节点必须能拿到 ref,哪怕它的边界框是零尺寸。
一个判据被收紧到这种程度,说明设计者是拿着真实的界面在调的,不是照着一张角色表抄的。refmap 本身有 1MB 的单文件上限,超了会报错并建议改用 --skeleton、--max-depth 或 --root @ref 缩小范围。
用法是一个渐进循环,技能文档里给的原文是这四行:
agent-desktop snapshot --skeleton --app "Notes" -i --compact # 浅层地图
agent-desktop snapshot --root @s8f3k2p9:e3 -i --compact # 只展开一块
agent-desktop click @s8f3k2p9:e12 # 点一个 ref
agent-desktop snapshot --root @s8f3k2p9:e3 -i --compact # 重读同一块验证
先要骨架,再钻进需要的那一块,点,然后重读同一区域确认。元素在两次读取之间消失了,你会拿到 STALE_REF,附一条结构化的恢复建议,标准动作是重新 snapshot 再重试。
安全模型:默认不出手,四个命令直接拒绝
这一层我认为比 ref 机制更值得抄,因为它回答了一个所有桌面 agent 都会碰到的问题:工具怎么保证自己不做出物理上有副作用的事。
答案是 InteractionPolicy 这个只有两个布尔值的结构:允不允许抢焦点、允不允许移动光标。它有三个预设,headless() 是两个都假,headed() 是两个都真,而 Default 就是 headless()。README 的原文是:ref 动作走无障碍 API,不移动指针、不抢焦点,全局的 --headed 才允许物理输入。
这个默认值不是写在文档里就算数的,它有一条测试专门逐命令断言。crates/core/src/commands/ref_policy_tests.rs 里的 default_ref_commands_use_least_permissive_supported_policy,会把 click、toggle、check、expand、set-value、select、type、scroll 等十来个命令在默认上下文下全部执行一遍,然后逐个请求断言它的策略等于最保守的那一档。文档会撒谎,这个不会。
四个命令干脆无条件拒绝执行:key-down、key-up、mouse-down、mouse-up。理由写在 input_hold_policy.rs 里,它返回的错误信息是:
… is unavailable in stateless mode. Use the atomic … command; held input requires a daemon-owned transaction that can guarantee release.
翻译过来:CLI 每次调用是一个独立进程,一次调用结束,它没有任何机制保证那个被按下的键会被松开。所以它的选择是拒绝执行,而不是冒留下一个卡住的按键的风险。README 里这条被明确计数:60 个命令名,56 个可用,剩下四个等有了能保证释放的守护进程再开放。
还有一处容易被忽略:为了让「不抢焦点」这条在失败路径上也成立,指针手势有一个「尚未送达」的保证。pointer_action.rs 里那个 before_first_event 函数的注释写得很准:它执行的是一个手势在第一个指针事件之前该做的事,到那时为止什么都还没到达目标,所以拒绝或超时都留下一个未送达的手势。失败时它会打上 not_delivered 的标记,而不是含糊地报「操作失败」。
数据出站这侧,它有 trace 脱敏:命中敏感键名(text、value、username、label、query、secret、token、password、title、url 等)的字段值会被替换成一个 {"redacted": true}。但它的 FAQ 里有一句我认为所有做 computer use 的人都该记住的话:脱敏不能让一张截图变得可以公开。屏幕上的内容就是用户的内容,这个性质不会因为你处理了 JSON 字段而改变。另外它明确写了,原生 CLI 不需要任何远程模型服务,仓库里只有那一个可选的 helper 会把屏幕描述发给配置好的模型 provider。
工程质量:400 行上限,和那个会自检的脚本
这段是我在写这篇时唯一一个先猜错、然后被打脸的地方,值得记下来。
我先按文件行数排了个序,看到一批文件整齐地停在 400 行,包括平台适配器里那些明显还能拆的模块,于是判断「这里有个 400 行的软约定」。然后我用一条临时的 awk 管线数「超过 400 行的文件有几个」,得到 2。这个数字我先记下了。
后来实测重数,结论是:2,741 个 Rust 文件里,超过 400 行的有 0 个,恰好停在 400 行的有 12 个。我那个 2 是错的,原因是我的管线把 wc -l 输出的 total 行和几处统计口径混在了一起。规则本身则比我猜的硬得多,它明写在 AGENTS.md:158:
400 lines at most per hand-written
.rsfile (scripts/check-rust-file-size.sh). Split by responsibility.
带 @generated 标记的生成文件豁免,探针脚本豁免,C 头文件由 cbindgen 生成。而且有脚本在 CI 里真的跑,跑出来是 exit 0。
真正值得抄的是那个脚本本身,它有三处讲究。
第一,它用 awk 数记录,不用 wc -l。脚本里的注释解释了原因:wc -l 数的是换行符个数,一个 401 行、末尾没有换行的文件会被少算一行,从而从行数上限里溜过去。awk 数的是记录,没换行的最后一行也是一条记录。
第二,脚本在扫描之前先做一轮自检,往规则里灌入违规样本,验证它还能抓到东西。理由写得更狠:一个已经检测不到任何东西的规则,必须让这道闸门失败,而不是静默通过。
第三,这个「不能静默通过」的执念有出处。脚本注释里记着一件真实事故:那条禁止行内注释的扫描器,它自己的测试套件从来没被执行过,因为 CI 里 unittest 的发现范围被限定在 tests/e2e 和一个 test_*.py 的模式上,那个测试文件名不匹配。结果是 7 个文件里 27 处注释违规一路进了 CI,而扫描器一直显示自己是绿的。
顺着这个思路看它的其它约定就顺了:非测试代码禁止 unwrap() / expect() / panic!(在 main.rs 里用 clippy 的 deny 钉住);每个结构体最多 7 个字段,每个函数最多 5 个参数;每个文件一个领域结构;每个命令一个同名文件;禁止行内注释,理由一句话,「名字承载含义」,只允许 public 项上的 ///。最后这条会逼着人把命名写到能自解释的程度,而不是靠注释补。
测试的体量也对得上这种纪律:#[test] 出现 6,850 次,*_tests.rs 文件 1,029 个,测试代码 164,643 行,占全部 Rust 代码的 44.7%,测试与非测试的行数比接近 0.81 比 1。而且命令模块禁止写 inline 测试,必须用 #[path = "foo_tests.rs"] mod tests; 挂兄弟文件,这条也有测试在断言。
装上试一下
npm install -g agent-desktop
agent-desktop version
agent-desktop status
agent-desktop skills get desktop
npm 包的 postinstall 会把 Node 启动器替换成原生二进制的链接,之后每次调用约 21 毫秒;如果你的包管理器屏蔽了 install 脚本(npm 12 起默认屏蔽),启动器兜底也能用,代价是每次约 146 毫秒。想从源码构建需要 Rust 1.89+。
平台要求是三行:macOS 13+(Intel 或 Apple Silicon),Windows 10 1809+ 或 Server 2019+(x64 或 ARM64),Linux 需要 Ubuntu 24.04+、glibc 2.35 以上、一个跑着 AT-SPI 总线的图形会话。macOS 上需要授予辅助功能权限。
agent-desktop skills get desktop 这条值得单独说:它打印的是一份写给 agent 看的技能文档,让 agent 自己学会怎么用这套命令,而不是让你去给 agent 解释。同 lab 还有一条 npx skills add 的装法。
第一次跑就三行,拿 Finder 开刀:
agent-desktop snapshot --app Finder -i # 每个可操作元素带 ref
agent-desktop click @s8f3k2p9:e8 # 用上一轮的 ref 点一下
agent-desktop snapshot --app Finder -i # 再读一次验证
有两个坑先讲清楚。第一,--help 会列出一些当前平台根本不支持的表面,比如 Linux 上 start-menu 和 system-tray-overflow 都列在那儿但会拒绝执行,taskbar 永远拒绝。第二,它说的 headless 专指「不移动指针、不抢焦点」,不代表不需要显示环境:一个没有那个图形会话的 TTY 或 SSH 环境,看不到它里面的应用。这两个词在别的工具里含义常常相反,读文档时要按它的定义来。
它做不到什么,以及不该夸大什么
拿不到无障碍树的界面它看不见。网页自动化明确不归它管。Wayland 下应用指针输入会拒绝,原生窗口控制不支持。GTK4 应用会拒绝聚焦调用,得改用 focus-window。
还有一条值得引用的话,写在它的 JSON 输出文档里:
ok: true means the command returned a delivered or already-satisfied result, not that the application effect was verified.
ok: true 只说明这次调用送达了或者本来就已满足,不说明应用层面的效果被验证过。同页还有一句,说那个结果和验证过的元素状态都不能证明应用级别的持久化。一个工具肯在自己的文档里写清成功标志的边界,比多写十页宣传有用。
锁屏状态下的行为我在源码和文档里都没找到明确声明,这里就不猜了。
我的判断
把它和上个月那篇 Neko 放在一起看,桌面 agent 这条路现在有两条岔路:一条是把界面变成像素流,让模型的视觉能力去理解它;另一条是把界面变成结构化数据,让模型只做决策。Neko 那条路赢在通用性,什么界面都能看;agent-desktop 这条路赢在可靠性,能看到的界面它不会看错。
两条路的胜负手不在模型能力,在于你要操作的界面有没有把无障碍树暴露出来。有,第二条路几乎全面占优,因为它连点击的坐标都不需要猜;没有,你只能回到第一条路。
而我在这份源码里看到的最有意思的东西其实不在 ref 机制上,是那份把自己的失效当成 bug 来对待的工程纪律。一个 400 行的文件上限、一条禁止行内注释的规则,配上一个会先给自己灌违规样本、验证自己还有效的检查脚本,再配上一段解释「为什么这件事必须这么较真」的事故复盘。这套东西和 agent-desktop 要解决的问题没有半点关系,但它是这份代码库能被信任的原因。要抄的话,我建议先抄这一段。
想先看另一条路是怎么走的,见《Neko 源码级拆解:把浏览器变成一条视频流,以及「只出像素」为什么是关键》;想让这类工具真的敢放手操作,前面还有一层审批与权限要解决,见《「在聊天框里点确认」撑不住了:Agent 的审批和权限,正在长成独立的一层》;想先看清浏览器 agent 这个赛道的格局,见《browser-use 114k⭐ 称王!浏览器 Agent 三强争霸》。