humanish 0.130.0 → 0.131.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/dist/actors/codex/restricted-participant.d.ts +2 -2
- package/dist/actors/codex/restricted-participant.js +3 -3
- package/dist/actors/computer-use/loop/actions.d.ts +17 -2
- package/dist/actors/computer-use/loop/actions.js +24 -5
- package/dist/actors/computer-use/loop/backstop.d.ts +9 -1
- package/dist/actors/computer-use/loop/backstop.js +4 -3
- package/dist/actors/computer-use/loop/clock.d.ts +4 -4
- package/dist/actors/computer-use/loop/clock.js +5 -5
- package/dist/actors/computer-use/loop/observation.d.ts +21 -2
- package/dist/actors/computer-use/loop/observation.js +50 -8
- package/dist/actors/computer-use/loop/trace.js +5 -1
- package/dist/actors/computer-use/loop/types.d.ts +30 -19
- package/dist/actors/computer-use/loop/watch.d.ts +42 -0
- package/dist/actors/computer-use/loop/watch.js +260 -0
- package/dist/actors/computer-use/loop.js +18 -4
- package/dist/actors/computer-use/wait.d.ts +36 -18
- package/dist/actors/computer-use/wait.js +52 -23
- package/dist/actors/contract.d.ts +34 -0
- package/dist/actors/local-agent/cli.js +5 -0
- package/dist/evidence/frame-change.d.ts +39 -0
- package/dist/evidence/frame-change.js +293 -0
- package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
- package/dist/routes/computer-use/e2b-desktop/start.d.ts +9 -0
- package/dist/routes/computer-use/e2b-desktop/start.js +17 -2
- package/dist/study/plan-participants.d.ts +2 -2
- package/dist/study/types.d.ts +11 -7
- package/dist/study/warnings.js +1 -1
- package/dist/substrates/e2b/desktop-screen.d.ts +10 -0
- package/dist/substrates/e2b/desktop-screen.js +25 -0
- package/docs/architecture/actor-contract.md +8 -0
- package/docs/architecture/browser-control.md +11 -9
- package/docs/contracts/schemas.md +48 -8
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
// Whether a screen changed while a participant waited, and whether the change has stopped
|
|
3
|
+
// changing. The loop's watch (src/actors/computer-use/loop/watch.ts) asks after each look.
|
|
4
|
+
//
|
|
5
|
+
// The constants were chosen on 323 idle screenshot pairs from 203 retained hosted-desktop runs.
|
|
6
|
+
// Regions that changed on their own were the panel clock (2 to 6 tiles, every 60 s), a text caret
|
|
7
|
+
// (2 tiles, about every 0.5 s), Chrome's tab spinner (2 to 4 tiles) and one landing page's
|
|
8
|
+
// rotating tagline (10 to 36 tiles, median 18). Page loads, navigations and app updates changed
|
|
9
|
+
// 94 to 2,598 tiles. A moved list selection changed 35 tiles and a re-rendered
|
|
10
|
+
// block of timestamps 8 to 12, both missed by the frame hash in frame-signature.ts.
|
|
11
|
+
//
|
|
12
|
+
// The rule for small regions was chosen on the wave 9 look corpus: 3 pages recorded for 40 minutes
|
|
13
|
+
// each on hosted desktops, replayed over 84 to 168 look timelines a page. There a small region
|
|
14
|
+
// that held still woke the participant falsely beside a panel clock, a relative timestamp
|
|
15
|
+
// ("Opened 3 minutes ago", whose digit ticks each minute and whose tail shifts when the text
|
|
16
|
+
// grows) and a moving chart. With the conditions below it woke none falsely and missed no event.
|
|
17
|
+
/** The side of a square tile, in pixels: one tile is the unit of change. */
|
|
18
|
+
const TILE_PX = 16;
|
|
19
|
+
/**
|
|
20
|
+
* A pixel changed when one of its channels differs by more than this (0 to 255). On the wave 9
|
|
21
|
+
* corpus 32 added a false chart wake, and 48 split a new row's light fill from its text, so a row
|
|
22
|
+
* read as small regions and was seen 69.9 s late.
|
|
23
|
+
*/
|
|
24
|
+
const PIXEL_TOLERANCE = 24;
|
|
25
|
+
/**
|
|
26
|
+
* A tile changed when at least this many of its pixels changed. One or two pixels are an
|
|
27
|
+
* antialiased edge: a spinner edge 2 pixels over the tolerance woke a 900 s watch in wave 7 live
|
|
28
|
+
* run 3c. On the wave 9 corpus every tile of its status and export labels changed 10 to 69 pixels,
|
|
29
|
+
* and its new rows (509 and 545 tiles) lost 7 or 8 tiles at 3. 1 and 3 gave the same wakes there.
|
|
30
|
+
*/
|
|
31
|
+
const TILE_MIN_PIXELS = 3;
|
|
32
|
+
/**
|
|
33
|
+
* A still region of more than this many tiles wakes the participant at once: it is above every
|
|
34
|
+
* self-changing region measured except 11 of 110 tagline swaps, and below every page change. On
|
|
35
|
+
* the wave 9 corpus the status label (30 tiles) and new rows were large, and the export label (8
|
|
36
|
+
* tiles) small.
|
|
37
|
+
*/
|
|
38
|
+
const LARGE_REGION_TILES = 24;
|
|
39
|
+
/**
|
|
40
|
+
* A still region of at most LARGE_REGION_TILES tiles wakes the participant once every tile in it
|
|
41
|
+
* has held still for more than this, and no tile within NEAR_TILES of it ticked or moved with a
|
|
42
|
+
* region of more than MOVING_REGION_TILES tiles in this time. Minute updates (a clock's minutes
|
|
43
|
+
* digit, "2 min ago") move again within it. On the wave 9 corpus a one-time 8-tile text change was
|
|
44
|
+
* seen 72.4 s after it landed (median, 5 s looks).
|
|
45
|
+
*/
|
|
46
|
+
const SMALL_REGION_HOLD_MS = 65_000;
|
|
47
|
+
/**
|
|
48
|
+
* How near, in tiles, a ticking tile or a large moving region holds a small region back. The
|
|
49
|
+
* relative timestamp's tail ("1 minute ago" to "2 minutes ago") sat 4 tiles from its digit in
|
|
50
|
+
* wave 7 live run 2a and on the wave 9 corpus, where a distance of 1 left 4 tail wakes in 168
|
|
51
|
+
* timelines and 4 left none.
|
|
52
|
+
*/
|
|
53
|
+
const NEAR_TILES = 4;
|
|
54
|
+
/**
|
|
55
|
+
* A tile ticks when its last move came at least this long after the move before it, or when it
|
|
56
|
+
* has moved once. A minute counter ticks. A spinner tile that moves every few looks does not, so a
|
|
57
|
+
* label beside a spinner still wakes the participant, at 90 s on the wave 9 synthetic screens.
|
|
58
|
+
*/
|
|
59
|
+
const TICK_GAP_MS = 30_000;
|
|
60
|
+
/**
|
|
61
|
+
* A tile that moves with a connected moving region of more than this many tiles (a chart, a video)
|
|
62
|
+
* holds a small region within NEAR_TILES back. A chart's tiles move every few looks, so they never
|
|
63
|
+
* tick. On the wave 9 corpus, without this condition a chart's tiles woke 3 watches falsely in its
|
|
64
|
+
* page's 40 minutes, and 259 in its 84 replayed timelines; with it, none. It was not tuned apart
|
|
65
|
+
* from LARGE_REGION_TILES and has the same value. A spinner of 1 to 4 tiles stays under it.
|
|
66
|
+
*/
|
|
67
|
+
const MOVING_REGION_TILES = 24;
|
|
68
|
+
const UNCHANGED = { verdict: "unchanged" };
|
|
69
|
+
function decode(frame) {
|
|
70
|
+
try {
|
|
71
|
+
const image = PNG.sync.read(frame);
|
|
72
|
+
return image.width > 0 && image.height > 0 ? image : undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Compare later looks at one screen with `reference`, the frame the participant last saw. Call
|
|
80
|
+
* `next` once per look, in order, with the look's time. Each tile that differs from the reference
|
|
81
|
+
* keeps the time it last moved, so a moving tile (a spinner) never joins a still region beside
|
|
82
|
+
* it. Nothing is masked: a large change that stops changing always settles, and a small one does
|
|
83
|
+
* unless a tile near it keeps ticking. Stillness is sampled at looks, so a blink seen in the same
|
|
84
|
+
* phase at every look reads as still.
|
|
85
|
+
*/
|
|
86
|
+
export function watchFrames(reference) {
|
|
87
|
+
const base = decode(reference);
|
|
88
|
+
// Each tile that differs from the reference, with its pixels at the look it last moved at. Only
|
|
89
|
+
// these are kept from the previous look, so a watch holds one decoded frame and not two.
|
|
90
|
+
const differing = new Map();
|
|
91
|
+
let moves;
|
|
92
|
+
return {
|
|
93
|
+
next(frame, atMs) {
|
|
94
|
+
const current = decode(frame);
|
|
95
|
+
// A frame that cannot be decoded is compared by its bytes; a frame of another size is a new
|
|
96
|
+
// screen, one change over the whole frame.
|
|
97
|
+
if (base === undefined || current === undefined)
|
|
98
|
+
return frame.equals(reference) ? UNCHANGED : wholeFrame(current ?? base);
|
|
99
|
+
if (current.width !== base.width || current.height !== base.height)
|
|
100
|
+
return wholeFrame(current);
|
|
101
|
+
moves ??= tileMoves(base);
|
|
102
|
+
return compare(base, current, atMs, differing, moves);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function tileMoves(image) {
|
|
107
|
+
const columns = Math.ceil(image.width / TILE_PX);
|
|
108
|
+
const rows = Math.ceil(image.height / TILE_PX);
|
|
109
|
+
const never = () => new Float64Array(columns * rows).fill(Number.NaN);
|
|
110
|
+
return { columns, rows, moved: never(), movedBefore: never(), movedLarge: never() };
|
|
111
|
+
}
|
|
112
|
+
function wholeFrame(image) {
|
|
113
|
+
return {
|
|
114
|
+
verdict: "settled",
|
|
115
|
+
area: { x: 0, y: 0, width: image?.width ?? 0, height: image?.height ?? 0 },
|
|
116
|
+
small: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function compare(base, current, atMs, differing, moves) {
|
|
120
|
+
const { columns, rows } = moves;
|
|
121
|
+
const still = new Set();
|
|
122
|
+
const movedNow = new Set();
|
|
123
|
+
let moved = 0;
|
|
124
|
+
const move = (tile) => {
|
|
125
|
+
moves.movedBefore[tile] = moves.moved[tile];
|
|
126
|
+
moves.moved[tile] = atMs;
|
|
127
|
+
movedNow.add(tile);
|
|
128
|
+
};
|
|
129
|
+
for (let row = 0; row < rows; row += 1)
|
|
130
|
+
for (let column = 0; column < columns; column += 1) {
|
|
131
|
+
const tile = row * columns + column;
|
|
132
|
+
const before = differing.get(tile);
|
|
133
|
+
if (!framesDiffer(base, current, column, row)) {
|
|
134
|
+
if (before === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
differing.delete(tile);
|
|
137
|
+
// A move back to the reference is a move: a ticking digit can tick back to its old shape.
|
|
138
|
+
if (tileDiffers(before, current, column, row))
|
|
139
|
+
move(tile);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// A tile that starts differing has moved, whatever the previous look's pixels were.
|
|
143
|
+
if (before === undefined || tileDiffers(before, current, column, row)) {
|
|
144
|
+
differing.set(tile, tilePixels(current, column, row));
|
|
145
|
+
move(tile);
|
|
146
|
+
moved += 1;
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
still.add(tile);
|
|
150
|
+
}
|
|
151
|
+
for (const region of connectedRegions(movedNow, columns))
|
|
152
|
+
if (region.length > MOVING_REGION_TILES)
|
|
153
|
+
for (const tile of region)
|
|
154
|
+
moves.movedLarge[tile] = atMs;
|
|
155
|
+
const regions = connectedRegions(still, columns);
|
|
156
|
+
const area = (region) => areaOf(region, columns, base.width, base.height);
|
|
157
|
+
const large = largest(regions.filter((region) => region.length > LARGE_REGION_TILES));
|
|
158
|
+
if (large !== undefined)
|
|
159
|
+
return { verdict: "settled", area: area(large), small: false };
|
|
160
|
+
const heldSmall = regions.filter((region) => region.every((tile) => atMs - moves.moved[tile] > SMALL_REGION_HOLD_MS));
|
|
161
|
+
const settled = largest(heldSmall.filter((region) => !heldBack(region, moves, atMs)));
|
|
162
|
+
if (settled !== undefined)
|
|
163
|
+
return { verdict: "settled", area: area(settled), small: true };
|
|
164
|
+
const waiting = largest(heldSmall);
|
|
165
|
+
const held = waiting === undefined ? {} : { held: area(waiting) };
|
|
166
|
+
if (moved > 0)
|
|
167
|
+
return { verdict: "changing", lookSoon: moved > LARGE_REGION_TILES, ...held };
|
|
168
|
+
return { verdict: "unchanged", ...held };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether a tile within NEAR_TILES of the region, outside it, ticked or moved with a large region
|
|
172
|
+
* within the hold.
|
|
173
|
+
*/
|
|
174
|
+
function heldBack(region, moves, atMs) {
|
|
175
|
+
const inRegion = new Set(region);
|
|
176
|
+
// NaN, a tile that never moved, is never recent.
|
|
177
|
+
const recent = (ms) => atMs - ms <= SMALL_REGION_HOLD_MS;
|
|
178
|
+
for (const tile of region)
|
|
179
|
+
for (let dy = -NEAR_TILES; dy <= NEAR_TILES; dy += 1)
|
|
180
|
+
for (let dx = -NEAR_TILES; dx <= NEAR_TILES; dx += 1) {
|
|
181
|
+
const column = (tile % moves.columns) + dx;
|
|
182
|
+
const row = Math.floor(tile / moves.columns) + dy;
|
|
183
|
+
if (column < 0 || column >= moves.columns || row < 0 || row >= moves.rows)
|
|
184
|
+
continue;
|
|
185
|
+
const near = row * moves.columns + column;
|
|
186
|
+
const moved = moves.moved[near];
|
|
187
|
+
if (inRegion.has(near) || !recent(moved))
|
|
188
|
+
continue;
|
|
189
|
+
const before = moves.movedBefore[near];
|
|
190
|
+
if (Number.isNaN(before) ||
|
|
191
|
+
moved - before >= TICK_GAP_MS ||
|
|
192
|
+
recent(moves.movedLarge[near]))
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
/** Whether at least TILE_MIN_PIXELS pixels of one tile differ between two frames of one size. */
|
|
198
|
+
function framesDiffer(a, b, column, row) {
|
|
199
|
+
const { width, height } = a;
|
|
200
|
+
const xEnd = Math.min(width, (column + 1) * TILE_PX);
|
|
201
|
+
const yEnd = Math.min(height, (row + 1) * TILE_PX);
|
|
202
|
+
let changed = 0;
|
|
203
|
+
for (let y = row * TILE_PX; y < yEnd; y += 1)
|
|
204
|
+
for (let x = column * TILE_PX; x < xEnd; x += 1) {
|
|
205
|
+
const at = (y * width + x) * 4;
|
|
206
|
+
if ((Math.abs(a.data[at] - b.data[at]) > PIXEL_TOLERANCE ||
|
|
207
|
+
Math.abs(a.data[at + 1] - b.data[at + 1]) > PIXEL_TOLERANCE ||
|
|
208
|
+
Math.abs(a.data[at + 2] - b.data[at + 2]) > PIXEL_TOLERANCE) &&
|
|
209
|
+
++changed >= TILE_MIN_PIXELS)
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
/** A copy of one tile's RGBA pixels, row by row. */
|
|
215
|
+
function tilePixels(image, column, row) {
|
|
216
|
+
const xStart = column * TILE_PX;
|
|
217
|
+
const xEnd = Math.min(image.width, xStart + TILE_PX);
|
|
218
|
+
const yEnd = Math.min(image.height, (row + 1) * TILE_PX);
|
|
219
|
+
const pixels = new Uint8Array((xEnd - xStart) * (yEnd - row * TILE_PX) * 4);
|
|
220
|
+
let to = 0;
|
|
221
|
+
for (let y = row * TILE_PX; y < yEnd; y += 1) {
|
|
222
|
+
const from = (y * image.width + xStart) * 4;
|
|
223
|
+
pixels.set(image.data.subarray(from, from + (xEnd - xStart) * 4), to);
|
|
224
|
+
to += (xEnd - xStart) * 4;
|
|
225
|
+
}
|
|
226
|
+
return pixels;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether at least TILE_MIN_PIXELS pixels of one tile of `image` differ from `pixels`, that tile's
|
|
230
|
+
* earlier pixels.
|
|
231
|
+
*/
|
|
232
|
+
function tileDiffers(pixels, image, column, row) {
|
|
233
|
+
const xStart = column * TILE_PX;
|
|
234
|
+
const xEnd = Math.min(image.width, xStart + TILE_PX);
|
|
235
|
+
const yEnd = Math.min(image.height, (row + 1) * TILE_PX);
|
|
236
|
+
const { data } = image;
|
|
237
|
+
let at = 0;
|
|
238
|
+
let changed = 0;
|
|
239
|
+
for (let y = row * TILE_PX; y < yEnd; y += 1)
|
|
240
|
+
for (let x = xStart; x < xEnd; x += 1, at += 4) {
|
|
241
|
+
const from = (y * image.width + x) * 4;
|
|
242
|
+
if ((Math.abs(pixels[at] - data[from]) > PIXEL_TOLERANCE ||
|
|
243
|
+
Math.abs(pixels[at + 1] - data[from + 1]) > PIXEL_TOLERANCE ||
|
|
244
|
+
Math.abs(pixels[at + 2] - data[from + 2]) > PIXEL_TOLERANCE) &&
|
|
245
|
+
++changed >= TILE_MIN_PIXELS)
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
/** Tiles grouped with every tile of the set next to them, diagonals included. */
|
|
251
|
+
function connectedRegions(tiles, columns) {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const regions = [];
|
|
254
|
+
for (const start of tiles) {
|
|
255
|
+
if (seen.has(start))
|
|
256
|
+
continue;
|
|
257
|
+
seen.add(start);
|
|
258
|
+
const region = [];
|
|
259
|
+
const queue = [start];
|
|
260
|
+
for (let tile = queue.pop(); tile !== undefined; tile = queue.pop()) {
|
|
261
|
+
region.push(tile);
|
|
262
|
+
const column = tile % columns;
|
|
263
|
+
for (const dy of [-1, 0, 1])
|
|
264
|
+
for (const dx of [-1, 0, 1]) {
|
|
265
|
+
const neighbourColumn = column + dx;
|
|
266
|
+
const neighbour = tile + dy * columns + dx;
|
|
267
|
+
if (neighbourColumn < 0 || neighbourColumn >= columns)
|
|
268
|
+
continue;
|
|
269
|
+
if (!tiles.has(neighbour) || seen.has(neighbour))
|
|
270
|
+
continue;
|
|
271
|
+
seen.add(neighbour);
|
|
272
|
+
queue.push(neighbour);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
regions.push(region);
|
|
276
|
+
}
|
|
277
|
+
return regions;
|
|
278
|
+
}
|
|
279
|
+
function largest(regions) {
|
|
280
|
+
return regions.reduce((best, region) => (best === undefined || region.length > best.length ? region : best), undefined);
|
|
281
|
+
}
|
|
282
|
+
function areaOf(tiles, columns, width, height) {
|
|
283
|
+
const columnsOf = tiles.map((tile) => tile % columns);
|
|
284
|
+
const rowsOf = tiles.map((tile) => Math.floor(tile / columns));
|
|
285
|
+
const x = Math.min(...columnsOf) * TILE_PX;
|
|
286
|
+
const y = Math.min(...rowsOf) * TILE_PX;
|
|
287
|
+
return {
|
|
288
|
+
x,
|
|
289
|
+
y,
|
|
290
|
+
width: Math.min(width, (Math.max(...columnsOf) + 1) * TILE_PX) - x,
|
|
291
|
+
height: Math.min(height, (Math.max(...rowsOf) + 1) * TILE_PX) - y,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -2,7 +2,7 @@ import { createE2BDesktopExecutor } from "../../../substrates/e2b/desktop-execut
|
|
|
2
2
|
import { participantInbox, planParticipantComms } from "./comms.js";
|
|
3
3
|
import { participantBrowserStateObserver } from "./fidelity.js";
|
|
4
4
|
import { acquireParticipantDesktop, placeParticipantFiles, provisionParticipantSubject, verifyParticipantScreen, } from "./prepare.js";
|
|
5
|
-
import { openParticipantSurface, startParticipantMedia, startParticipantStream } from "./start.js";
|
|
5
|
+
import { keepParticipantScreenOn, openParticipantSurface, startParticipantMedia, startParticipantStream, } from "./start.js";
|
|
6
6
|
import { desktopEvidenceOf, newParticipantState } from "./state.js";
|
|
7
7
|
import { finishE2BDesktop } from "./teardown.js";
|
|
8
8
|
import { participantServeUrl } from "../types.js";
|
|
@@ -44,6 +44,7 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
|
|
|
44
44
|
await provisionParticipantSubject(ctx, state, desktop);
|
|
45
45
|
await startParticipantMedia(ctx, state, desktop);
|
|
46
46
|
await openParticipantSurface(ctx, state, desktop);
|
|
47
|
+
await keepParticipantScreenOn(ctx, desktop);
|
|
47
48
|
prepared = true;
|
|
48
49
|
}
|
|
49
50
|
async function openSession() {
|
|
@@ -8,6 +8,15 @@ export declare function startParticipantMedia(ctx: E2BParticipantContext, state:
|
|
|
8
8
|
* desktop's panel clock in it; a clock that cannot be set is a warning, and it shows UTC.
|
|
9
9
|
*/
|
|
10
10
|
export declare function openParticipantSurface(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Turn the desktop's screen saver off, so the screen stays on while the participant waits without
|
|
13
|
+
* input. The XFCE session sets the screen saver's timeout in its first seconds, and the last write
|
|
14
|
+
* wins. Nothing waits for those writes: this runs after the browser or terminal has opened and the
|
|
15
|
+
* desktop has waited DESKTOP_SETTLE_MS, and on 16 fresh desktops the last write came within 2.5 s
|
|
16
|
+
* of a command sent as soon as the desktop was created. A screen saver that cannot be turned off
|
|
17
|
+
* is a warning.
|
|
18
|
+
*/
|
|
19
|
+
export declare function keepParticipantScreenOn(ctx: E2BParticipantContext, desktop: E2BDesktopSandbox): Promise<void>;
|
|
11
20
|
/**
|
|
12
21
|
* Measure the browser window and start the live stream. A stream that cannot start is a warning;
|
|
13
22
|
* unusable browser geometry fails the participant before any action.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// The second half of an E2B desktop participant's preparation, and the start of its session: speech and
|
|
2
|
-
// recording, then the browser or terminal the participant sees, then the
|
|
3
|
-
// fills the participant's state.
|
|
2
|
+
// recording, then the browser or terminal the participant sees, then the screen saver turned off,
|
|
3
|
+
// then the live stream. Each step fills the participant's state.
|
|
4
4
|
import { redactText, toErrorMessage } from "../../../evidence/redaction.js";
|
|
5
5
|
import { CHROME_DEVTOOLS_PORT, DESKTOP_SETTLE_MS, openDesktopBrowserTarget, openDesktopTerminal, startDesktopStream, } from "../../../substrates/e2b/desktop-browser.js";
|
|
6
6
|
import { setPanelClockZone } from "../../../substrates/e2b/desktop-clock.js";
|
|
7
7
|
import { captureDesktopBrowserGeometry } from "../../../substrates/e2b/desktop-geometry.js";
|
|
8
8
|
import { prepareDesktopMedia, startE2BDesktopMedia, } from "../../../substrates/e2b/desktop-media.js";
|
|
9
9
|
import { startE2BDesktopRecording } from "../../../substrates/e2b/desktop-recording.js";
|
|
10
|
+
import { turnOffScreenSaver } from "../../../substrates/e2b/desktop-screen.js";
|
|
10
11
|
import { applyParticipantMobileFidelity, mobileLaunchFlags } from "./fidelity.js";
|
|
11
12
|
/** Start the declared speech worker and screen recording. A recording that cannot start is a warning. */
|
|
12
13
|
export async function startParticipantMedia(ctx, state, desktop) {
|
|
@@ -101,6 +102,20 @@ export async function openParticipantSurface(ctx, state, desktop) {
|
|
|
101
102
|
warnings,
|
|
102
103
|
});
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Turn the desktop's screen saver off, so the screen stays on while the participant waits without
|
|
107
|
+
* input. The XFCE session sets the screen saver's timeout in its first seconds, and the last write
|
|
108
|
+
* wins. Nothing waits for those writes: this runs after the browser or terminal has opened and the
|
|
109
|
+
* desktop has waited DESKTOP_SETTLE_MS, and on 16 fresh desktops the last write came within 2.5 s
|
|
110
|
+
* of a command sent as soon as the desktop was created. A screen saver that cannot be turned off
|
|
111
|
+
* is a warning.
|
|
112
|
+
*/
|
|
113
|
+
export async function keepParticipantScreenOn(ctx, desktop) {
|
|
114
|
+
const { spec, deps, warnings } = ctx;
|
|
115
|
+
await turnOffScreenSaver(desktop, deps.requestTimeoutMs).catch((error) => {
|
|
116
|
+
warnings.push(`The desktop's screen saver for participant ${spec.planned.id} could not be turned off, so its screen may go black after 5 minutes without input: ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
104
119
|
/**
|
|
105
120
|
* DevTools answering later than this after launch is recorded as a warning on the participant. Six live
|
|
106
121
|
* launches answered in 4.8-7.7 s; without this wait the reads it guards give up at about 11 s.
|
|
@@ -37,11 +37,11 @@ export interface DesktopParticipant extends Participant {
|
|
|
37
37
|
readonly dwell?: DwellWindow;
|
|
38
38
|
readonly reasoningEffort?: ReasoningEffort;
|
|
39
39
|
readonly maxOutputTokens?: number;
|
|
40
|
-
/** actor.maxWaitMs: the longest one wait
|
|
40
|
+
/** actor.maxWaitMs: the longest one wait or one turn of only waits lasts. */
|
|
41
41
|
readonly maxWaitMs?: number;
|
|
42
42
|
/**
|
|
43
43
|
* The participant's idleWaitMs, else actor.idleWaitMs: how long a wait with no duration lasts
|
|
44
|
-
* while the participant only waits.
|
|
44
|
+
* while the participant only waits, and how often the loop looks at its screen then.
|
|
45
45
|
*/
|
|
46
46
|
readonly idleWaitMs?: number;
|
|
47
47
|
/**
|
package/dist/study/types.d.ts
CHANGED
|
@@ -361,7 +361,8 @@ export interface StudyParticipantEntry {
|
|
|
361
361
|
windDownMs?: number;
|
|
362
362
|
/**
|
|
363
363
|
* Computer use and shared world: this participant's actor.idleWaitMs, how long a wait that names
|
|
364
|
-
* no duration lasts while it only waits
|
|
364
|
+
* no duration lasts while it only waits and how often the loop looks at its screen then. Same
|
|
365
|
+
* range, and no longer than actor.maxWaitMs.
|
|
365
366
|
*/
|
|
366
367
|
idleWaitMs?: number;
|
|
367
368
|
/**
|
|
@@ -404,16 +405,19 @@ export interface StudyActor {
|
|
|
404
405
|
maxOutputTokens?: number;
|
|
405
406
|
/**
|
|
406
407
|
* Computer-use and shared-world: the longest one `wait` action lasts, in whole milliseconds from
|
|
407
|
-
* 1000 to
|
|
408
|
-
* to it, the trace records a `wait shortened` notice, and the
|
|
409
|
-
* turn.
|
|
408
|
+
* 1000 to 3600000. Default 120000, or 30000 on a desktop with speech. In a turn with another
|
|
409
|
+
* action a longer wait is shortened to it, the trace records a `wait shortened` notice, and the
|
|
410
|
+
* participant is told on its next turn. A turn that only waits is watched: its named waits last
|
|
411
|
+
* their lengths, together up to this or to 600000 when it is not set, and on a screen that stays
|
|
412
|
+
* the same its watches in a row grow to the same cap.
|
|
410
413
|
*/
|
|
411
414
|
maxWaitMs?: number;
|
|
412
415
|
/**
|
|
413
416
|
* Computer-use and shared-world: how long a `wait` that names no duration lasts when the
|
|
414
|
-
* participant's turn only waits or takes screenshots,
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
+
* participant's turn only waits or takes screenshots, and how often the loop looks at the screen
|
|
418
|
+
* while it watches such a turn, in whole milliseconds from 1000 to 600000 and no longer than
|
|
419
|
+
* maxWaitMs. Default 5000. After an action in the same turn such a wait lasts 500 ms. OpenAI's
|
|
420
|
+
* computer-use `wait` never names a duration.
|
|
417
421
|
*/
|
|
418
422
|
idleWaitMs?: number;
|
|
419
423
|
/**
|
package/dist/study/warnings.js
CHANGED
|
@@ -66,7 +66,7 @@ const ACTOR_ROWS = [
|
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
field: "actor.idleWaitMs",
|
|
69
|
-
reason: "how long a computer-use participant's wait with no duration lasts; needs route: computer-use or shared-world",
|
|
69
|
+
reason: "how long a computer-use participant's wait with no duration lasts and how often it looks while it waits; needs route: computer-use or shared-world",
|
|
70
70
|
applies: (config, routes) => config.actor?.idleWaitMs !== undefined && !routes.cua && !routes.shared,
|
|
71
71
|
},
|
|
72
72
|
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { E2BDesktopSandbox } from "./sdk.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turn the desktop's X screen saver off with `xset s off`. Nothing here waits for the three
|
|
4
|
+
* writers, so a call holds only when it comes after their last write: on 16 fresh desktops that
|
|
5
|
+
* write came 1.6 to 2.5 s after a command sent as soon as the desktop was created, and none came
|
|
6
|
+
* in the following 27 s. After that, xfce4-screensaver and xfce4-power-manager write the timeout
|
|
7
|
+
* again only when the xfconf settings of either one change, which setting the panel clock does not
|
|
8
|
+
* do. A desktop without xset is left as it is. Throws with xset's error output when xset fails.
|
|
9
|
+
*/
|
|
10
|
+
export declare function turnOffScreenSaver(desktop: E2BDesktopSandbox, requestTimeoutMs: number): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The hosted desktop's X screen saver. The stock template's XFCE session has three writers of its
|
|
2
|
+
// timeout, each acting in the session's first seconds: xfce4-screensaver sets its idle delay
|
|
3
|
+
// (300 s), xfce4-power-manager sets its blank-on-ac time (600 s), and an autostart entry runs
|
|
4
|
+
// `xset s off`. The last write wins. A timeout left at 300 s turns the screen black after 5 minutes
|
|
5
|
+
// without input, so a participant waiting on a page sees a black screen. The desktop runs on Xvfb,
|
|
6
|
+
// which has no display power management extension, so the screen saver is the only thing that
|
|
7
|
+
// blanks it.
|
|
8
|
+
import { e2bShell } from "./shell.js";
|
|
9
|
+
/**
|
|
10
|
+
* Turn the desktop's X screen saver off with `xset s off`. Nothing here waits for the three
|
|
11
|
+
* writers, so a call holds only when it comes after their last write: on 16 fresh desktops that
|
|
12
|
+
* write came 1.6 to 2.5 s after a command sent as soon as the desktop was created, and none came
|
|
13
|
+
* in the following 27 s. After that, xfce4-screensaver and xfce4-power-manager write the timeout
|
|
14
|
+
* again only when the xfconf settings of either one change, which setting the panel clock does not
|
|
15
|
+
* do. A desktop without xset is left as it is. Throws with xset's error output when xset fails.
|
|
16
|
+
*/
|
|
17
|
+
export async function turnOffScreenSaver(desktop, requestTimeoutMs) {
|
|
18
|
+
const result = await e2bShell(desktop).run([
|
|
19
|
+
'export DISPLAY="${DISPLAY:-:0}"',
|
|
20
|
+
"command -v xset >/dev/null 2>&1 || exit 0",
|
|
21
|
+
"xset s off",
|
|
22
|
+
].join("\n"), { requestTimeoutMs, timeoutMs: 15_000 });
|
|
23
|
+
if (result.exitCode !== 0)
|
|
24
|
+
throw new Error(result.stderr.trim() || `xset exited ${result.exitCode}`);
|
|
25
|
+
}
|
|
@@ -168,6 +168,14 @@ export interface ActorTraceItem {
|
|
|
168
168
|
screenshotRef?: { path: string; redaction: "blurred" | "ocr_scrubbed" | "none" };
|
|
169
169
|
text?: string; // redacted
|
|
170
170
|
page?: { origin: string; path: string }; // computer-use screenshot items; redacted, no query
|
|
171
|
+
// computer-use: the wait ui_action during which a watch of a turn that only waited ended
|
|
172
|
+
watch?: {
|
|
173
|
+
limitMs: number;
|
|
174
|
+
askedMs: number; // what the turn's waits asked for; above limitMs past the watch's growth cap
|
|
175
|
+
wake: "changed" | "speech" | "condition" | "limit" | "clock" | "stalled" | "stopped";
|
|
176
|
+
looks: number;
|
|
177
|
+
area?: { x: number; y: number; width: number; height: number }; // pixels, coordinates only
|
|
178
|
+
};
|
|
171
179
|
}
|
|
172
180
|
|
|
173
181
|
export interface ActorCapabilities {
|
|
@@ -104,15 +104,17 @@ title, text, fractional scroll position and heard speech; those remain
|
|
|
104
104
|
runtime-only under the existing loop contract. Arbitrary `appState` is refused
|
|
105
105
|
because v1 has no closed schema for it. The protocol does not truncate strings,
|
|
106
106
|
round coordinates, or silently drop unsupported state. A participant may ask for a
|
|
107
|
-
longer wait than one request carries.
|
|
108
|
-
`actor.maxWaitMs` (by default two minutes, or 30 seconds on a desktop
|
|
109
|
-
speech reaches the participant only with a screenshot), records a `wait
|
|
110
|
-
durations when it does, and sends the rest as consecutive waits of at
|
|
111
|
-
participant's `humanish_ui` schema leaves a wait's length open for that
|
|
112
|
-
refuses a single wait over 30 seconds. A wait that names no duration, as
|
|
113
|
-
wait does, reaches the desktop with a length too:
|
|
114
|
-
|
|
115
|
-
the
|
|
107
|
+
longer wait than one request carries. In a turn with another action, the computer-use loop
|
|
108
|
+
shortens it to the study's `actor.maxWaitMs` (by default two minutes, or 30 seconds on a desktop
|
|
109
|
+
with speech, whose heard speech reaches the participant only with a screenshot), records a `wait
|
|
110
|
+
shortened` notice with both durations when it does, and sends the rest as consecutive waits of at
|
|
111
|
+
most 30 seconds. The Codex participant's `humanish_ui` schema leaves a wait's length open for that
|
|
112
|
+
reason; the wire still refuses a single wait over 30 seconds. A wait that names no duration, as
|
|
113
|
+
every OpenAI computer-use wait does, reaches the desktop with a length too: 500 ms after an action
|
|
114
|
+
in the same turn. A turn that only waits or takes screenshots sends no wait to the desktop: the
|
|
115
|
+
loop observes the screen every `actor.idleWaitMs` until it changes or the wait's time passes
|
|
116
|
+
(`src/actors/computer-use/loop/watch.ts`), so a waiting participant costs one observe request per
|
|
117
|
+
look.
|
|
116
118
|
|
|
117
119
|
Click, double-click, move, scroll and drag may carry `heldKeys`, which the
|
|
118
120
|
OpenAI provider maps from its computer tool's `keys`. The Codex participant's
|
|
@@ -263,8 +263,10 @@ The rest of this section names keys by their v3 spelling:
|
|
|
263
263
|
the end, a turn that requests no action does not end the session: the trace records a
|
|
264
264
|
`participant ended before its scheduled end` notice, the next request says the time left, and
|
|
265
265
|
the turn counts toward the idle backstop. Before the end, a turn that only waits or takes
|
|
266
|
-
screenshots counts toward neither backstop streak (`advanceBackstop`'s `waitingIsTheTask`).
|
|
267
|
-
|
|
266
|
+
screenshots counts toward neither backstop streak (`advanceBackstop`'s `waitingIsTheTask`). A
|
|
267
|
+
watch that reaches the end stops there with no further look, and a backstop streak that trips on
|
|
268
|
+
a turn reaching the end gives way to the end. The plan entry carries `endAfterMs`, and `study
|
|
269
|
+
check` prints a `windows` row;
|
|
268
270
|
- `participants[].maxSessionMs` (computer-use only, also on `count:` groups): the longest the
|
|
269
271
|
participant's session lasts, in whole milliseconds from 1 to 86,400,000, counted from the start
|
|
270
272
|
of its own session. `windDownMs` applies to it as to `endAfterMs`. Refused: a value outside that
|
|
@@ -282,7 +284,9 @@ The rest of this section names keys by their v3 spelling:
|
|
|
282
284
|
`cap=<duration>`;
|
|
283
285
|
- `participants[].idleWaitMs` (computer-use and shared-world, also on `count:` groups): this
|
|
284
286
|
participant's `actor.idleWaitMs`, in the same range and refused above an explicit
|
|
285
|
-
`actor.maxWaitMs` (`waitLimitValidationReason`)
|
|
287
|
+
`actor.maxWaitMs` (`waitLimitValidationReason`): how long its wait with no duration lasts in a
|
|
288
|
+
turn that only waits, and how often the loop looks at its screen while it watches such a turn.
|
|
289
|
+
Its trace records it in `waitSettings`;
|
|
286
290
|
- `actor.clock.timeZone` (computer-use and shared-world): a tz database time zone name, such as `Europe/London`. Each turn's context hint carries
|
|
287
291
|
the local time in it, and a scheduled end or session cap is given in it (UTC without it). On a hosted desktop the
|
|
288
292
|
participant's browser launches with `TZ` set to it (`openParticipantSurface` in
|
|
@@ -1067,6 +1071,35 @@ Core-owned fields:
|
|
|
1067
1071
|
click-like `ui_action` items, structured `coord` (`x`/`y`). Both are additive:
|
|
1068
1072
|
absent on older bundles and non-stamping producers, and absence means
|
|
1069
1073
|
"timing/position unrecorded", never zero
|
|
1074
|
+
- optional `items[].watch` (`ActorWatch` in `src/actors/contract.ts`), on the
|
|
1075
|
+
computer-use `ui_action` of the wait during which a watch ended. A turn whose
|
|
1076
|
+
actions are only waits and screenshots, and that asks for wait time, sends no
|
|
1077
|
+
wait to the desktop: the loop looks at the screen every `waitSettings.idleWaitMs`
|
|
1078
|
+
and requests the next turn when a change holds still, speech arrives, a
|
|
1079
|
+
`stopWhen` rule, `dwell.when` or task `success` rule matches a look, the
|
|
1080
|
+
participant's wind-down or end arrives, or the watch's time passes
|
|
1081
|
+
(`src/actors/computer-use/loop/watch.ts`). A change of more than 24 tiles of
|
|
1082
|
+
16 pixels counts once it holds still at a look; one of 24 tiles or fewer once
|
|
1083
|
+
it has held still for more than 65 s with no tile within 4 tiles of it ticking
|
|
1084
|
+
or moving with a region of more than 24 tiles
|
|
1085
|
+
(`src/evidence/frame-change.ts`). The first watch lasts the turn's planned
|
|
1086
|
+
length: the named waits' lengths, together up to `actor.maxWaitMs` (else
|
|
1087
|
+
600000), and `idleWaitMs` for each wait that names none. Each further one in a row that ended
|
|
1088
|
+
at its limit, or that a change of 24 tiles or fewer woke, lasts twice as long,
|
|
1089
|
+
up to the larger of its planned length and that cap. `limitMs` is how long it
|
|
1090
|
+
could last; `askedMs` is what the turn's waits asked for, the same sum with no
|
|
1091
|
+
cap, so it is above `limitMs` when the named waits passed the cap and below it
|
|
1092
|
+
when the watch grew; `wake` is `changed`, `speech`,
|
|
1093
|
+
`condition`, `limit`, `clock` (wind-down or end), `stalled` (a look got no
|
|
1094
|
+
frame; the next observation is a fresh one), or `stopped` (the study budget or
|
|
1095
|
+
the session's time ran out); `looks` counts its looks; `area` (`x`, `y`,
|
|
1096
|
+
`width`, `height` in pixels) bounds the change that woke it and is absent for
|
|
1097
|
+
other wakes and for a change still moving at the last look. A `notice` titled
|
|
1098
|
+
`watching the screen`, `lifecycle: started`, opens each watch. The turn's
|
|
1099
|
+
waits share the watch in order, in proportion to their planned lengths, and
|
|
1100
|
+
waits after the one carrying `watch` are titled `wait 0ms`. The look that
|
|
1101
|
+
ended a watch is the turn's `turn-NN` screenshot; other looks are not kept.
|
|
1102
|
+
Additive: absent on plain waits, other producers and older bundles
|
|
1070
1103
|
- optional `items[].page` and `finalPage` (`{ origin, path }`, `TracePage` in
|
|
1071
1104
|
`src/actors/contract.ts`): where a computer-use participant's browser was. The loop puts `page`
|
|
1072
1105
|
on the `screenshot` item of each observation that read a URL: `turn-00-start` is the page before
|
|
@@ -1121,13 +1154,20 @@ Core-owned fields:
|
|
|
1121
1154
|
`stopCause: provider_output_limit`.
|
|
1122
1155
|
- optional `waitSettings`: the wait lengths a computer-use participant's session
|
|
1123
1156
|
applied, in milliseconds, defaults included. `maxWaitMs` is the longest one
|
|
1124
|
-
wait action lasts (`actor.maxWaitMs
|
|
1157
|
+
wait action lasts (`actor.maxWaitMs`, else 120000, or 30000 on a desktop with
|
|
1158
|
+
speech); in a turn with another action a longer wait is shortened to it.
|
|
1125
1159
|
`idleWaitMs` is how long a wait that names no duration lasts in a turn that
|
|
1126
1160
|
only waits or takes screenshots (`participants[].idleWaitMs`, else
|
|
1127
|
-
`actor.idleWaitMs`), and
|
|
1128
|
-
|
|
1129
|
-
a wait
|
|
1130
|
-
|
|
1161
|
+
`actor.idleWaitMs`), and the time between looks at the screen while the loop
|
|
1162
|
+
watches such a turn, at most 30000 on a desktop with speech. `settleWaitMs` is
|
|
1163
|
+
how long a wait that names no duration lasts after an action in the same turn,
|
|
1164
|
+
and the time to a watch's quick look after a large change. A watch's length
|
|
1165
|
+
grows to `actor.maxWaitMs`, else 600000, which `waitSettings` does not record;
|
|
1166
|
+
each watch's `watch.limitMs` does. The named waits of a turn that only waits
|
|
1167
|
+
are watched for their lengths, together up to that cap, past `maxWaitMs`. A wait sent to the desktop has a
|
|
1168
|
+
`ui_action` titled with its planned length, which a stalled wait keeps. A
|
|
1169
|
+
watched wait's `ui_action` is titled with the part of the watch it covered.
|
|
1170
|
+
Absent on other routes and on earlier bundles
|
|
1131
1171
|
- optional `conversation`: how the first-party OpenAI computer-use provider
|
|
1132
1172
|
carried the conversation between requests. `mode` is `threaded` (the server
|
|
1133
1173
|
kept it through `previous_response_id`) or `explicit_context` (the server kept
|