别让模型截图自证:一个借了前台会还的桌面控制工具

一篇设计复盘:给 agent 的 Windows 桌面工具,为什么从“截图 → 按坐标点 → 再截图看对不对”改成“按元素引用操作、工具自带证据、借前台用完就还”。
证据口径:机制描述取自这次落地的源码与测试(30 个文件,+3960 / −2033,7 个分支提交);引用格式是 文件 或测试名,路径相对源码仓库根,仓库不公开。文中唯一的耗时数字(单步借用前台 110–260ms)来自技能文档里记录的实测,样本量文档没写,当它是量级而不是统计。标“推断”的是我的判断。
1. 旧设计的两个通病⌗
agent 操作桌面,业界最常见的形态是这样一个循环:
screenshot ──▶ 模型看图、估坐标 ──▶ click(x, y) ──▶ screenshot ──▶ 模型看图判断“成了没”
▲ │
└───────────────────────────── 没成?再来 ─────────────────────────────┘
它有两个结构性问题:
- 成败由模型看图判定。 工具只负责“把鼠标事件发出去”,至于输入框里有没有出现那段字、复选框有没有勾上,工具不知道,得靠下一张截图让模型自己看。每一步一张图,上下文被图片淹没;更糟的是看图判断本身会错。
- 要么抢焦点,要么做不了。 真实输入(
SendInput)只会送到前台窗口。电脑前如果坐着一个正在打字的人,agent 每点一下,他的输入就可能被劫走。
MaxClaw 这次重做之前的 Windows 实现已经在第二点上很克制,但代价是把选择题甩给了模型:
| 旧设计 | 表现 |
|---|---|
mode=background / mode=foreground 由模型每步选 | 模型要先知道目标控件吃不吃后台消息,它通常不知道 |
所有输入必须 target + frameId,frame 只能来自窗口截图 | 每次操作前先截一张图,图过期(移动、缩放、超过 120 秒)就作废 |
background 下 hotkey 一律 background_unsupported | 修饰键是系统级状态,投递给单个窗口的 Ctrl+A 可能被当成 A |
| foreground 下 hotkey 只支持单键 | 组合键直接 hotkey_unsupported |
| 返回值只说“发出去了” | 成没成还得截图看 |
旧设计的每条限制都有合理来由,合起来却是:安全靠拒绝,正确性靠模型看图。这次重做把这两样都收回到工具自己身上。
2. 第一条:以元素为中心,坐标退居二线⌗
新的典型调用不再出现坐标:
desktop({ action:'app', appName:'<程序>' }) // 不激活,返回新窗口 target
desktop({ action:'snapshot', target }) // 每个元素带 ref,如 e17
desktop({ action:'type', ref:'e21', text:'hello 你好' })
desktop({ action:'click', ref:'e17' })
desktop({ action:'hotkey', ref:'e21', keys:'ctrl+z' })
ref 背后有一个工程上的小麻烦:Windows 侧的 helper(NativeAOT 单文件,UIA 走 COM vtable)每次调用都是一个新进程,没法持有 UIA 元素对象。所以 ref 不是对象句柄,而是一个能在新进程里重新找到元素的 locator:
ref "e17" ──(worker 内存里的表,上限 5000 条)──▶ locator
├─ path: 从窗口根开始的控件树子索引 [0, 2, 5]
├─ role: "Edit"
├─ automationId(有就用)
└─ name
解析顺序(native/input-helper-win/Elements.cs 的 Locator.Resolve):先按 path 精确走,走到的元素 role + automationId(或 role + name)对得上就用;对不上就在子树里按同样条件搜索(深度上限 40);还找不到就报 element_not_found,让模型重新 snapshot。模型看不到 locator,只拿到短 ref(测试 snapshot hands out refs; click/type/hotkey by ref send the locator, not coordinates 断言了 locator 不出现在模型可见的结果里,且 click 按 ref 时不带 x)。
ref 和窗口身份绑在一起:拿着 A 窗口的 ref 去配 B 窗口的 target,直接拒绝(同一测试的后半段)。动作结果里的 element / focused 也会登记成新 ref,下一步可以直接接着用,不必再 snapshot。
坐标操作还在,用于 Electron、自绘 UI 这类 UIA 树里只有一层 Pane 骨架的界面,但门槛更高:必须 target + frameId + x/y,并且命中点必须真的在目标窗口上,否则 target_occluded,宁可不点,也不点到别的窗口上。
3. 第二条:每个动作自带执行后证据,只在没有可观测效果时失败⌗
这是整个设计里我认为最值钱的一条。每个动作成功时返回的不是 ok:true,而是一份证据:
| 字段 | 内容 |
|---|---|
delivery | 实际怎么送达的,如 BM_CLICK(control-hwnd)、EM_SETSEL+posted-keys(control-hwnd)、uia:invoke、send-input(foreground) |
element | 目标元素执行后重新解析出的状态(带新 ref) |
changes | 前后对比,如 value ''->'hello 你好'、selection [0,8)->[0,0)、toggle=0 -> toggle=1 |
focused | 目标窗口线程的焦点控件,以及它是否在目标窗口内 |
foreground | 前台有没有被动过(见第 5 节) |
frameId | 新的坐标帧,供后续坐标操作使用 |
失败判据写死在 Act.cs 里,按动作读回状态:
| 动作 | 读回什么 | 判为 no_change 的条件 |
|---|---|---|
type(纯文字) | 元素的 Value / Text 模式 | 读回值里不含这段文字,或值没变 |
type(含按键) | 同上 | 值没变 |
hotkey | 值 + 选区 | 值和选区都没变(ctrl+c 例外:复制本来就不改变控件) |
click 可切换 / 可选中 / 可展开的元素 | Toggle / SelectionItem / ExpandCollapse 状态 | 状态没变 |
关键在失败也带证据。no_change 的结果里照样有 delivery、changes、读回的状态,模型一眼就知道是“送到了但没反应”还是“根本没送到”。这里有个容易忽略的坑:helper 到 worker 之间的传输链路对失败的运行只转发错误文本。如果 helper 用失败退出码表示 no_change,证据会在半路被丢掉。所以 helper 把这类结果写成 success:true, verified:false,由 TypeScript 侧再翻译成带 evidence 的失败(测试 verified:false becomes a failure that keeps its evidence)。
这样一来,batch 的语义也变了:Windows batch 默认不在末尾截图(测试 Windows batch observes nothing by default (actions carry evidence)),第一个 no_change 即停,并带回那一步的证据。模型只在真正不确定的地方(会弹窗、界面会跳转)才需要看图。
它不能验证的东西,它会说不能。 普通按钮点完没有可读回的状态,结果里标 verifiable:false;没有元素 ref 的 type 同样如此。这不是失败,也不假装成功,模型要不要截图核对,自己决定。我认为这比“一律 ok”或“一律截图”都诚实。
4. 按控件选送达方式,而不是让模型选 mode⌗
旧设计让模型选 mode,新设计把这道选择题收回 helper:先看元素有没有自己的 HWND、UIA 报告的 FrameworkId、窗口类名,再决定怎么送。
| 控件类型 | click | type / hotkey | 前台 |
|---|---|---|---|
| Win32 / WinForms(有自己的 HWND) | 按钮类发 BM_CLICK,其他投递鼠标消息 | 文字 WM_CHAR;按键先挂接目标输入线程、设置修饰键状态再投递 WM_KEYDOWN/UP;EDIT / RichEdit 的 ctrl+a/z/x/c/v 直接发 EM_SETSEL / WM_UNDO / WM_CUT / WM_COPY / WM_PASTE | 不动 |
| Chromium / WebView2 页面 | 向渲染窗口投递鼠标(顺带给 DOM 焦点) | 投递,带修饰键状态 | 不动 |
| 资源管理器风格的 DirectUI 列表 | 投递鼠标 | — | 不动 |
| WPF、WinUI 3 / XAML | UIA Invoke → Toggle → SelectionItem → ExpandCollapse | 真实键盘输入 | 借用并归还 |
两处细节解释了旧设计的限制是怎么被拆掉的:
- 后台组合键。 旧设计说修饰键是系统级状态、没法给单个窗口投递
Ctrl+A。新实现的做法是AttachThreadInput挂到目标窗口的输入线程上,用SetKeyboardState把 Ctrl/Shift/Alt 的位置上,投递完再恢复原状态(Act.cs的WithKeyboard)。目标线程里GetKeyState看到的就是“Ctrl 按着”。对编辑框,干脆绕过按键,直接发对应的编辑消息,因为非激活窗口里剪贴板与撤销组合键往往只认这些消息。 - 经典按钮。 标准 BUTTON 控件只在鼠标抬起且持有捕获时才算点击,非激活窗口拿不到捕获,投递鼠标点不动它。
BM_CLICK绕过这条路,坐标点击命中按钮类窗口时也走它。
5. 第三条:借用前台、做完归还⌗
WPF 和 WinUI 是硬骨头。按技能文档记录的实测:它们在窗口未激活时忽略投递的鼠标键盘消息;对它们调 UIA 的 Invoke / Toggle / SetValue,应用会自己把自己切到前台;而后台进程按系统规则拿不到前台锁。也就是说,这一类控件不存在“完全后台”的解法。
有意思的是这条设计在分支内部改过一次方向,提交历史里看得很清楚:
| 分支提交 | 对 WPF/WinUI 的策略 |
|---|---|
| 第 3 个 | 预先拒绝(needs_activate),除非窗口已在前台;任何前台/焦点被夺走都恢复并判该步失败(focus_stolen) |
| 第 4 个 | 工具借用前台,做完归还;应用自己抢前台也还回去;只有 no_change 才失败 |
为什么拒绝反而更差(推断,但有机制支撑):拒绝不会让任务消失。模型收到 needs_activate 之后的合理反应是自己调 window activate 把窗口拉到前台再操作,而那个窗口会一直留在前台,直到有人想起来把它放回去。拒绝把“抢焦点”从一个工具内部、有界、可归还的动作,变成了模型手里的、无界、不归还的动作。对坐在电脑前的人来说,后者更糟。
借用的过程在 native/input-helper-win/Guard.cs 的 FocusGuard.Run 里:
记录 before = {前台窗口, 其线程的焦点控件}
│
▼
等用户停手:最近一次键鼠输入 < 700ms 就等,最多等 3s(等满也照做,不因此失败)
│
▼
ForceForeground(目标):AttachThreadInput 挂到当前前台线程后 SetForegroundWindow;
被拒就按一下 F24 让自己成为“最近输入的进程”再试
│ (选 F24 是因为它没有默认功能;按 Alt 会打开菜单栏)
▼
动作:UIA 模式 / 真实输入 ──▶ 读回校验
│
▼
归还:ForceForeground(before.前台窗口)
│
▼
evidence.foreground = { borrowed:true, by:'tool', restored:true, durationMs, waitedForUserMs? }
还有两种情况被同一个守卫兜住:
- 应用自己抢前台。 后台动作结束后守卫再盯 350ms,期间前台变成目标窗口、同进程窗口或目标拥有的弹窗,就记作
by:'app'并还回去。这不算失败。 - 前台被别人动了。 变化来自与目标无关的窗口时,记
changedBy:'another-window',如实报告,不去碰它。
evidence.foreground 因此只有几种形态:{unchanged:true},或 {borrowed:true, by:'tool'|'app', restored:true, durationMs, …}。模型不用管前台,但事后能审计每一步有没有动过、动了多久、还回去了没有。
batch 只借一次。 如果一个 batch 里连续五步都打在同一个 XAML 窗口上,每步借还一次,窗口就会在屏幕上闪五下。实现是:batch 期间每一步都带 keep_foreground,第一次借到的步骤把窗口留在前台并返回要还给谁(held:true, prev),后续步骤直接用;batch 结束时在 finally 里还一次,结果里给出 foregroundReturned。测试 a batch borrows the foreground once: steps keep it, the batch gives it back once 钉死了三件事:三步都带 keep_foreground、只还了一次、batch 外的单步调用不带 keep_foreground。中途抛出非 no_change 的异常时,守卫不等 batch 结束,当场归还,因为那种失败没有证据可带,也就没理由继续占着前台。
坐标点击 XAML 窗口同理:借前台、真实点击、把鼠标指针放回原处;点击点被别的窗口挡住就 target_occluded 拒绝,因为真实点击落在哪里,由屏幕上那个点归谁决定,而不是由你想点谁决定。
6. 让“不打扰”贯穿到边角⌗
几处不显眼但决定体验的地方:
- 启动不激活。
app launch用SW_SHOWNOACTIVATE启动,然后对比启动前后的顶层窗口集合、优先匹配新进程树,认领出新窗口作为 target 直接返回(Launch.cs)。如果新窗口还是抢了前台,就还回去。Windows 上app只剩 launch,activate被拒并指向window windowAction=activate(测试Windows app only launches, never activates)。有意把窗口长期留在前台的操作只有这一个入口。 - DPI。 helper 是 per-monitor-v2 感知的,截图、UIA rect、坐标都用物理像素。未声明 DPI 感知的程序活在一个被 DWM 拉伸的逻辑坐标空间里,投递给它的客户区坐标要按
显示器 DPI / 窗口 DPI换算回去,滚轮消息里的屏幕坐标用PhysicalToLogicalPointForPerMonitorDPI换算(Dpi.cs)。截图按窗口逻辑尺寸PrintWindow再拉伸到物理尺寸,于是“截图上看到的点”和“送进去的点”是同一个点。 - 二进制跟着源码走。 常驻的 Windows 执行端只同步 git、不构建。desktop 工具在进程内第一次调用 broker 前,对 helper 与提权代理的
.cs/.csproj源码算哈希(忽略换行风格,测试covers both helpers, ignores line endings and build output),和已部署的记录比较;不一致就本地重建、等在飞调用结束、替换、重启 broker、验证应答、写入哈希。更新失败时本次调用直接报错,不带着旧二进制跑,下次调用再试。
这次改动还顺手瘦了身:skills/desktop-control/tools.ts 从 855 行降到 481 行,schema 里 mode 和 gridConfig 被删掉,只剩 action 必填(测试 only action is required; mode and gridConfig are gone)。让模型少做一道它答不好的选择题,本身就是一项改进。
7. 代价与没解决的问题⌗
| 问题 | 现状 |
|---|---|
| 借用期间用户的输入 | 等停手只看“最近一次输入的时间”。借用的 100 多毫秒里用户恰好又开始打字,按键会落进被借来的窗口。窗口变小了,但没有消失 |
| 借用依然可见 | XAML 控件的每次借用都会让窗口在前台出现一瞬。batch 合并能减少闪烁次数,减不到零 |
| 归还可能失败 | 原前台窗口没了、或系统拒绝时,结果里是 restored:false,不判该步失败。这是有意的(动作本身成功了),但意味着“必还”是尽力而非保证 |
| 普通按钮无法自证 | 无状态按钮只能 verifiable:false,点了之后有没有发生什么,仍要靠后续观察 |
| locator 的模糊回退 | path 对不上时按 automationId 或 role + name 搜子树。界面上有两个同名同角色的元素时,可能命中错的那一个;校验只防“完全找不到”,防不住“找到了另一个长得一样的” |
| ref 是进程内状态 | 登记表在 worker 内存里,worker 重启后全部作废,需要重新 snapshot |
| 网页内容进 UIA 树 | Chromium 系浏览器要以 --force-renderer-accessibility 启动,网页元素才有 ref;否则退回坐标路径 |
| 实测样本 | 送达方式表与 110–260ms 来自开发期在单台机器上的实测,没有跨版本、跨机器的系统性数据 |
8. 小结⌗
| 常见做法 | 这里的做法 | 换来什么 |
|---|---|---|
| 截图估坐标 | 元素 ref + 可重定位的 locator | 不用先截图,界面变了显式报 element_not_found |
| 返回“已发送”,由模型看图判成败 | 执行后读回,返回证据;没有可观测效果才失败 | 不用每步截图,失败时知道是哪一环没生效 |
| 让模型选前台 / 后台模式 | helper 按控件类型选送达方式 | 少一道模型答不好的选择题 |
| 抢前台,或拒绝 | 只在必要时借、等用户停手、做完归还、batch 只借一次、全程记录 | 坐在电脑前的人基本感觉不到,偶尔的打扰也有记录可查 |
最后一行是这次设计里最反直觉的取舍:对“会抢焦点的操作”,拒绝不是最安全的选项。 能做到“必要时才借、用完立刻还、每次都留记录”,比把这件事推给一个不会还的调用方更接近“不打扰”。