Skip to content

Commit afb6653

Browse files
authored
improvement(computer-use): fold empty layout groups and compact the accessibility tree shown to the model (#9997)
* improvement(computer-use): fold empty layout groups and compact rows in the model's accessibility tree Wrapper groups with no text, state or action no longer spend the observation budget; their children point at the nearest shown ancestor. Rows drop the selected window id, JSON array syntax and fractional coordinates, and a one-line legend explains the format. * fix(computer-use): keep disabled layout groups in the model's accessibility tree * fix(computer-use): keep JSON-encoded action names so custom actions stay exact
1 parent cd82e4f commit afb6653

2 files changed

Lines changed: 119 additions & 16 deletions

File tree

‎apps/sim/lib/mothership/tools/client/computer-tool-result.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,63 @@ describe('native computer model projection', () => {
198198
expect(result).toMatchObject({ dispatched: true, sequence: { completedSteps: 1 } })
199199
})
200200

201+
it('folds empty layout groups so their controls fit and point at a shown parent', () => {
202+
const state = snapshot()
203+
state.nodes = [
204+
{ elementId: 'window', role: 'AXWindow', label: 'Fixture', actions: [], windowId: '1' },
205+
...Array.from({ length: 300 }, (_, index) => ({
206+
elementId: `wrap-${index}`,
207+
parentId: index === 0 ? 'window' : `wrap-${index - 1}`,
208+
role: 'AXGroup',
209+
actions: [],
210+
windowId: '1',
211+
})),
212+
...Array.from({ length: 150 }, (_, index) => ({
213+
elementId: `button-${index}`,
214+
parentId: 'wrap-299',
215+
role: 'AXButton',
216+
label: `Action ${index}`,
217+
actions: ['AXPress'],
218+
windowId: '1',
219+
})),
220+
]
221+
const result = computerToolResultForModel(state)
222+
if (!('accessibilityTree' in result)) throw new Error('Missing projected tree')
223+
const rows = result.accessibilityTree.split('\n')
224+
expect(rows.some((row) => row.includes('AXGroup'))).toBe(false)
225+
expect(rows.filter((row) => row.includes('AXButton'))).toHaveLength(150)
226+
expect(rows.find((row) => row.startsWith('button-0 '))).toContain('parent=window')
227+
expect(result.omittedNodeCount).toBe(0)
228+
})
229+
230+
it('keeps an empty layout group that reports itself disabled', () => {
231+
const state = snapshot()
232+
state.nodes = [
233+
{ elementId: 'window', role: 'AXWindow', label: 'Fixture', actions: [], windowId: '1' },
234+
{
235+
elementId: 'pane',
236+
parentId: 'window',
237+
role: 'AXGroup',
238+
enabled: false,
239+
actions: [],
240+
windowId: '1',
241+
},
242+
{
243+
elementId: 'save',
244+
parentId: 'pane',
245+
role: 'AXButton',
246+
label: 'Save',
247+
actions: ['AXPress'],
248+
windowId: '1',
249+
},
250+
]
251+
const result = computerToolResultForModel(state)
252+
if (!('accessibilityTree' in result)) throw new Error('Missing projected tree')
253+
const rows = result.accessibilityTree.split('\n')
254+
expect(rows.find((row) => row.startsWith('pane '))).toContain('disabled')
255+
expect(rows.find((row) => row.startsWith('save '))).toContain('parent=pane')
256+
})
257+
201258
it('does not split an emoji when bounding text for the model', () => {
202259
const state = snapshot()
203260
state.nodes[0] = {

‎apps/sim/lib/mothership/tools/client/computer-tool-result.ts‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,38 @@ function jsonBytes(value: unknown): number {
2424
return encoder.encode(JSON.stringify(value)).length
2525
}
2626

27+
type SnapshotNode = ComputerUseSnapshot['nodes'][number]
28+
29+
/** Layout wrappers that carry no text, state (including disabled) or action; their children are shown under the nearest meaningful ancestor. */
30+
const WRAPPER_ROLES = new Set([
31+
'AXGroup',
32+
'AXSplitGroup',
33+
'AXLayoutArea',
34+
'AXLayoutItem',
35+
'AXUnknown',
36+
])
37+
38+
function isWrapper(node: SnapshotNode): boolean {
39+
return (
40+
WRAPPER_ROLES.has(node.role) &&
41+
!node.label &&
42+
!node.value &&
43+
!node.placeholder &&
44+
!node.focused &&
45+
!node.editable &&
46+
node.enabled !== false &&
47+
node.actions.length === 0
48+
)
49+
}
50+
2751
function nodeRow(
28-
node: ComputerUseSnapshot['nodes'][number],
52+
node: SnapshotNode,
53+
parentId: string | undefined,
54+
selectedWindowId: string,
2955
selected?: ReadonlySet<string>
3056
): string {
3157
const fields = [node.elementId, node.role]
32-
if (node.parentId && (!selected || selected.has(node.parentId)))
33-
fields.push(`parent=${node.parentId}`)
58+
if (parentId && (!selected || selected.has(parentId))) fields.push(`parent=${parentId}`)
3459
if (node.label) fields.push(`label=${JSON.stringify(truncateAtCodePoint(node.label, 512, ''))}`)
3560
if (node.value) fields.push(`value=${JSON.stringify(truncateAtCodePoint(node.value, 512, ''))}`)
3661
if (node.placeholder)
@@ -39,14 +64,16 @@ function nodeRow(
3964
if (node.editable) fields.push('editable')
4065
if (node.enabled === false) fields.push('disabled')
4166
if (node.actions.length) fields.push(`actions=${JSON.stringify(node.actions.slice(0, 16))}`)
42-
if (node.windowId) fields.push(`window=${node.windowId}`)
67+
if (node.windowId && node.windowId !== selectedWindowId) fields.push(`window=${node.windowId}`)
4368
if (
4469
node.x !== undefined &&
4570
node.y !== undefined &&
4671
node.width !== undefined &&
4772
node.height !== undefined
4873
)
49-
fields.push(`screenRect=${JSON.stringify([node.x, node.y, node.width, node.height])}`)
74+
fields.push(
75+
`screenRect=${[node.x, node.y, node.width, node.height].map((value) => Math.round(value)).join(',')}`
76+
)
5077
return fields.join(' ')
5178
}
5279

@@ -70,8 +97,10 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
7097
: {}),
7198
windows,
7299
omittedWindowCount: allWindows.length - windows.length,
100+
treeFormat:
101+
'Rows: elementId role [parent=id] [label|value|placeholder] [focused] [editable] [disabled] [actions=JSON array of exact names] [window=id, omitted for windowId] [screenRect=x,y,w,h in global points]. Empty layout groups are folded into their parent.',
73102
accessibilityTree: '',
74-
omittedNodeCount: nodes.length,
103+
omittedNodeCount: nodes.filter((node) => !isWrapper(node)).length,
75104
nodeTextLimit: 512,
76105
...(image
77106
? {
@@ -87,8 +116,25 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
87116
model.omittedWindowCount += 1
88117
}
89118
const byId = new Map(nodes.map((node) => [node.elementId, node]))
90-
const rows = new Map(nodes.map((node) => [node.elementId, nodeRow(node)]))
91-
const priority = (node: ComputerUseSnapshot['nodes'][number]) => {
119+
const shown = nodes.filter((node) => !isWrapper(node))
120+
/** Each shown node's nearest shown ancestor, skipping wrappers (and stopping on a parent cycle). */
121+
const shownParent = new Map<string, string | undefined>()
122+
for (const node of shown) {
123+
const seen = new Set([node.elementId])
124+
let parent = node.parentId ? byId.get(node.parentId) : undefined
125+
while (parent && isWrapper(parent) && !seen.has(parent.elementId)) {
126+
seen.add(parent.elementId)
127+
parent = parent.parentId ? byId.get(parent.parentId) : undefined
128+
}
129+
shownParent.set(node.elementId, parent && !isWrapper(parent) ? parent.elementId : undefined)
130+
}
131+
const rows = new Map(
132+
shown.map((node) => [
133+
node.elementId,
134+
nodeRow(node, shownParent.get(node.elementId), state.windowId),
135+
])
136+
)
137+
const priority = (node: SnapshotNode) => {
92138
const visible =
93139
selectedWindow &&
94140
node.windowId === selectedWindow.windowId &&
@@ -133,14 +179,14 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
133179
bytes += cost
134180
return true
135181
}
136-
for (const node of [...nodes].sort((left, right) => priority(right) - priority(left))) {
182+
for (const node of [...shown].sort((left, right) => priority(right) - priority(left))) {
137183
const ancestry = [node.elementId]
138184
const seen = new Set(ancestry)
139-
let parent = node.parentId
140-
while (parent && byId.has(parent) && !seen.has(parent)) {
185+
let parent = shownParent.get(node.elementId)
186+
while (parent && !seen.has(parent)) {
141187
ancestry.push(parent)
142188
seen.add(parent)
143-
parent = byId.get(parent)?.parentId
189+
parent = shownParent.get(parent)
144190
}
145191
/** Keep the editor itself even if an unusually deep ancestry cannot fit. */
146192
if (!add(ancestry.reverse())) add([node.elementId])
@@ -155,14 +201,14 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
155201
) || allWindows.some((window) => window.title.length > 256)
156202
return {
157203
...model,
158-
accessibilityTree: nodes
204+
accessibilityTree: shown
159205
.filter((node) => selected.has(node.elementId))
160-
.map((node) => nodeRow(node, selected))
206+
.map((node) => nodeRow(node, shownParent.get(node.elementId), state.windowId, selected))
161207
.join('\n'),
162-
omittedNodeCount: nodes.length - selected.size,
208+
omittedNodeCount: shown.length - selected.size,
163209
truncated:
164210
state.truncated ||
165-
selected.size < nodes.length ||
211+
selected.size < shown.length ||
166212
windows.length < allWindows.length ||
167213
clipped ||
168214
screenshotOmitted,

0 commit comments

Comments
 (0)