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.
- package/CHANGELOG.md +16 -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 +51 -22
- 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/study/plan-participants.d.ts +1 -1
- package/dist/study/types.d.ts +11 -7
- package/dist/study/warnings.js +1 -1
- 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
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.131.0: Participants that wait watch the screen and wake when it changes (2026-10-13)
|
|
12
|
+
|
|
13
|
+
humanish 0.131.0 watches the screen while a computer-use or shared-world participant waits. A
|
|
14
|
+
turn of only waits sends no wait to the desktop: humanish looks every `idleWaitMs` and asks the
|
|
15
|
+
model for the next turn when a change holds still, speech is heard, a condition matches, the
|
|
16
|
+
participant's end arrives or the wait's time passes, and idle turns in a row grow toward
|
|
17
|
+
`actor.maxWaitMs`. A clock, a spinner or a "2 minutes ago" label ends no wait. In a live hour on a
|
|
18
|
+
support queue, new rows reached the participant a median of 7 seconds after they appeared, against
|
|
19
|
+
30 seconds on 0.129.0, and an OpenAI participant made 50 requests where 0.129.0 made 43. Codex
|
|
20
|
+
and Claude Code waits last the length they ask for, up to 10 minutes. This is a minor release with
|
|
21
|
+
breaking changes: `idleWaitMs` now sets the time between looks, `actor.maxWaitMs` defaults to 10
|
|
22
|
+
minutes for idle turns and accepts up to an hour, and a turn of only waits sends no `wait` action
|
|
23
|
+
to a library caller's executor. The migration table says how to keep the old pacing.
|
|
24
|
+
|
|
25
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.131.0)
|
|
26
|
+
|
|
11
27
|
## 0.130.0: Analysis reads traces up to 32 MiB one at a time and names what it refused; the bundle estimate counts the mission (2026-10-13)
|
|
12
28
|
|
|
13
29
|
humanish 0.130.0 lets analysis read long runs that 0.129.0 refused. Analysis reads each
|
|
@@ -18,8 +18,8 @@ export interface RestrictedParticipantOptions {
|
|
|
18
18
|
/** Admit speech actions and heard-speaker evidence for a speech-capable desktop only. */
|
|
19
19
|
speechEnabled?: boolean;
|
|
20
20
|
/**
|
|
21
|
-
* The study's actor.maxWaitMs, which the loop applies and the tool description states
|
|
22
|
-
*
|
|
21
|
+
* The study's actor.maxWaitMs, which the loop applies and the tool description states with the
|
|
22
|
+
* defaults the loop applies without it (resolveWaitSettings).
|
|
23
23
|
*/
|
|
24
24
|
maxWaitMs?: number;
|
|
25
25
|
}
|
|
@@ -3,7 +3,7 @@ import { defaultCodexCliVersion, untestedOperatorReleaseWarning } from "./codex-
|
|
|
3
3
|
import { IMPRESSIONS_ASK, } from "../computer-use/loop.js";
|
|
4
4
|
import { ComputerUseProviderError, isComputerUseProviderError, } from "../computer-use/provider-error.js";
|
|
5
5
|
import { validateBrowserControlPng, validateHeardSpeech } from "../../browser-control/protocol.js";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveWaitSettings } from "../computer-use/wait.js";
|
|
7
7
|
import { createRestrictedCodexSession, } from "./restricted-session.js";
|
|
8
8
|
import { PARTICIPANT_PROFILE, PARTICIPANT_LIMITS as L, PARTICIPANT_FINAL_SCHEMA, participantToolSchema, parseParticipantTool, parseParticipantFinal, } from "./restricted-participant-policy.js";
|
|
9
9
|
import { truncatedFrameWarning, unknownNotificationsWarning } from "./restricted-notifications.js";
|
|
@@ -41,7 +41,7 @@ function deferred() {
|
|
|
41
41
|
return { promise, resolve, reject };
|
|
42
42
|
}
|
|
43
43
|
// prose-check: model prompt (Codex reads this tool description, not a person)
|
|
44
|
-
const toolDescription = (speechEnabled, maxWaitMs) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A
|
|
44
|
+
const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits returns when the screen changes and holds still${speechEnabled ? ", when speech is heard" : ""}, or after its waits' ms, together at most ${watchMaxMs} ms. While no change on the screen holds still, each further such call in a row waits twice as long as the one before, up to ${watchMaxMs} ms, and contextHint says how long it waited and why it returned. A wait among other actions lasts its ms, at most ${maxWaitMs} ms.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
|
|
45
45
|
const participantInstructions = (prompt, speechEnabled) => `${prompt}\n\nYou are the study participant throughout this conversation, including its closing account. Use only the supplied screenshots and humanish_ui tool to interact. The tool returns a JSON string: parse it, inspect acknowledgments${speechEnabled ? " and heardSpeech captured from the actual participant speaker sink" : ""}, and display imageUrl with Code Mode image(). Do not print the image data URL as text. Keep your persona and earlier observations throughout the session.${speechEnabled ? " A speak action plays into the participant microphone; use it only after the visible UI shows that you joined the call and the microphone is unmuted." : ""} Speak publicly about your experience, never reveal private reasoning. Completed inputs do not prove application outcomes; verify on the next screenshot. When the task ends, return only the required final JSON with outcome, summary, frictionReports and impressions. Report observed confusion and recovered mistakes as well as blockers. ${IMPRESSIONS_ASK} Do not invent observations.`;
|
|
46
46
|
/** Turns and failures from the native run, handed in order to the next provider request. */
|
|
47
47
|
class ParticipantEvents {
|
|
@@ -192,7 +192,7 @@ function participantSession(options, speechEnabled, effort, call) {
|
|
|
192
192
|
reasoningEffort: effort,
|
|
193
193
|
tool: {
|
|
194
194
|
name: "humanish_ui",
|
|
195
|
-
description: toolDescription(speechEnabled, options.maxWaitMs
|
|
195
|
+
description: toolDescription(speechEnabled, resolveWaitSettings({ maxWaitMs: options.maxWaitMs, speechEnabled })),
|
|
196
196
|
inputSchema: participantToolSchema(speechEnabled),
|
|
197
197
|
call,
|
|
198
198
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ActorWatch } from "../../contract.js";
|
|
1
2
|
import type { LoopSession } from "./session.js";
|
|
2
3
|
import type { CuaAction, CuaTurnRequest } from "./types.js";
|
|
3
4
|
/** A public-safe one-line action label. Never includes raw typed text. */
|
|
@@ -21,9 +22,23 @@ export interface ActionBatch {
|
|
|
21
22
|
*/
|
|
22
23
|
readonly hint: string | undefined;
|
|
23
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The waits of a turn the loop watched in place of sending them to the desktop (watch.ts): the
|
|
27
|
+
* part of the watch each wait covered, in order, and the `watch` field of the wait during which
|
|
28
|
+
* the watch ended.
|
|
29
|
+
*/
|
|
30
|
+
export interface WatchedWaits {
|
|
31
|
+
readonly ms: readonly number[];
|
|
32
|
+
readonly endedOn: number;
|
|
33
|
+
readonly watch: ActorWatch;
|
|
34
|
+
/** The session ended during the watch, so the turn's screenshot actions are not sent. */
|
|
35
|
+
readonly sessionEnded: boolean;
|
|
36
|
+
}
|
|
24
37
|
/**
|
|
25
38
|
* Dispatch one turn's actions in order. A declared pre-dispatch rejection stops the batch and
|
|
26
39
|
* leaves the desktop usable; a failed desktop command skips its action; any other failure ends
|
|
27
|
-
* the session.
|
|
40
|
+
* the session. The waits of a watched turn (`watched`) already ran: each is recorded with the part
|
|
41
|
+
* of the watch it covered and nothing is sent for it. When that watch ended the session, the
|
|
42
|
+
* turn's screenshots are acknowledged as not dispatched and not recorded.
|
|
28
43
|
*/
|
|
29
|
-
export declare function runActionBatch(session: LoopSession, actions: readonly CuaAction[]): Promise<ActionBatch>;
|
|
44
|
+
export declare function runActionBatch(session: LoopSession, actions: readonly CuaAction[], watched?: WatchedWaits): Promise<ActionBatch>;
|
|
@@ -119,19 +119,34 @@ function recordShortenedWait(session, title, requestedMs) {
|
|
|
119
119
|
/**
|
|
120
120
|
* Dispatch one turn's actions in order. A declared pre-dispatch rejection stops the batch and
|
|
121
121
|
* leaves the desktop usable; a failed desktop command skips its action; any other failure ends
|
|
122
|
-
* the session.
|
|
122
|
+
* the session. The waits of a watched turn (`watched`) already ran: each is recorded with the part
|
|
123
|
+
* of the watch it covered and nothing is sent for it. When that watch ended the session, the
|
|
124
|
+
* turn's screenshots are acknowledged as not dispatched and not recorded.
|
|
123
125
|
*/
|
|
124
|
-
export async function runActionBatch(session, actions) {
|
|
126
|
+
export async function runActionBatch(session, actions, watched) {
|
|
125
127
|
const execution = { actions: [] };
|
|
126
128
|
const { actionHistory, trace } = session;
|
|
127
129
|
const notes = [];
|
|
128
130
|
const hint = () => (notes.length === 0 ? undefined : notes.join(" "));
|
|
129
131
|
const idleTurn = isIdleTurn(actions);
|
|
132
|
+
let waits = 0;
|
|
130
133
|
for (const [index, requested] of actions.entries()) {
|
|
131
134
|
if (session.signal?.aborted)
|
|
132
135
|
throw new CuaAbortError();
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
if (watched?.sessionEnded === true && requested.kind !== "wait") {
|
|
137
|
+
execution.actions.push({ index, status: "not_dispatched" });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const waitIndex = requested.kind === "wait" ? waits++ : undefined;
|
|
141
|
+
const watchedMs = waitIndex === undefined ? undefined : watched?.ms[waitIndex];
|
|
142
|
+
const wait = requested.kind === "wait" && watchedMs === undefined
|
|
143
|
+
? planWait(session.waits, requested.ms, idleTurn)
|
|
144
|
+
: undefined;
|
|
145
|
+
const action = watchedMs !== undefined
|
|
146
|
+
? { kind: "wait", ms: watchedMs }
|
|
147
|
+
: wait === undefined
|
|
148
|
+
? requested
|
|
149
|
+
: { kind: "wait", ms: wait.ms };
|
|
135
150
|
const title = describeCuaAction(action);
|
|
136
151
|
if (wait?.shortenedFromMs !== undefined)
|
|
137
152
|
notes.push(recordShortenedWait(session, title, wait.shortenedFromMs));
|
|
@@ -147,7 +162,10 @@ export async function runActionBatch(session, actions) {
|
|
|
147
162
|
session.phase = `executing ${title}`;
|
|
148
163
|
let status;
|
|
149
164
|
try {
|
|
150
|
-
status =
|
|
165
|
+
status =
|
|
166
|
+
watchedMs === undefined
|
|
167
|
+
? await dispatchAction(session, action, title, wait?.steps)
|
|
168
|
+
: "completed";
|
|
151
169
|
}
|
|
152
170
|
catch (error) {
|
|
153
171
|
const declared = isComputerUseExecutorError(error)
|
|
@@ -190,6 +208,7 @@ export async function runActionBatch(session, actions) {
|
|
|
190
208
|
...(action.kind === "click" || action.kind === "double_click"
|
|
191
209
|
? { coord: { x: action.x, y: action.y } }
|
|
192
210
|
: {}),
|
|
211
|
+
...(watched !== undefined && waitIndex === watched.endedOn ? { watch: watched.watch } : {}),
|
|
193
212
|
}));
|
|
194
213
|
}
|
|
195
214
|
return { execution, hint: hint() };
|
|
@@ -12,6 +12,12 @@ import type { CuaAction, CuaObservation } from "./types.js";
|
|
|
12
12
|
* mutates the input. (See docs/architecture/state-driven-executor.md.)
|
|
13
13
|
*/
|
|
14
14
|
export declare function stableProgressKey(appState: Record<string, unknown>): string;
|
|
15
|
+
/**
|
|
16
|
+
* The friction progress key: a stable projection of appState when present, else stateSignature.
|
|
17
|
+
* A state executor with a constant signature still registers progress when its appState changes,
|
|
18
|
+
* and a vision executor behaves exactly as before.
|
|
19
|
+
*/
|
|
20
|
+
export declare function progressKeyOf(observation: CuaObservation): string;
|
|
15
21
|
export interface Backstop {
|
|
16
22
|
readonly progressKey: string;
|
|
17
23
|
/** The last few turns' action fingerprints, in memory only, for the corroboration rule below. */
|
|
@@ -37,11 +43,13 @@ export interface BackstopStep {
|
|
|
37
43
|
/**
|
|
38
44
|
* Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
|
|
39
45
|
* participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
|
|
40
|
-
* toward neither streak and earns no nudge. A turn that acts is folded as usual.
|
|
46
|
+
* toward neither streak and earns no nudge. A turn that acts is folded as usual. `screenChanged`
|
|
47
|
+
* says the turn's watch woke on a change it saw, which the progress key can miss.
|
|
41
48
|
*/
|
|
42
49
|
export declare function advanceBackstop(previous: Backstop, turn: {
|
|
43
50
|
actions: readonly CuaAction[];
|
|
44
51
|
observation: CuaObservation;
|
|
45
52
|
heardNewSpeech: boolean;
|
|
46
53
|
waitingIsTheTask?: boolean;
|
|
54
|
+
screenChanged?: boolean;
|
|
47
55
|
}, limits: BackstopLimits): BackstopStep;
|
|
@@ -103,7 +103,7 @@ export function stableProgressKey(appState) {
|
|
|
103
103
|
* A state executor with a constant signature still registers progress when its appState changes,
|
|
104
104
|
* and a vision executor behaves exactly as before.
|
|
105
105
|
*/
|
|
106
|
-
function progressKeyOf(observation) {
|
|
106
|
+
export function progressKeyOf(observation) {
|
|
107
107
|
const base = observation.appState !== undefined
|
|
108
108
|
? stableProgressKey(observation.appState)
|
|
109
109
|
: observation.stateSignature;
|
|
@@ -125,7 +125,8 @@ export function startBackstop(observation) {
|
|
|
125
125
|
/**
|
|
126
126
|
* Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
|
|
127
127
|
* participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
|
|
128
|
-
* toward neither streak and earns no nudge. A turn that acts is folded as usual.
|
|
128
|
+
* toward neither streak and earns no nudge. A turn that acts is folded as usual. `screenChanged`
|
|
129
|
+
* says the turn's watch woke on a change it saw, which the progress key can miss.
|
|
129
130
|
*/
|
|
130
131
|
export function advanceBackstop(previous, turn, limits) {
|
|
131
132
|
const idle = isIdleTurn(turn.actions);
|
|
@@ -157,7 +158,7 @@ export function advanceBackstop(previous, turn, limits) {
|
|
|
157
158
|
// behavioral signal already (the agent took nothing but screenshots and waits), so it keeps
|
|
158
159
|
// reading the frame on its own: a repeated screenshot is exactly what an idle streak is, and
|
|
159
160
|
// feeding repetition into it would grant an extra forgiveness step for being idle.
|
|
160
|
-
const observedProgress = frameChanged || turn.heardNewSpeech;
|
|
161
|
+
const observedProgress = frameChanged || turn.heardNewSpeech || turn.screenChanged === true;
|
|
161
162
|
const progressed = observedProgress || !repeatingRecentAction;
|
|
162
163
|
// A screenshot/wait turn while the UI visibly changes may be patience through loading or a
|
|
163
164
|
// transition, so grant a bounded recovery window. Do not grant infinite immunity: animated
|
|
@@ -14,8 +14,8 @@ export declare function clockHint(clock: ParticipantClock | undefined, now: () =
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function heldToScheduledEnd(session: LoopSession): boolean;
|
|
16
16
|
/**
|
|
17
|
-
* The stop at the participant's end, or undefined before
|
|
18
|
-
* which is the stop cause. The session passes as
|
|
19
|
-
* its closing report on the last observation.
|
|
17
|
+
* The stop at the participant's end, or undefined before `nowMs`, by default the session's clock,
|
|
18
|
+
* reaches it: its scheduled end or its session cap, which is the stop cause. The session passes as
|
|
19
|
+
* a planned end and the participant is asked for its closing report on the last observation.
|
|
20
20
|
*/
|
|
21
|
-
export declare function clockEndStop(session: LoopSession, observation: CuaObservation): Stop | undefined;
|
|
21
|
+
export declare function clockEndStop(session: LoopSession, observation: CuaObservation, nowMs?: number): Stop | undefined;
|
|
@@ -37,13 +37,13 @@ export function heldToScheduledEnd(session) {
|
|
|
37
37
|
return end !== undefined && end.kind !== "session_cap" && session.now() < end.atMs;
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
|
-
* The stop at the participant's end, or undefined before
|
|
41
|
-
* which is the stop cause. The session passes as
|
|
42
|
-
* its closing report on the last observation.
|
|
40
|
+
* The stop at the participant's end, or undefined before `nowMs`, by default the session's clock,
|
|
41
|
+
* reaches it: its scheduled end or its session cap, which is the stop cause. The session passes as
|
|
42
|
+
* a planned end and the participant is asked for its closing report on the last observation.
|
|
43
43
|
*/
|
|
44
|
-
export function clockEndStop(session, observation) {
|
|
44
|
+
export function clockEndStop(session, observation, nowMs) {
|
|
45
45
|
const end = session.settings.clock?.end;
|
|
46
|
-
if (end === undefined || session.now() < end.atMs)
|
|
46
|
+
if (end === undefined || (nowMs ?? session.now()) < end.atMs)
|
|
47
47
|
return undefined;
|
|
48
48
|
const at = endTime(session.settings.clock, end.atMs);
|
|
49
49
|
const kind = end.kind ?? "scheduled_end";
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type Stop } from "./ending.js";
|
|
2
2
|
import type { LoopSession } from "./session.js";
|
|
3
3
|
import type { CuaObservation } from "./types.js";
|
|
4
|
+
/** What one look during a watch found (watch.ts). */
|
|
5
|
+
export interface Look {
|
|
6
|
+
readonly observation: CuaObservation;
|
|
7
|
+
/** The look brought speech the participant had not heard. */
|
|
8
|
+
readonly speech: boolean;
|
|
9
|
+
/** A stopWhen rule, a pending dwell trigger or a newly completed task matched the look. */
|
|
10
|
+
readonly condition: boolean;
|
|
11
|
+
}
|
|
4
12
|
/** What a checkpoint found: a stop, or the observation the next turn reacts to. */
|
|
5
13
|
export type Checkpoint = {
|
|
6
14
|
readonly stop: Stop;
|
|
@@ -18,15 +26,26 @@ export declare class DesktopObserver {
|
|
|
18
26
|
constructor(session: LoopSession);
|
|
19
27
|
/**
|
|
20
28
|
* Observe, persist the frame, advance the task funnel, run a due dwell window, and check the
|
|
21
|
-
* harness stop conditions. Turn 0 is the initial observation, before any model turn.
|
|
29
|
+
* harness stop conditions. Turn 0 is the initial observation, before any model turn. `looked` is
|
|
30
|
+
* the look that ended a watch: it stands in for the observation, so the frame that woke the
|
|
31
|
+
* participant is the one it sees, and its URL and app state were already handed on at the look.
|
|
22
32
|
*/
|
|
23
|
-
checkpoint(turnNumber: number): Promise<Checkpoint>;
|
|
33
|
+
checkpoint(turnNumber: number, looked?: CuaObservation): Promise<Checkpoint>;
|
|
24
34
|
/**
|
|
25
35
|
* Hand an observation the next turn may react to to the runtime hooks, and note whether it
|
|
26
36
|
* carried app state. Runtime-only: the participant's live location.href goes back to the
|
|
27
37
|
* orchestrator and is never persisted.
|
|
28
38
|
*/
|
|
29
39
|
private handToHooks;
|
|
40
|
+
private handUrlOn;
|
|
41
|
+
private handFrameToHook;
|
|
42
|
+
/**
|
|
43
|
+
* One look at the screen while the participant waits. Speech it brings is kept for the next
|
|
44
|
+
* request, its URL goes to the URL tap (the shared-world lobby latch) and a task it completes is
|
|
45
|
+
* recorded. Detects, without acting on, the conditions the checkpoint acts on; the watch that
|
|
46
|
+
* asked hands a look that ends it to the checkpoint. Nothing is persisted.
|
|
47
|
+
*/
|
|
48
|
+
look(turnNumber: number): Promise<Look>;
|
|
30
49
|
/**
|
|
31
50
|
* Every observation that carried app state counts, including dwell frames and the final task
|
|
32
51
|
* observation, which no turn receives: they feed the task funnel, and the trace notes must say
|
|
@@ -28,9 +28,11 @@ export class DesktopObserver {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Observe, persist the frame, advance the task funnel, run a due dwell window, and check the
|
|
31
|
-
* harness stop conditions. Turn 0 is the initial observation, before any model turn.
|
|
31
|
+
* harness stop conditions. Turn 0 is the initial observation, before any model turn. `looked` is
|
|
32
|
+
* the look that ended a watch: it stands in for the observation, so the frame that woke the
|
|
33
|
+
* participant is the one it sees, and its URL and app state were already handed on at the look.
|
|
32
34
|
*/
|
|
33
|
-
async checkpoint(turnNumber) {
|
|
35
|
+
async checkpoint(turnNumber, looked) {
|
|
34
36
|
const { session } = this;
|
|
35
37
|
const { stopWhen } = session.settings;
|
|
36
38
|
const opening = turnNumber === 0;
|
|
@@ -41,7 +43,9 @@ export class DesktopObserver {
|
|
|
41
43
|
throw new CuaAbortError();
|
|
42
44
|
session.phase = `observing UI state after turn ${turnNumber}`;
|
|
43
45
|
}
|
|
44
|
-
let observation =
|
|
46
|
+
let observation = looked === undefined
|
|
47
|
+
? this.handToHooks(this.withHeardSpeech(await this.observeBounded(opening ? "initial" : `after turn ${turnNumber}`)))
|
|
48
|
+
: this.handFrameToHook(looked);
|
|
45
49
|
// Fail closed before a vision provider is asked to reason over a missing frame.
|
|
46
50
|
const frameless = missingFrame(session.provider, observation);
|
|
47
51
|
if (frameless !== undefined)
|
|
@@ -78,13 +82,48 @@ export class DesktopObserver {
|
|
|
78
82
|
* orchestrator and is never persisted.
|
|
79
83
|
*/
|
|
80
84
|
handToHooks(observation) {
|
|
81
|
-
|
|
85
|
+
this.handUrlOn(observation);
|
|
86
|
+
return this.handFrameToHook(observation);
|
|
87
|
+
}
|
|
88
|
+
handUrlOn(observation) {
|
|
89
|
+
const { onObservedUrl } = this.session.settings;
|
|
82
90
|
onObservedUrl?.(observation.url);
|
|
91
|
+
this.noteAppState(observation);
|
|
92
|
+
}
|
|
93
|
+
// The frame tap reads the lobby code with a model call outside the study budget, so it gets only
|
|
94
|
+
// frames a turn receives, never every look of a watch.
|
|
95
|
+
handFrameToHook(observation) {
|
|
96
|
+
const { onScreenshot } = this.session.settings;
|
|
83
97
|
if (observation.screenshot !== undefined)
|
|
84
98
|
onScreenshot?.(observation.screenshot);
|
|
85
|
-
this.noteAppState(observation);
|
|
86
99
|
return observation;
|
|
87
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* One look at the screen while the participant waits. Speech it brings is kept for the next
|
|
103
|
+
* request, its URL goes to the URL tap (the shared-world lobby latch) and a task it completes is
|
|
104
|
+
* recorded. Detects, without acting on, the conditions the checkpoint acts on; the watch that
|
|
105
|
+
* asked hands a look that ends it to the checkpoint. Nothing is persisted.
|
|
106
|
+
*/
|
|
107
|
+
async look(turnNumber) {
|
|
108
|
+
const { session } = this;
|
|
109
|
+
if (session.signal?.aborted)
|
|
110
|
+
throw new CuaAbortError();
|
|
111
|
+
session.phase = `looking at the screen during turn ${turnNumber}`;
|
|
112
|
+
const heardBefore = this.pendingHeardSpeech.length;
|
|
113
|
+
const observation = this.withHeardSpeech(await this.observeBounded(`look during turn ${turnNumber}`));
|
|
114
|
+
this.handUrlOn(observation);
|
|
115
|
+
const evidence = stopObservationOf(observation);
|
|
116
|
+
const { stopWhen, dwell } = session.settings;
|
|
117
|
+
const completedTask = this.observeTasks(observation, turnNumber);
|
|
118
|
+
const dwellDue = dwell?.when !== undefined &&
|
|
119
|
+
!this.dwellDone &&
|
|
120
|
+
evaluateStopWhen(dwell.when, evidence) !== undefined;
|
|
121
|
+
return {
|
|
122
|
+
observation,
|
|
123
|
+
speech: this.pendingHeardSpeech.length > heardBefore,
|
|
124
|
+
condition: completedTask || dwellDue || evaluateStopWhen(stopWhen, evidence) !== undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
88
127
|
/**
|
|
89
128
|
* Every observation that carried app state counts, including dwell frames and the final task
|
|
90
129
|
* observation, which no turn receives: they feed the task funnel, and the trace notes must say
|
|
@@ -205,16 +244,19 @@ export class DesktopObserver {
|
|
|
205
244
|
session.trace.bump("screenshots");
|
|
206
245
|
}
|
|
207
246
|
// Evaluated before the stopWhen check each turn so a final task whose criterion coincides with
|
|
208
|
-
// the stop condition still lands in the funnel of the very turn that ends the session.
|
|
247
|
+
// the stop condition still lands in the funnel of the very turn that ends the session. Returns
|
|
248
|
+
// whether a task completed.
|
|
209
249
|
observeTasks(observation, turnNumber) {
|
|
210
250
|
const { taskTracker } = this.session;
|
|
211
251
|
if (taskTracker === undefined)
|
|
212
|
-
return;
|
|
213
|
-
|
|
252
|
+
return false;
|
|
253
|
+
const completions = taskTracker.observe(stopObservationOf(observation), turnNumber);
|
|
254
|
+
for (const completion of completions) {
|
|
214
255
|
// The id is researcher-authored config and the kinds are rule-type names; the matched values
|
|
215
256
|
// (a URL, page text) never appear here: the same discipline as the stopWhen notice.
|
|
216
257
|
this.session.trace.record("notice", () => notice("matched", `task completed: ${this.session.redactNarration(completion.id)}`, this.session.redactNarration(`turn ${turnNumber}; matched rule ${completion.matchedRuleIndex} (${completion.matchedKinds.join("+")})`)));
|
|
217
258
|
}
|
|
259
|
+
return completions.length > 0;
|
|
218
260
|
}
|
|
219
261
|
// The declared observation window. The harness holds, looks, and takes nothing back to
|
|
220
262
|
// the model: no action, no turn, no tokens. It runs once, cut to whatever session budget is
|
|
@@ -129,7 +129,11 @@ export function loopResult(session, stop, debrief) {
|
|
|
129
129
|
: { maxOutputTokens: provider.modelSettings.maxOutputTokens }),
|
|
130
130
|
},
|
|
131
131
|
}),
|
|
132
|
-
waitSettings: {
|
|
132
|
+
waitSettings: {
|
|
133
|
+
maxWaitMs: session.waits.maxWaitMs,
|
|
134
|
+
idleWaitMs: session.waits.idleWaitMs,
|
|
135
|
+
settleWaitMs: session.waits.settleWaitMs,
|
|
136
|
+
},
|
|
133
137
|
counts,
|
|
134
138
|
items: recorder.items,
|
|
135
139
|
...(session.finalPage === undefined ? {} : { finalPage: session.finalPage }),
|
|
@@ -297,8 +297,8 @@ export interface CuaLoopOptions {
|
|
|
297
297
|
/** Injected clock (ms). Lets tests drive deadlines deterministically. */
|
|
298
298
|
now: () => number;
|
|
299
299
|
/**
|
|
300
|
-
* Cancellation. Checked before each turn, each action
|
|
301
|
-
* every provider and desktop call.
|
|
300
|
+
* Cancellation. Checked before each turn, each action, each dwell frame and each look of a
|
|
301
|
+
* watched turn, and raced against every provider and desktop call and each pause of a watch.
|
|
302
302
|
*/
|
|
303
303
|
signal?: AbortSignal;
|
|
304
304
|
/** Idle streak (no material action) that trips the backstop. Default 24. */
|
|
@@ -306,19 +306,25 @@ export interface CuaLoopOptions {
|
|
|
306
306
|
/** No-progress streak that trips the backstop. Default 20. */
|
|
307
307
|
noProgressSteps?: number;
|
|
308
308
|
/**
|
|
309
|
-
* The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs.
|
|
310
|
-
* shortened to it, the trace records a `wait shortened` notice, and the
|
|
311
|
-
* next request
|
|
312
|
-
*
|
|
313
|
-
*
|
|
309
|
+
* The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs. In a turn with another
|
|
310
|
+
* action a longer wait is shortened to it, the trace records a `wait shortened` notice, and the
|
|
311
|
+
* participant is told on its next request; a wait longer than one desktop call carries is sent in
|
|
312
|
+
* steps. A turn that only waits or takes screenshots is watched instead (loop/watch.ts): it first
|
|
313
|
+
* lasts its waits' lengths, the named ones together up to this or, when it is not set, 600000,
|
|
314
|
+
* and each further such turn in a row that ends with no change lasts twice as long, up to that
|
|
315
|
+
* or the turn's own length. A whole number from 1000 to 3600000; any other value throws a
|
|
316
|
+
* RangeError before the session starts. Default 120000, or 30000 when the executor is speechEnabled, for one wait sent to the
|
|
317
|
+
* desktop, and 600000 for a watch.
|
|
314
318
|
*/
|
|
315
319
|
maxWaitMs?: number;
|
|
316
320
|
/**
|
|
317
321
|
* How long a `wait` that names no duration lasts, in ms, when its turn only waits or takes
|
|
318
|
-
* screenshots: the study's actor.idleWaitMs.
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
+
* screenshots: the study's actor.idleWaitMs. It is also how often the loop looks at the screen
|
|
323
|
+
* while such a turn is watched, at most every 30000 ms when the executor is speechEnabled. After
|
|
324
|
+
* an action in the same turn such a wait lasts 500 ms. A whole number from 1000 to 600000 and no
|
|
325
|
+
* longer than an explicit maxWaitMs; any other value throws a RangeError before the session
|
|
326
|
+
* starts. Default 5000. The session never applies more than its maxWaitMs, and the trace records
|
|
327
|
+
* the applied lengths as `waitSettings`.
|
|
322
328
|
*/
|
|
323
329
|
idleWaitMs?: number;
|
|
324
330
|
/**
|
|
@@ -347,9 +353,10 @@ export interface CuaLoopOptions {
|
|
|
347
353
|
/** Persist a screenshot (raw or redacted per redactScreenshots), returning the trace ref path. */
|
|
348
354
|
writeScreenshot?: (name: string, bytes: Buffer) => Promise<string>;
|
|
349
355
|
/**
|
|
350
|
-
* Deterministic harness-owned success guards. Evaluated after the initial observation
|
|
351
|
-
* every post-action observation
|
|
352
|
-
* wandering after the product already reached an
|
|
356
|
+
* Deterministic harness-owned success guards. Evaluated after the initial observation, after
|
|
357
|
+
* every post-action observation and at each look while a turn is watched, before another model
|
|
358
|
+
* turn is requested. This keeps a participant from wandering after the product already reached an
|
|
359
|
+
* app-visible endpoint.
|
|
353
360
|
*/
|
|
354
361
|
stopWhen?: StopWhen;
|
|
355
362
|
/**
|
|
@@ -359,7 +366,10 @@ export interface CuaLoopOptions {
|
|
|
359
366
|
* most once per session and never past the session budget.
|
|
360
367
|
*/
|
|
361
368
|
dwell?: DwellWindow;
|
|
362
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Injected pause for the dwell window's cadence and the looks of a watched turn; tests advance
|
|
371
|
+
* their clock through it.
|
|
372
|
+
*/
|
|
363
373
|
sleep?: (ms: number) => Promise<void>;
|
|
364
374
|
/**
|
|
365
375
|
* What each turn tells the participant about the time (loop/clock.ts). With `timeZone`, a time zone
|
|
@@ -419,7 +429,7 @@ export interface CuaLoopOptions {
|
|
|
419
429
|
* Trace snapshot for a watcher: the redacted items recorded so far, with the running
|
|
420
430
|
* usage so a run can be priced in flight. Called after each checkpoint's screenshot (the initial
|
|
421
431
|
* observation and every acted turn, so a flush never shows an action without the frame before
|
|
422
|
-
* it), after each dwell frame, and after a closing request. The array is a fresh copy; its items
|
|
432
|
+
* it), after each dwell frame, when a watched turn starts watching, and after a closing request. The array is a fresh copy; its items
|
|
423
433
|
* are the objects the final trace persists. Not awaited, so a slow flush cannot stall a turn.
|
|
424
434
|
* Default: no-op.
|
|
425
435
|
*/
|
|
@@ -433,8 +443,8 @@ export interface CuaLoopOptions {
|
|
|
433
443
|
export interface LoopTaps {
|
|
434
444
|
/**
|
|
435
445
|
* Runtime-only URL tap: called with `observation.url` for each observation a turn may receive
|
|
436
|
-
* (the initial one, each post-action one and the one after a dwell window)
|
|
437
|
-
* persists the raw URL. Default: no-op.
|
|
446
|
+
* (the initial one, each post-action one and the one after a dwell window) and each look while a
|
|
447
|
+
* turn is watched. The loop never persists the raw URL. Default: no-op.
|
|
438
448
|
*
|
|
439
449
|
* Built-in caller: the external-public shared-world handoff (src/routes/shared-world/handoff.ts)
|
|
440
450
|
* latches the host participant's lobby URL and checks that each follower reached the same lobby.
|
|
@@ -452,7 +462,8 @@ export interface LoopTaps {
|
|
|
452
462
|
onMessage?: (text: string) => void;
|
|
453
463
|
/**
|
|
454
464
|
* Runtime-only frame tap: called with each raw frame a turn may receive and each dwell-window
|
|
455
|
-
* frame, before any redaction.
|
|
465
|
+
* frame, before any redaction. Of a watched turn's looks, only the one that ended the watch is a
|
|
466
|
+
* frame a turn receives. The buffer stays in memory; screenshot persistence is separate and
|
|
456
467
|
* follows redactScreenshots. Not awaited. Default: no-op.
|
|
457
468
|
*
|
|
458
469
|
* Built-in caller: the external-public handoff reads the lobby code off each participant's own
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ActorWatch } from "../../contract.js";
|
|
2
|
+
import { type WatchedWaits } from "./actions.js";
|
|
3
|
+
import { type Stop } from "./ending.js";
|
|
4
|
+
import type { DesktopObserver } from "./observation.js";
|
|
5
|
+
import type { LoopSession } from "./session.js";
|
|
6
|
+
import type { CuaAction, CuaObservation } from "./types.js";
|
|
7
|
+
/** A watch that ran for one idle turn: how it ended, and what the loop does next. */
|
|
8
|
+
export interface Watched {
|
|
9
|
+
readonly wake: ActorWatch["wake"];
|
|
10
|
+
/**
|
|
11
|
+
* The look that ended the watch, which the checkpoint takes in place of a fresh observe. Absent
|
|
12
|
+
* when no look ended it: a stalled look, or the participant's end.
|
|
13
|
+
*/
|
|
14
|
+
readonly observation?: CuaObservation;
|
|
15
|
+
/** The next request's note: how long the participant waited and why it woke. */
|
|
16
|
+
readonly hint: string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Set when the session ends here: the study budget or the session's time ran out, or the
|
|
19
|
+
* participant's end came.
|
|
20
|
+
*/
|
|
21
|
+
readonly stop?: Stop;
|
|
22
|
+
/** Each wait's part of the watch, for the trace. */
|
|
23
|
+
readonly waits: WatchedWaits;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The watches of one participant's idle turns. Between turns it keeps the run of idle turns the
|
|
27
|
+
* doubling counts: a watch that ended at its limit or on a small change makes the next one in the
|
|
28
|
+
* run longer, and a turn with another action ends the run.
|
|
29
|
+
*/
|
|
30
|
+
export declare class IdleWatcher {
|
|
31
|
+
private readonly session;
|
|
32
|
+
private readonly observer;
|
|
33
|
+
/** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
|
|
34
|
+
private grownFromMs;
|
|
35
|
+
constructor(session: LoopSession, observer: DesktopObserver);
|
|
36
|
+
/**
|
|
37
|
+
* Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
|
|
38
|
+
* another action, which ends the run, or with no wait time (screenshots only, or waits of 0 ms),
|
|
39
|
+
* which leaves the run as it is. `reference` is the observation the participant chose to wait on.
|
|
40
|
+
*/
|
|
41
|
+
watch(turnNumber: number, reference: CuaObservation, actions: readonly CuaAction[]): Promise<Watched | undefined>;
|
|
42
|
+
}
|