Skip to content

Commit 2bd3203

Browse files
committed
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.
1 parent cd82e4f commit 2bd3203

2 files changed

Lines changed: 91 additions & 17 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,35 @@ 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+
201230
it('does not split an emoji when bounding text for the model', () => {
202231
const state = snapshot()
203232
state.nodes[0] = {

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

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,55 @@ 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 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.actions.length === 0
47+
)
48+
}
49+
2750
function nodeRow(
28-
node: ComputerUseSnapshot['nodes'][number],
51+
node: SnapshotNode,
52+
parentId: string | undefined,
53+
selectedWindowId: string,
2954
selected?: ReadonlySet<string>
3055
): string {
3156
const fields = [node.elementId, node.role]
32-
if (node.parentId && (!selected || selected.has(node.parentId)))
33-
fields.push(`parent=${node.parentId}`)
57+
if (parentId && (!selected || selected.has(parentId))) fields.push(`parent=${parentId}`)
3458
if (node.label) fields.push(`label=${JSON.stringify(truncateAtCodePoint(node.label, 512, ''))}`)
3559
if (node.value) fields.push(`value=${JSON.stringify(truncateAtCodePoint(node.value, 512, ''))}`)
3660
if (node.placeholder)
3761
fields.push(`placeholder=${JSON.stringify(truncateAtCodePoint(node.placeholder, 256, ''))}`)
3862
if (node.focused) fields.push('focused')
3963
if (node.editable) fields.push('editable')
4064
if (node.enabled === false) fields.push('disabled')
41-
if (node.actions.length) fields.push(`actions=${JSON.stringify(node.actions.slice(0, 16))}`)
42-
if (node.windowId) fields.push(`window=${node.windowId}`)
65+
if (node.actions.length) fields.push(`actions=${node.actions.slice(0, 16).join(',')}`)
66+
if (node.windowId && node.windowId !== selectedWindowId) fields.push(`window=${node.windowId}`)
4367
if (
4468
node.x !== undefined &&
4569
node.y !== undefined &&
4670
node.width !== undefined &&
4771
node.height !== undefined
4872
)
49-
fields.push(`screenRect=${JSON.stringify([node.x, node.y, node.width, node.height])}`)
73+
fields.push(
74+
`screenRect=${[node.x, node.y, node.width, node.height].map((value) => Math.round(value)).join(',')}`
75+
)
5076
return fields.join(' ')
5177
}
5278

@@ -70,8 +96,10 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
7096
: {}),
7197
windows,
7298
omittedWindowCount: allWindows.length - windows.length,
99+
treeFormat:
100+
'Rows: elementId role [parent=id] [label|value|placeholder] [focused] [editable] [disabled] [actions=...] [window=id, omitted for windowId] [screenRect=x,y,w,h in global points]. Empty layout groups are folded into their parent.',
73101
accessibilityTree: '',
74-
omittedNodeCount: nodes.length,
102+
omittedNodeCount: nodes.filter((node) => !isWrapper(node)).length,
75103
nodeTextLimit: 512,
76104
...(image
77105
? {
@@ -87,8 +115,25 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
87115
model.omittedWindowCount += 1
88116
}
89117
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]) => {
118+
const shown = nodes.filter((node) => !isWrapper(node))
119+
/** Each shown node's nearest shown ancestor, skipping wrappers (and stopping on a parent cycle). */
120+
const shownParent = new Map<string, string | undefined>()
121+
for (const node of shown) {
122+
const seen = new Set([node.elementId])
123+
let parent = node.parentId ? byId.get(node.parentId) : undefined
124+
while (parent && isWrapper(parent) && !seen.has(parent.elementId)) {
125+
seen.add(parent.elementId)
126+
parent = parent.parentId ? byId.get(parent.parentId) : undefined
127+
}
128+
shownParent.set(node.elementId, parent && !isWrapper(parent) ? parent.elementId : undefined)
129+
}
130+
const rows = new Map(
131+
shown.map((node) => [
132+
node.elementId,
133+
nodeRow(node, shownParent.get(node.elementId), state.windowId),
134+
])
135+
)
136+
const priority = (node: SnapshotNode) => {
92137
const visible =
93138
selectedWindow &&
94139
node.windowId === selectedWindow.windowId &&
@@ -133,14 +178,14 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
133178
bytes += cost
134179
return true
135180
}
136-
for (const node of [...nodes].sort((left, right) => priority(right) - priority(left))) {
181+
for (const node of [...shown].sort((left, right) => priority(right) - priority(left))) {
137182
const ancestry = [node.elementId]
138183
const seen = new Set(ancestry)
139-
let parent = node.parentId
140-
while (parent && byId.has(parent) && !seen.has(parent)) {
184+
let parent = shownParent.get(node.elementId)
185+
while (parent && !seen.has(parent)) {
141186
ancestry.push(parent)
142187
seen.add(parent)
143-
parent = byId.get(parent)?.parentId
188+
parent = shownParent.get(parent)
144189
}
145190
/** Keep the editor itself even if an unusually deep ancestry cannot fit. */
146191
if (!add(ancestry.reverse())) add([node.elementId])
@@ -155,14 +200,14 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
155200
) || allWindows.some((window) => window.title.length > 256)
156201
return {
157202
...model,
158-
accessibilityTree: nodes
203+
accessibilityTree: shown
159204
.filter((node) => selected.has(node.elementId))
160-
.map((node) => nodeRow(node, selected))
205+
.map((node) => nodeRow(node, shownParent.get(node.elementId), state.windowId, selected))
161206
.join('\n'),
162-
omittedNodeCount: nodes.length - selected.size,
207+
omittedNodeCount: shown.length - selected.size,
163208
truncated:
164209
state.truncated ||
165-
selected.size < nodes.length ||
210+
selected.size < shown.length ||
166211
windows.length < allWindows.length ||
167212
clipped ||
168213
screenshotOmitted,

0 commit comments

Comments
 (0)