一篇设计复盘:给 agent 的 Windows 桌面工具,为什么从“截图 → 按坐标点 → 再截图看对不对”改成“按元素引用操作、工具自带证据、借前台用完就还”。

证据口径:机制描述取自这次落地的源码与测试(30 个文件,+3960 / −2033,7 个分支提交);引用格式是 文件 或测试名,路径相对源码仓库根,仓库不公开。文中唯一的耗时数字(单步借用前台 110–260ms)来自技能文档里记录的实测,样本量文档没写,当它是量级而不是统计。标“推断”的是我的判断。


1. 旧设计的两个通病⌗

agent 操作桌面,业界最常见的形态是这样一个循环:

screenshot ──▶ 模型看图、估坐标 ──▶ click(x, y) ──▶ screenshot ──▶ 模型看图判断“成了没”
     ▲                                                                   │
     └───────────────────────────── 没成?再来 ─────────────────────────────┘

它有两个结构性问题:

  1. 成败由模型看图判定。 工具只负责“把鼠标事件发出去”,至于输入框里有没有出现那段字、复选框有没有勾上,工具不知道,得靠下一张截图让模型自己看。每一步一张图,上下文被图片淹没;更糟的是看图判断本身会错。
  2. 要么抢焦点,要么做不了。 真实输入(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、窗口类名,再决定怎么送。

控件类型clicktype / 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 / XAMLUIA 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 只借一次、全程记录坐在电脑前的人基本感觉不到,偶尔的打扰也有记录可查

最后一行是这次设计里最反直觉的取舍:对“会抢焦点的操作”,拒绝不是最安全的选项。 能做到“必要时才借、用完立刻还、每次都留记录”,比把这件事推给一个不会还的调用方更接近“不打扰”。