humanish 0.130.0 → 0.131.0

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.
@@ -0,0 +1,260 @@
1
+ import { watchFrames, } from "../../../evidence/frame-change.js";
2
+ import { plural } from "../../../run/text.js";
3
+ import { isIdleTurn } from "./actions.js";
4
+ import { progressKeyOf } from "./backstop.js";
5
+ import { clockEndStop } from "./clock.js";
6
+ import { timeLimit } from "./ending.js";
7
+ import { CuaAbortError, CuaStallError, raceSessionDeadline } from "./race.js";
8
+ import { spendStop } from "./spend.js";
9
+ /**
10
+ * The watches of one participant's idle turns. Between turns it keeps the run of idle turns the
11
+ * doubling counts: a watch that ended at its limit or on a small change makes the next one in the
12
+ * run longer, and a turn with another action ends the run.
13
+ */
14
+ export class IdleWatcher {
15
+ session;
16
+ observer;
17
+ /** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
18
+ grownFromMs;
19
+ constructor(session, observer) {
20
+ this.session = session;
21
+ this.observer = observer;
22
+ }
23
+ /**
24
+ * Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
25
+ * another action, which ends the run, or with no wait time (screenshots only, or waits of 0 ms),
26
+ * which leaves the run as it is. `reference` is the observation the participant chose to wait on.
27
+ */
28
+ async watch(turnNumber, reference, actions) {
29
+ const { session } = this;
30
+ if (!isIdleTurn(actions)) {
31
+ this.grownFromMs = undefined;
32
+ return undefined;
33
+ }
34
+ // A named wait past maxWaitMs, the longest one wait sent to the desktop lasts, is watched for
35
+ // its length: a participant told to wait 10 minutes waits 10 minutes. A turn's named waits
36
+ // together last at most watchMaxMs, in order, so a Codex call of 4 long waits stays open no
37
+ // longer than one. A wait that names no length lasts idleWaitMs.
38
+ const asked = actions.flatMap((action) => action.kind === "wait" ? [Math.max(0, action.ms ?? session.waits.idleWaitMs)] : []);
39
+ let namedMs = 0;
40
+ const lengths = actions.flatMap((action) => {
41
+ if (action.kind !== "wait")
42
+ return [];
43
+ if (action.ms === undefined)
44
+ return [session.waits.idleWaitMs];
45
+ const ms = Math.min(Math.max(0, action.ms), session.waits.watchMaxMs - namedMs);
46
+ namedMs += ms;
47
+ return [ms];
48
+ });
49
+ const plannedMs = lengths.reduce((sum, ms) => sum + ms, 0);
50
+ if (plannedMs === 0)
51
+ return undefined;
52
+ const capMs = Math.max(plannedMs, session.waits.watchMaxMs);
53
+ // Never shorter than the turn's own planned length, so a long named wait is not cut short.
54
+ const limitMs = this.grownFromMs === undefined
55
+ ? plannedMs
56
+ : Math.min(capMs, Math.max(plannedMs, 2 * this.grownFromMs));
57
+ const ending = await watchScreen(session, this.observer, turnNumber, reference, limitMs);
58
+ // A small change that held still (a status label, or a counter the rule did not hold back)
59
+ // wakes the participant and keeps the doubling, so a repeating small change cannot keep every
60
+ // watch short.
61
+ const grows = ending.wake === "limit" || (ending.seen?.verdict === "settled" && ending.seen.small);
62
+ this.grownFromMs = grows ? limitMs : undefined;
63
+ return {
64
+ wake: ending.wake,
65
+ ...(ending.observation === undefined ? {} : { observation: ending.observation }),
66
+ ...(ending.stop === undefined ? {} : { stop: ending.stop }),
67
+ hint: hintFor(ending, grows && limitMs < capMs ? capMs : undefined),
68
+ waits: {
69
+ ...splitWatch(lengths, limitMs, ending.ms),
70
+ sessionEnded: ending.stop !== undefined,
71
+ watch: {
72
+ limitMs,
73
+ askedMs: asked.reduce((sum, ms) => sum + ms, 0),
74
+ wake: ending.wake,
75
+ looks: ending.looks,
76
+ ...(ending.area === undefined ? {} : { area: { ...ending.area } }),
77
+ },
78
+ },
79
+ };
80
+ }
81
+ }
82
+ async function watchScreen(session, observer, turnNumber, reference, limitMs) {
83
+ const { lookEveryMs, settleWaitMs } = session.waits;
84
+ const startedAt = session.now();
85
+ const limitAt = startedAt + limitMs;
86
+ const deadlineAt = session.startedAtMs + session.timeoutMs;
87
+ const end = session.settings.clock?.end;
88
+ const endAt = end?.atMs;
89
+ // A wind-down already under way bounds no watch: the participant was told when it started.
90
+ const windDownAt = end !== undefined && end.atMs - end.windDownMs > startedAt
91
+ ? end.atMs - end.windDownMs
92
+ : undefined;
93
+ // A reply that came back past the end or the deadline starts no watch.
94
+ if (session.signal?.aborted)
95
+ throw new CuaAbortError();
96
+ if (endAt !== undefined && startedAt >= endAt)
97
+ return {
98
+ wake: "clock",
99
+ ms: 0,
100
+ looks: 0,
101
+ ...stopAt(clockEndStop(session, reference, startedAt)),
102
+ };
103
+ if (startedAt >= deadlineAt)
104
+ return { wake: "stopped", ms: 0, looks: 0, stop: timeLimit(session) };
105
+ session.trace.record("notice", () => ({
106
+ lifecycle: "started",
107
+ title: "watching the screen",
108
+ text: `until the screen changes or ${limitMs}ms pass, looking every ${lookEveryMs}ms and at the end; no model request until then`,
109
+ }));
110
+ session.flush();
111
+ const frames = reference.screenshot === undefined ? undefined : watchFrames(reference.screenshot);
112
+ const referenceKey = progressKeyOf(reference);
113
+ let looks = 0;
114
+ let quickLooks = 0;
115
+ let lastLook;
116
+ let nextLookAt = startedAt + lookEveryMs;
117
+ for (;;) {
118
+ if (session.signal?.aborted)
119
+ throw new CuaAbortError();
120
+ session.phase = `watching the screen during turn ${turnNumber}`;
121
+ const target = Math.min(nextLookAt, limitAt, windDownAt ?? limitAt, endAt ?? limitAt, deadlineAt);
122
+ const sleptFrom = session.now();
123
+ await raceSessionDeadline(session.sleep(Math.max(0, target - sleptFrom)), deadlineAt - sleptFrom, session.signal);
124
+ // An injected clock need not move with the injected sleep; the watch's schedule still does.
125
+ const at = Math.max(session.now(), target);
126
+ // The wait lasts until the watch returns, so it includes the look that ended it, and a stalled
127
+ // look's observe and retry.
128
+ const lasted = () => Math.max(session.now(), at) - startedAt;
129
+ // No observe after the end or the deadline: one started with no time left would fail, and at
130
+ // the end the session would end as timed out. The stop is read at `at`: a host timer can fire
131
+ // a millisecond before the clock reaches the end.
132
+ if (endAt !== undefined && at >= endAt)
133
+ return {
134
+ wake: "clock",
135
+ ms: lasted(),
136
+ looks,
137
+ ...stopAt(clockEndStop(session, lastLook ?? reference, at)),
138
+ };
139
+ if (at >= deadlineAt)
140
+ return { wake: "stopped", ms: lasted(), looks, stop: timeLimit(session) };
141
+ let look;
142
+ try {
143
+ look = await observer.look(turnNumber);
144
+ }
145
+ catch (error) {
146
+ if (error instanceof CuaStallError)
147
+ return { wake: "stalled", ms: lasted(), looks };
148
+ throw error;
149
+ }
150
+ looks += 1;
151
+ lastLook = look.observation;
152
+ const ms = lasted();
153
+ const ended = (wake, seen) => ({
154
+ wake,
155
+ ms,
156
+ looks,
157
+ observation: look.observation,
158
+ ...(seen === undefined ? {} : { seen }),
159
+ ...(seen?.verdict === "settled" ? { area: seen.area } : {}),
160
+ });
161
+ // A condition comes first: the checkpoint persists the look that matched it (a task completed
162
+ // at it is counted already), and the budget then stops the session before the next request.
163
+ if (look.condition)
164
+ return ended("condition");
165
+ const overBudget = spendStop(session);
166
+ if (overBudget !== undefined)
167
+ return { ...ended("stopped"), stop: overBudget };
168
+ if (look.speech)
169
+ return ended("speech");
170
+ const seen = compareLook(frames, referenceKey, look.observation, at);
171
+ if (seen.verdict === "settled")
172
+ return ended("changed", seen);
173
+ const windingDown = windDownAt !== undefined && at >= windDownAt;
174
+ // A change still moving at the last look, such as a video, ended nothing: the participant
175
+ // sees it in that look's frame, the hint says a large part was changing, and the next watch
176
+ // grows as after a still screen.
177
+ if (at >= limitAt || windingDown)
178
+ return ended(windingDown ? "clock" : "limit", seen);
179
+ const largeMove = seen.verdict === "changing" && seen.lookSoon;
180
+ quickLooks = largeMove && quickLooks < 2 ? quickLooks + 1 : 0;
181
+ nextLookAt = at + (quickLooks > 0 ? settleWaitMs : lookEveryMs);
182
+ }
183
+ }
184
+ const stopAt = (stop) => (stop === undefined ? {} : { stop });
185
+ /**
186
+ * Whether a look shows a change that wakes the participant. A frame is compared with the reference
187
+ * frame tile by tile; an observation without one (a state executor) by its progress key.
188
+ */
189
+ function compareLook(frames, referenceKey, observation, atMs) {
190
+ if (frames === undefined || observation.screenshot === undefined)
191
+ return progressKeyOf(observation) === referenceKey
192
+ ? { verdict: "unchanged" }
193
+ : { verdict: "settled", area: { x: 0, y: 0, width: 0, height: 0 }, small: false };
194
+ return frames.next(observation.screenshot, atMs);
195
+ }
196
+ /**
197
+ * Each wait's part of a watch that lasted `ms`: the limit is shared among the waits in proportion
198
+ * to their planned lengths, and the watch covers them in order. Later waits get 0.
199
+ */
200
+ function splitWatch(lengths, limitMs, ms) {
201
+ const plannedMs = lengths.reduce((sum, length) => sum + length, 0);
202
+ const starts = [];
203
+ const ends = [];
204
+ let planned = 0;
205
+ for (const length of lengths) {
206
+ starts.push(Math.floor((limitMs * planned) / plannedMs));
207
+ planned += length;
208
+ ends.push(Math.floor((limitMs * planned) / plannedMs));
209
+ }
210
+ const found = ends.findIndex((shareEnd, index) => shareEnd > starts[index] && ms <= shareEnd);
211
+ const endedOn = found === -1 ? lengths.length - 1 : found;
212
+ return {
213
+ ms: lengths.map((_, index) => index < endedOn ? ends[index] - starts[index] : index === endedOn ? ms - starts[index] : 0),
214
+ endedOn,
215
+ };
216
+ }
217
+ /**
218
+ * The next request's note. A clock ending says nothing: the clock text that follows says it. A
219
+ * small change is named by its place, so a participant is not told the screen changed when what
220
+ * changed was a counter or a timestamp. `growsToMs` adds the growth sentence when the next watch
221
+ * doubles.
222
+ */
223
+ function hintFor(ending, growsToMs) {
224
+ const lasted = `Your wait lasted ${duration(ending.ms)}`;
225
+ const grows = growsToMs === undefined
226
+ ? ""
227
+ : ` A wait right after it can last twice as long, at most ${duration(growsToMs)}.`;
228
+ const { seen } = ending;
229
+ switch (ending.wake) {
230
+ case "changed":
231
+ return seen?.verdict === "settled" && seen.small
232
+ ? `${lasted} and ended when a small part of the screen changed and then stayed the same for over a minute, ${place(seen.area)}.${grows}`
233
+ : `${lasted} and ended when the screen changed.`;
234
+ case "speech":
235
+ return `${lasted} and ended when you heard speech.`;
236
+ case "condition":
237
+ return `${lasted} and ended when the page changed.`;
238
+ // A small change seen less than 65 s before the limit has not held still yet, so the hint
239
+ // never says the screen stayed the same.
240
+ case "limit":
241
+ if (seen?.verdict === "changing" && seen.lookSoon)
242
+ return `${lasted}; a large part of the screen was changing at its last check.${grows}`;
243
+ if (seen?.verdict !== "settled" && seen?.held !== undefined)
244
+ return `${lasted}; a small part of the screen changed and stayed the same, ${place(seen.held)}. A small change does not end a wait.${grows}`;
245
+ return `${lasted}; no change on the screen held still long enough to end it.${grows}`;
246
+ case "stalled":
247
+ return `${lasted}; the screen could not be checked during it.`;
248
+ case "clock":
249
+ case "stopped":
250
+ return undefined;
251
+ }
252
+ }
253
+ const place = (area) => `at x ${area.x}, y ${area.y} (${area.width} x ${area.height} pixels)`;
254
+ function duration(ms) {
255
+ if (ms < 1_000)
256
+ return plural(ms, "millisecond");
257
+ if (ms < 120_000)
258
+ return plural(Math.round(ms / 1_000), "second");
259
+ return plural(Math.round(ms / 60_000), "minute");
260
+ }
@@ -14,6 +14,7 @@ import { LoopSession } from "./loop/session.js";
14
14
  import { spendStop, unknownSpendStop } from "./loop/spend.js";
15
15
  import { accountBillingConflicts } from "./loop/usage.js";
16
16
  import { loopResult, notice } from "./loop/trace.js";
17
+ import { IdleWatcher } from "./loop/watch.js";
17
18
  export { describeCuaAction } from "./loop/actions.js";
18
19
  export { stableProgressKey } from "./loop/backstop.js";
19
20
  export { IMPRESSIONS_ASK } from "./loop/impressions.js";
@@ -75,6 +76,7 @@ export async function runComputerUseLoopWithTaps(options) {
75
76
  */
76
77
  async function runTurns(session, conversation) {
77
78
  const observer = new DesktopObserver(session);
79
+ const watcher = new IdleWatcher(session, observer);
78
80
  const opening = await observer.checkpoint(0);
79
81
  if ("stop" in opening)
80
82
  return opening.stop;
@@ -142,9 +144,13 @@ async function runTurns(session, conversation) {
142
144
  ? { ...ended, debriefTrigger: { kind: "participant_end", observation } }
143
145
  : ended;
144
146
  }
145
- const batch = await runActionBatch(session, turn.actions);
147
+ const watched = await watcher.watch(turnNumber, observation, turn.actions);
148
+ const batch = await runActionBatch(session, turn.actions, watched?.waits);
146
149
  conversation.previousExecution = batch.execution;
147
- const checkpoint = await observer.checkpoint(turnNumber);
150
+ // An earlier reply's unknown usage still stops the session before its closing request.
151
+ if (watched?.stop !== undefined)
152
+ return unknownSpendStop(session) ?? watched.stop;
153
+ const checkpoint = await observer.checkpoint(turnNumber, watched?.observation);
148
154
  if ("stop" in checkpoint)
149
155
  return checkpoint.stop;
150
156
  observation = checkpoint.observation;
@@ -153,11 +159,19 @@ async function runTurns(session, conversation) {
153
159
  observation,
154
160
  heardNewSpeech: observer.heardNewSpeech,
155
161
  waitingIsTheTask: heldToScheduledEnd(session),
162
+ screenChanged: watched?.wake === "changed" ||
163
+ watched?.wake === "speech" ||
164
+ watched?.wake === "condition",
156
165
  }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
157
166
  backstop = step.backstop;
158
- const stalled = applyBackstop(session, conversation, step, [checkpoint.hint, batch.hint]);
167
+ const stalled = applyBackstop(session, conversation, step, [
168
+ watched?.hint,
169
+ checkpoint.hint,
170
+ batch.hint,
171
+ ]);
172
+ // A turn that reached the participant's end is that end, never the backstop's.
159
173
  if (stalled !== undefined)
160
- return stalled;
174
+ return clockEndStop(session, observation) ?? stalled;
161
175
  const unknownSpend = unknownSpendStop(session);
162
176
  if (unknownSpend !== undefined)
163
177
  return unknownSpend;
@@ -7,31 +7,54 @@ export declare const CUA_WAIT_LIMITS: Readonly<{
7
7
  */
8
8
  stepMs: 30000;
9
9
  /**
10
- * The longest one wait action lasts when a study does not set actor.maxWaitMs and the desktop
11
- * has no speech (defaultMaxWaitMs). Two minutes covers waiting in a lobby or for an email to
10
+ * The longest one wait action sent to the desktop lasts when a study does not set
11
+ * actor.maxWaitMs and the desktop has no speech (defaultMaxWaitMs). Two minutes covers waiting in a lobby or for an email to
12
12
  * arrive, and the participant still sees a fresh screenshot at least that often.
13
13
  */
14
14
  defaultMaxMs: 120000;
15
+ /**
16
+ * The longest a watch over an unchanged screen grows to, and the longest the named waits of a
17
+ * turn that only waits are watched for together, when a study does not set actor.maxWaitMs. Each further watch
18
+ * in a row that ends with no change lasts twice as long as the one before, so on a static screen
19
+ * a participant settles at one model request per 10 minutes.
20
+ */
21
+ defaultWatchMaxMs: 600000;
15
22
  /**
16
23
  * How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
17
24
  * when a study does not set actor.idleWaitMs. OpenAI's computer-use `wait` names no duration. A
18
25
  * participant told to wait about a minute sent ten in one turn, which lasts 50 s at this length;
19
- * one wait a turn makes a one-minute wait about six turns.
26
+ * one wait a turn makes a one-minute wait about six turns. It is also how often a watch looks.
20
27
  */
21
28
  defaultIdleMs: 5000;
22
29
  /**
23
30
  * How long a wait that names no duration lasts after the participant acts in the same turn, so
24
31
  * the screen can settle before the next screenshot. Nearly every OpenAI computer-use wait follows
25
- * a click or keypress this way, so a longer one would slow every run.
32
+ * a click or keypress this way, so a longer one would slow every run. A watch looks again this
33
+ * soon after a large part of the screen moved.
26
34
  */
27
35
  settleMs: 500;
36
+ /**
37
+ * The longest gap between two looks of a watch on a desktop with speech. Heard speech reaches the
38
+ * participant with a look, and the desktop session ends when more utterances arrive between two
39
+ * observations than it holds; 30 s is the spacing the speech default kept before watches.
40
+ */
41
+ speechLookMs: 30000;
28
42
  /** The smallest actor.maxWaitMs or actor.idleWaitMs a study may set. */
29
43
  leastMs: 1000;
30
- /** The largest actor.maxWaitMs or actor.idleWaitMs a study may set. */
31
- mostMs: 600000;
44
+ /** The largest actor.idleWaitMs a study may set. */
45
+ mostIdleMs: 600000;
46
+ /** The largest actor.maxWaitMs a study may set: a watch may grow to an hour. */
47
+ mostMaxMs: 3600000;
32
48
  }>;
33
- /** The wait lengths one session applies; the trace records them as `waitSettings`. */
34
- export type WaitSettings = Readonly<ActorWaitSettings>;
49
+ /**
50
+ * The wait lengths one session applies. The trace records the first three as `waitSettings`.
51
+ * `watchMaxMs` is the longest a watch grows to over an unchanged screen and the longest a watched
52
+ * turn's named waits last together, and `lookEveryMs` how often a watch looks.
53
+ */
54
+ export type WaitSettings = Readonly<ActorWaitSettings> & {
55
+ readonly watchMaxMs: number;
56
+ readonly lookEveryMs: number;
57
+ };
35
58
  /** One wait as the desktop runs it. */
36
59
  export interface PlannedWait {
37
60
  /** How long the wait lasts. */
@@ -41,13 +64,6 @@ export interface PlannedWait {
41
64
  /** What the participant asked for, when that was longer than maxWaitMs. */
42
65
  readonly shortenedFromMs?: number;
43
66
  }
44
- /**
45
- * The longest one wait action lasts when a study does not set actor.maxWaitMs. On a desktop with
46
- * speech, heard speech reaches the participant only with a screenshot, and the desktop session ends
47
- * when more utterances arrive between two screenshots than it holds. A wait takes no screenshot, so
48
- * there the default keeps one wait to one desktop call.
49
- */
50
- export declare function defaultMaxWaitMs(speechEnabled: boolean): number;
51
67
  /**
52
68
  * Why these wait fields cannot run, or null. `prefix` names their owner in the message: `actor.`
53
69
  * in a study file, empty for loop options. `maxPrefix` names the owner of maxWaitMs when it is
@@ -59,8 +75,9 @@ export declare function waitFieldsReason(fields: {
59
75
  }, prefix: string, maxPrefix?: string): string | null;
60
76
  /**
61
77
  * The wait lengths a session applies: the study's values, or their defaults. The idle wait never
62
- * outlasts the longest wait, which on a desktop with speech defaults to 30 s. Throws a RangeError
63
- * for fields waitFieldsReason refuses.
78
+ * outlasts the longest wait, which on a desktop with speech defaults to 30 s. A watch grows to
79
+ * actor.maxWaitMs, else 10 minutes, and looks every idle wait, at most every 30 s on a desktop with
80
+ * speech. Throws a RangeError for fields waitFieldsReason refuses.
64
81
  */
65
82
  export declare function resolveWaitSettings(options: {
66
83
  readonly maxWaitMs?: number | undefined;
@@ -70,6 +87,7 @@ export declare function resolveWaitSettings(options: {
70
87
  /**
71
88
  * How long one wait lasts and the desktop calls it takes. A wait that names no duration lasts
72
89
  * idleWaitMs when its turn only waits or takes screenshots (`idleTurn`), and settleWaitMs after an
73
- * action. A longer wait than maxWaitMs is shortened to it. 70 s is sent as 30 s, 30 s and 10 s.
90
+ * action. A longer wait than maxWaitMs is shortened to it, and a negative one lasts 0 ms. 70 s is
91
+ * sent as 30 s, 30 s and 10 s.
74
92
  */
75
93
  export declare function planWait(settings: WaitSettings, requestedMs: number | undefined, idleTurn: boolean): PlannedWait;
@@ -1,7 +1,9 @@
1
1
  // How long one participant wait action lasts, for every provider and desktop. A study sets the
2
2
  // longest (actor.maxWaitMs) and how long a wait that names no duration lasts while the participant
3
- // only waits (actor.idleWaitMs). The loop asks planWait for each wait and sends it to the desktop
4
- // in steps one desktop call carries.
3
+ // only waits (actor.idleWaitMs). The loop asks planWait for each wait. A turn with another action
4
+ // sends its waits to the desktop in steps one desktop call carries; a turn that only waits watches
5
+ // the screen instead (loop/watch.ts), for its waits' lengths, the named ones together up to
6
+ // watchMaxMs, or longer as the watches of idle turns in a row grow.
5
7
  export const CUA_WAIT_LIMITS = Object.freeze({
6
8
  /**
7
9
  * The longest wait one desktop call is given. A browser-control request must be answered within
@@ -10,35 +12,51 @@ export const CUA_WAIT_LIMITS = Object.freeze({
10
12
  */
11
13
  stepMs: 30_000,
12
14
  /**
13
- * The longest one wait action lasts when a study does not set actor.maxWaitMs and the desktop
14
- * has no speech (defaultMaxWaitMs). Two minutes covers waiting in a lobby or for an email to
15
+ * The longest one wait action sent to the desktop lasts when a study does not set
16
+ * actor.maxWaitMs and the desktop has no speech (defaultMaxWaitMs). Two minutes covers waiting in a lobby or for an email to
15
17
  * arrive, and the participant still sees a fresh screenshot at least that often.
16
18
  */
17
19
  defaultMaxMs: 120_000,
20
+ /**
21
+ * The longest a watch over an unchanged screen grows to, and the longest the named waits of a
22
+ * turn that only waits are watched for together, when a study does not set actor.maxWaitMs. Each further watch
23
+ * in a row that ends with no change lasts twice as long as the one before, so on a static screen
24
+ * a participant settles at one model request per 10 minutes.
25
+ */
26
+ defaultWatchMaxMs: 600_000,
18
27
  /**
19
28
  * How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
20
29
  * when a study does not set actor.idleWaitMs. OpenAI's computer-use `wait` names no duration. A
21
30
  * participant told to wait about a minute sent ten in one turn, which lasts 50 s at this length;
22
- * one wait a turn makes a one-minute wait about six turns.
31
+ * one wait a turn makes a one-minute wait about six turns. It is also how often a watch looks.
23
32
  */
24
33
  defaultIdleMs: 5_000,
25
34
  /**
26
35
  * How long a wait that names no duration lasts after the participant acts in the same turn, so
27
36
  * the screen can settle before the next screenshot. Nearly every OpenAI computer-use wait follows
28
- * a click or keypress this way, so a longer one would slow every run.
37
+ * a click or keypress this way, so a longer one would slow every run. A watch looks again this
38
+ * soon after a large part of the screen moved.
29
39
  */
30
40
  settleMs: 500,
41
+ /**
42
+ * The longest gap between two looks of a watch on a desktop with speech. Heard speech reaches the
43
+ * participant with a look, and the desktop session ends when more utterances arrive between two
44
+ * observations than it holds; 30 s is the spacing the speech default kept before watches.
45
+ */
46
+ speechLookMs: 30_000,
31
47
  /** The smallest actor.maxWaitMs or actor.idleWaitMs a study may set. */
32
48
  leastMs: 1_000,
33
- /** The largest actor.maxWaitMs or actor.idleWaitMs a study may set. */
34
- mostMs: 600_000,
49
+ /** The largest actor.idleWaitMs a study may set. */
50
+ mostIdleMs: 600_000,
51
+ /** The largest actor.maxWaitMs a study may set: a watch may grow to an hour. */
52
+ mostMaxMs: 3_600_000,
35
53
  });
36
- /** A whole number of milliseconds within the range actor.maxWaitMs and actor.idleWaitMs accept. */
37
- function isWaitMs(value) {
54
+ /** A whole number of milliseconds from CUA_WAIT_LIMITS.leastMs to `mostMs`. */
55
+ function isWaitMs(value, mostMs) {
38
56
  return (typeof value === "number" &&
39
57
  Number.isSafeInteger(value) &&
40
58
  value >= CUA_WAIT_LIMITS.leastMs &&
41
- value <= CUA_WAIT_LIMITS.mostMs);
59
+ value <= mostMs);
42
60
  }
43
61
  /**
44
62
  * The longest one wait action lasts when a study does not set actor.maxWaitMs. On a desktop with
@@ -46,12 +64,16 @@ function isWaitMs(value) {
46
64
  * when more utterances arrive between two screenshots than it holds. A wait takes no screenshot, so
47
65
  * there the default keeps one wait to one desktop call.
48
66
  */
49
- export function defaultMaxWaitMs(speechEnabled) {
67
+ function defaultMaxWaitMs(speechEnabled) {
50
68
  return speechEnabled ? CUA_WAIT_LIMITS.stepMs : CUA_WAIT_LIMITS.defaultMaxMs;
51
69
  }
52
70
  const FIELDS = [
53
- ["maxWaitMs", "the longest one wait action lasts"],
54
- ["idleWaitMs", "how long a wait with no duration lasts while the participant only waits"],
71
+ ["maxWaitMs", "the longest one wait action lasts", CUA_WAIT_LIMITS.mostMaxMs],
72
+ [
73
+ "idleWaitMs",
74
+ "how long a wait with no duration lasts while the participant only waits",
75
+ CUA_WAIT_LIMITS.mostIdleMs,
76
+ ],
55
77
  ];
56
78
  /**
57
79
  * Why these wait fields cannot run, or null. `prefix` names their owner in the message: `actor.`
@@ -59,9 +81,9 @@ const FIELDS = [
59
81
  * another, as for a participant's own idleWaitMs under actor.maxWaitMs.
60
82
  */
61
83
  export function waitFieldsReason(fields, prefix, maxPrefix = prefix) {
62
- for (const [name, meaning] of FIELDS) {
63
- if (fields[name] !== undefined && !isWaitMs(fields[name]))
64
- return `${name === "maxWaitMs" ? maxPrefix : prefix}${name}, ${meaning}, must be a whole number of milliseconds from ${CUA_WAIT_LIMITS.leastMs} to ${CUA_WAIT_LIMITS.mostMs}.`;
84
+ for (const [name, meaning, mostMs] of FIELDS) {
85
+ if (fields[name] !== undefined && !isWaitMs(fields[name], mostMs))
86
+ return `${name === "maxWaitMs" ? maxPrefix : prefix}${name}, ${meaning}, must be a whole number of milliseconds from ${CUA_WAIT_LIMITS.leastMs} to ${mostMs}.`;
65
87
  }
66
88
  const { maxWaitMs, idleWaitMs } = fields;
67
89
  if (typeof maxWaitMs === "number" && typeof idleWaitMs === "number" && idleWaitMs > maxWaitMs)
@@ -70,27 +92,34 @@ export function waitFieldsReason(fields, prefix, maxPrefix = prefix) {
70
92
  }
71
93
  /**
72
94
  * The wait lengths a session applies: the study's values, or their defaults. The idle wait never
73
- * outlasts the longest wait, which on a desktop with speech defaults to 30 s. Throws a RangeError
74
- * for fields waitFieldsReason refuses.
95
+ * outlasts the longest wait, which on a desktop with speech defaults to 30 s. A watch grows to
96
+ * actor.maxWaitMs, else 10 minutes, and looks every idle wait, at most every 30 s on a desktop with
97
+ * speech. Throws a RangeError for fields waitFieldsReason refuses.
75
98
  */
76
99
  export function resolveWaitSettings(options) {
77
100
  const reason = waitFieldsReason(options, "");
78
101
  if (reason !== null)
79
102
  throw new RangeError(reason);
80
103
  const maxWaitMs = options.maxWaitMs ?? defaultMaxWaitMs(options.speechEnabled);
104
+ const idleWaitMs = Math.min(options.idleWaitMs ?? CUA_WAIT_LIMITS.defaultIdleMs, maxWaitMs);
81
105
  return {
82
106
  maxWaitMs,
83
- idleWaitMs: Math.min(options.idleWaitMs ?? CUA_WAIT_LIMITS.defaultIdleMs, maxWaitMs),
107
+ idleWaitMs,
84
108
  settleWaitMs: CUA_WAIT_LIMITS.settleMs,
109
+ watchMaxMs: options.maxWaitMs ?? CUA_WAIT_LIMITS.defaultWatchMaxMs,
110
+ lookEveryMs: options.speechEnabled
111
+ ? Math.min(idleWaitMs, CUA_WAIT_LIMITS.speechLookMs)
112
+ : idleWaitMs,
85
113
  };
86
114
  }
87
115
  /**
88
116
  * How long one wait lasts and the desktop calls it takes. A wait that names no duration lasts
89
117
  * idleWaitMs when its turn only waits or takes screenshots (`idleTurn`), and settleWaitMs after an
90
- * action. A longer wait than maxWaitMs is shortened to it. 70 s is sent as 30 s, 30 s and 10 s.
118
+ * action. A longer wait than maxWaitMs is shortened to it, and a negative one lasts 0 ms. 70 s is
119
+ * sent as 30 s, 30 s and 10 s.
91
120
  */
92
121
  export function planWait(settings, requestedMs, idleTurn) {
93
- const askedMs = requestedMs ?? (idleTurn ? settings.idleWaitMs : settings.settleWaitMs);
122
+ const askedMs = Math.max(0, requestedMs ?? (idleTurn ? settings.idleWaitMs : settings.settleWaitMs));
94
123
  const ms = Math.min(askedMs, settings.maxWaitMs);
95
124
  const { stepMs } = CUA_WAIT_LIMITS;
96
125
  const steps = [];
@@ -87,6 +87,40 @@ export interface ActorTraceItem {
87
87
  /** On a computer-use `screenshot` item: the page that observation was on. Additive: absent when
88
88
  * the observation read no URL, on other producers and in older bundles. */
89
89
  page?: TracePage;
90
+ /** On the computer-use `ui_action` of the wait during which a watch ended: the watch the loop
91
+ * ran in place of a turn that only waited. Additive: absent on plain waits, on other items and
92
+ * producers, and in older bundles. */
93
+ watch?: ActorWatch;
94
+ }
95
+ /**
96
+ * A watch over the screen while a computer-use participant only waited: the loop looked every
97
+ * idle wait and asked the model again when the screen changed and held still, or at the limit.
98
+ */
99
+ export interface ActorWatch {
100
+ /** How long the watch could last, in milliseconds. */
101
+ limitMs: number;
102
+ /**
103
+ * How long the turn's waits asked for, in milliseconds: the lengths they named, and the idle
104
+ * wait for each that named none. Above limitMs when the named waits together passed the longest
105
+ * a watch grows to (actor.maxWaitMs, else 10 minutes).
106
+ */
107
+ askedMs: number;
108
+ /**
109
+ * Why it ended: `changed` (a change held still), `speech` (the participant heard speech),
110
+ * `condition` (a study stop condition, dwell trigger or task matched a look), `limit` (its time
111
+ * passed with no change holding still), `clock` (the participant's wind-down or end arrived),
112
+ * `stalled` (a look got no frame), `stopped` (the study budget or the session's time ran out).
113
+ */
114
+ wake: "changed" | "speech" | "condition" | "limit" | "clock" | "stalled" | "stopped";
115
+ /** How many times it looked at the screen. */
116
+ looks: number;
117
+ /** The region of change that woke it, in pixels from the frame's top-left; coordinates only. */
118
+ area?: {
119
+ x: number;
120
+ y: number;
121
+ width: number;
122
+ height: number;
123
+ };
90
124
  }
91
125
  export interface ActorCapabilities {
92
126
  headless: boolean;
@@ -190,6 +190,11 @@ export function promptFor(request, screenshotPath) {
190
190
  "giving up, and put your closing words in message. When done=true, set outcome: reached if " +
191
191
  "the task is finished, blocked if something in the app stopped you, not_reached if you are " +
192
192
  "stopping for another reason. Otherwise outcome is null.",
193
+ "A turn whose actions are only waits ends when the screen changes and holds still, or when its " +
194
+ "waits' ms pass. While no change on the screen holds still, each further turn in a row that " +
195
+ "only waits lasts twice as long as the one before, up to the study's longest wait, and the note " +
196
+ "from the harness says how long you waited and why it ended. A wait among other actions lasts " +
197
+ "its ms.",
193
198
  `Reply with ONLY a JSON object of this shape: ${shape}`,
194
199
  hint,
195
200
  ].join("\n");
@@ -0,0 +1,39 @@
1
+ /** A rectangle of the frame, in pixels from its top-left corner. */
2
+ export interface FrameRegion {
3
+ readonly x: number;
4
+ readonly y: number;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ }
8
+ /**
9
+ * What one look found, compared with the frame the participant last saw. `settled`: a region of
10
+ * change held still long enough to wake the participant; `area` bounds it, and `small` is true
11
+ * when it is at most LARGE_REGION_TILES tiles, which the participant's hint names. `changing`: tiles moved
12
+ * since the previous look and nothing settled; `lookSoon` when more than LARGE_REGION_TILES moved,
13
+ * so a large change is confirmed by a quick look. `unchanged`: nothing moved and nothing settled.
14
+ * `held` on the last two bounds a small region that held still past the hold and is held back by a
15
+ * ticking tile or a large moving region near it.
16
+ */
17
+ export type FrameVerdict = {
18
+ readonly verdict: "unchanged";
19
+ readonly held?: FrameRegion;
20
+ } | {
21
+ readonly verdict: "changing";
22
+ readonly lookSoon: boolean;
23
+ readonly held?: FrameRegion;
24
+ } | {
25
+ readonly verdict: "settled";
26
+ readonly area: FrameRegion;
27
+ readonly small: boolean;
28
+ };
29
+ /**
30
+ * Compare later looks at one screen with `reference`, the frame the participant last saw. Call
31
+ * `next` once per look, in order, with the look's time. Each tile that differs from the reference
32
+ * keeps the time it last moved, so a moving tile (a spinner) never joins a still region beside
33
+ * it. Nothing is masked: a large change that stops changing always settles, and a small one does
34
+ * unless a tile near it keeps ticking. Stillness is sampled at looks, so a blink seen in the same
35
+ * phase at every look reads as still.
36
+ */
37
+ export declare function watchFrames(reference: Buffer): {
38
+ next(frame: Buffer, atMs: number): FrameVerdict;
39
+ };