Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/computer-use-foundation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,100 @@
## Split Gate

每个 stacked PR 必须写清:负责的 contract 条款、non-goals、exported interface、focused verifier 和 cumulative verifier。重建从最终已验证 tree 按目标文件/hunk 提取,不机械重放旧 73-commit 历史。

## 两个执行器留下的教训

Maka 在两个 native executor 上做过真机实测:cua-driver(trycua,Rust,MCP)和一个
自有的 Swift executor(协议 `maka.cu/2`)。下面每一条都由真机实测得出,写在这里是
因为它们是设计层面的,换执行器不会自动消失。

### 协议要封闭,而不是宽容

cua-driver 的 MCP 面是开放字符串:dispatch tier 要从 path 字符串猜,猜错的每一次
都落到 `coordinate-background`;错误消息可能带应用文本,于是宿主必须整体脱敏,
结果是**模型永远只看到错误码**,看不到那句可操作的话。`maka.cu/2` 把这些收成闭
集(§1.1 的双错误层、§1.2 的固定句子、§6.3 的 tier/path 配对),宿主才敢把执行器
的句子直接给模型看。

教训:能让模型自救的信息,往往正是"看起来可能不安全所以被丢掉"的那部分。解法是
让它在协议层就不可能不安全,而不是在宿主层一刀切。

### 两端各写一份的东西,一定会分叉

坐标动作 100% 不可用,藏了整个开发期。根因:快照侧 `hostWalkTree` 与校验侧
`HostAXBindingProbe` 各自实现了同一份"摘要输入"字段表,根节点的 `ancestors` 一个
读活链、一个硬编码空数组。65 个元素差 1 个,窗口摘要就不符,而窗口摘要是坐标动作
**唯一**的锚。元素动作因为只校验自身,24/24 一直是绿的,完全遮住了它。

修法不是让两份拷贝再对齐一次(那已经试过一次并且正是这次分叉的来源),而是收敛成
一条代码路径、规则放在里面。

教训:凡是"记录时算一遍、校验时再算一遍"的结构,必须共用一个函数。绿灯不覆盖的
那条路,就是它会坏掉的地方。

### 缓存不是查询

`NSWorkspace.shared.runningApplications` 和 `frontmostApplication` 在没有 AppKit
run loop 的进程里**永不刷新**。执行器因此看不见任何在它之后启动的应用,而
`foregroundTaken` 恒为启动时刻的那个值——一个抢了用户前台的启动会如实报告"没抢"。
所有真机测试之所以一直是绿的,只是因为目标应用碰巧早就在跑。

教训:在无 run loop 的进程里,AppKit 的任何"当前状态"访问器都要按缓存对待,改用
`proc_listpids` / 窗口服务这类每次真查的接口。

### 上限要有时钟,截断要说出来

`maxElements` 挡不住慢:由另一个进程托管的 open/save 面板走 1500 个元素花了 35 秒,
撞穿宿主 20 秒死线被杀,而宿主报的是"执行器已退出"——把排查引向了错的一侧。而且
截断只进了 trace,模型读到一棵残树会得出"这个控件不存在"。

教训:任何遍历都要同时有数量上限和时间上限;任何截断都必须出现在**模型读得到的
地方**,并且要说出它的含义("可能存在但没列出"),而不只是一个 `truncated=true`。

### 不变量要请求,而不是假设

`apps.launch` 的类型注释写着"启动的应用不得抢焦点",而实现用的是
`NSWorkspace.OpenConfiguration()` 默认值——`activates` 默认为 `true`,从来没有请求
过后台启动。诚实上报那一半是对的(应用自激活时如实报 `foregroundTaken: true`),
缺的是先去请求。

教训:一条不变量如果只写在注释里、没有对应的一行代码去请求它,它就不是不变量。

### 剪枝要有回退路径才付得起

Codex 剪得很狠(13 层深的通用容器全收),因为它有 `click{x,y}` 兜底:藏错了元素,
模型还能按坐标点。Maka 的坐标路径默认关闭,藏掉的元素就是**够不到**的元素。跨 10
个应用 9129 个元素实测,朴素的"无 label 就剪"会藏掉 3428 个,其中 1023 个
(占全树 17%)是可操作的。

教训:能不能剪,取决于剪错了有没有第二条路。没有回退的实现必须比有回退的保守。

### 省 token 的地方常常不在编码上

JSON/YAML 不比"一元素一行 + 缩进"省:实测分别是它的 3.5 倍和 2.1 倍,因为后者把
包含关系编码成缩进、把默认状态编码成"不写"。真正的浪费在别处——`list_apps` 无条件
返回 133 个应用(12,933 字节,约 3,600 token,占一个三步回合的 85%),而其中 118
个根本没有窗口、模型碰都碰不到。

教训:先量一次真实回合的 token 分布再动手。最大的一笔开销往往不在你正在优化的那
个字段上。

### 措辞补不上不存在的能力

一条真实任务上的三轮迭代,每轮都把拒绝语句写得更准,模型的调用次数是 32 → 46 → 57。

任务是「把窗口挪到左边」。移动窗口只能拖标题栏,拖标题栏只能用坐标动作,而坐标动作
要求目标像素属于目标窗口——Computer Use 驱动的是用户没在看的窗口,后台启动的窗口
必然压在 z-order 底部,于是必然被遮挡。**这个任务没有解**:协议里没有窗口管理动词,
而「移动窗口」也不是任何控件的 AX 动作。

把拒绝语句写清楚之后,模型确实读懂了「这条路不通」,于是去试别的路——而别的路也不
通,所以试得更多。同一批改动对「导出 PDF」是有效的:那里存在一个正确答案(「做不
到,因为菜单快捷键到不了后台应用」),模型说出这句话就停了。

分界线:

- 存在正确答案(包括「做不到」本身就是正确答案)→ 措辞能把模型引到那里,值得改。
- 不存在正确答案 → 措辞只会让模型更快地把所有错路试一遍。要补的是能力,不是句子。

判断方法:先问「一个熟练的人拿着同样这套动作面,能不能做成」。答不上来就先别改文案。
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,26 @@ import {
type CuObservation,
type CuRunContext,
} from '@maka/runtime';
import { parseObservationText } from '@maka/runtime/test-only/observation-text-reader';
import {
createComputerUseOverlayHook,
type OverlayCursorSink,
} from '../computer-use-overlay-hook.js';

/**
* The observation id the tool just handed the model.
*
* The model-facing observation is a rendered document, not JSON, so this reads
* it with the runtime's own test-only reader rather than a fourth copy of a
* parser. It was `JSON.parse` here, which is how this suite went red the day
* the rendering changed.
*/
function observationIdOf(modelText: string | undefined): string {
const parsed = parseObservationText(modelText ?? '');
assert.ok(parsed, 'the tool did not return a rendered observation');
return parsed.observation_id;
}

function context(overrides: Partial<CuRunContext> = {}) {
return {
sessionId: 'session-1',
Expand Down Expand Up @@ -145,7 +160,7 @@ describe('Computer Use cross-layer deterministic contract', () => {
} as never,
context(),
)) as { text: string; modelText?: string };
const observationId = JSON.parse(observed.modelText ?? '{}').observation_id;
const observationId = observationIdOf(observed.modelText);
const result = (await tool.impl(
{
action: 'left_click',
Expand Down Expand Up @@ -234,7 +249,7 @@ describe('Computer Use cross-layer deterministic contract', () => {
await tool.impl(
{
action: 'left_click',
observation_id: JSON.parse(firstObservation.modelText ?? '{}').observation_id,
observation_id: observationIdOf(firstObservation.modelText),
coordinate: [400, 200],
} as never,
context({ toolCallId: 'target-change' }),
Expand All @@ -254,7 +269,7 @@ describe('Computer Use cross-layer deterministic contract', () => {
await tool.impl(
{
action: 'left_click',
observation_id: JSON.parse(secondObservation.modelText ?? '{}').observation_id,
observation_id: observationIdOf(secondObservation.modelText),
coordinate: [400, 200],
} as never,
context({ turnId: 'turn-2', toolCallId: 'unknown' }),
Expand Down Expand Up @@ -307,7 +322,7 @@ describe('Computer Use cross-layer deterministic contract', () => {
const afterTurn = (await tool.impl(
{
action: 'left_click',
observation_id: JSON.parse(observed.modelText ?? '{}').observation_id,
observation_id: observationIdOf(observed.modelText),
coordinate: [400, 200],
} as never,
context({ toolCallId: 'late-action' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { CuAction } from '@maka/core';
import { buildComputerUseTools } from '@maka/runtime';
import { parseObservationText } from '@maka/runtime/test-only/observation-text-reader';
import { createComputerUseOverlayHook } from '../computer-use-overlay-hook.js';

function fakeController() {
Expand Down Expand Up @@ -344,7 +345,10 @@ async function driveRealTool(
{ action: 'observe', app: 'Fixture' } as never,
toolContext() as never,
)) as { modelText?: string };
const observationId = JSON.parse(first.modelText ?? '{}').observation_id;
// `observe` answers in the rendered observation format, not JSON, so the id
// is read with the same parser the runtime's own tests use rather than a
// second copy of the grammar here.
const observationId = parseObservationText(first.modelText ?? '')?.observation_id;
events.length = 0;
await tool.impl({ ...call, observation_id: observationId } as never, toolContext() as never);
return events;
Expand Down
22 changes: 20 additions & 2 deletions packages/computer-use/src/maka-cu-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,12 +457,30 @@ export type MakaCuBackend = Omit<
'runSemantic' | 'observeApp' | 'captureObservation'
> & {
observeApp(
input: { app?: string; windowId?: number; includeScreenshot: boolean },
input: {
app?: string;
windowId?: number;
includeScreenshot: boolean;
menu?: string;
query?: string;
},
signal: AbortSignal,
context: CuRunContext,
): Promise<MakaCuObservation>;
captureObservation(
input: { app?: string; windowId?: number; includeScreenshot: true },
input: {
app?: string;
windowId?: number;
// Was pinned to `true` on both of these while every caller wanted a
// picture. `observe` now asks for one only when the model does, and a
// capture between the steps of a sequence asks for none at all. The
// declaration said otherwise while `observe` itself passed `menu` and
// `query` that were not declared either — the implementation delegates to
// one `observe` that has always handled all of it.
includeScreenshot: boolean;
menu?: string;
query?: string;
},
signal: AbortSignal,
context: CuRunContext,
): Promise<MakaCuObservation>;
Expand Down
Loading
Loading