humanish 0.133.1 → 0.135.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 +28 -0
- package/dist/actors/computer-use/loop/actions.d.ts +2 -0
- package/dist/actors/computer-use/loop/actions.js +11 -5
- package/dist/actors/computer-use/loop/backstop.d.ts +23 -2
- package/dist/actors/computer-use/loop/backstop.js +40 -7
- package/dist/actors/computer-use/loop/session.d.ts +3 -0
- package/dist/actors/computer-use/loop/session.js +3 -0
- package/dist/actors/computer-use/loop/streak.d.ts +18 -0
- package/dist/actors/computer-use/loop/streak.js +47 -0
- package/dist/actors/computer-use/loop/types.d.ts +5 -1
- package/dist/actors/computer-use/loop/watch.d.ts +22 -4
- package/dist/actors/computer-use/loop/watch.js +83 -27
- package/dist/actors/computer-use/loop.js +18 -6
- package/dist/actors/contract.d.ts +3 -1
- package/dist/analysis/automatic-config.js +5 -3
- package/dist/analysis/execute.js +2 -1
- package/dist/analysis/service.js +5 -3
- package/dist/analysis/source.d.ts +6 -0
- package/dist/analysis/source.js +15 -13
- package/dist/cli/commands/run-signals.js +6 -2
- package/dist/evidence/encoded-text.d.ts +13 -3
- package/dist/evidence/encoded-text.js +88 -25
- package/dist/observer/page-data.d.ts +6 -0
- package/dist/observer/page-data.js +34 -7
- package/dist/observer/participant-items.d.ts +15 -1
- package/dist/observer/participant-items.js +4 -3
- package/dist/observer/render.js +5 -3
- package/dist/observer/run-routes.js +4 -4
- package/dist/observer-app.html +4 -4
- package/dist/routes/computer-use/bundle-parts.d.ts +9 -0
- package/dist/routes/computer-use/bundle-parts.js +24 -4
- package/dist/routes/computer-use/fanout-bundle.js +8 -6
- package/dist/routes/computer-use/participant-runs.js +2 -2
- package/dist/routes/computer-use/single-bundle.js +5 -5
- package/dist/routes/scripted/setup.js +62 -17
- package/dist/routes/shared-world/bundle.js +3 -3
- package/dist/routes/shared-world/participant-specs.js +2 -2
- package/dist/routes/terminal/bundle.js +2 -2
- package/dist/run/locate.d.ts +8 -2
- package/dist/run/locate.js +10 -4
- package/dist/run/run.js +22 -5
- package/dist/run/sandbox-ids.d.ts +4 -3
- package/dist/run/sandbox-ids.js +8 -6
- package/dist/run/stream-trace.d.ts +8 -2
- package/dist/run/stream-trace.js +13 -7
- package/dist/run-study.d.ts +3 -3
- package/dist/run-study.js +3 -3
- package/dist/study/bundle-estimate.d.ts +0 -2
- package/dist/study/bundle-estimate.js +33 -33
- package/dist/study/config.d.ts +4 -3
- package/dist/study/config.js +8 -5
- package/dist/study/parse/actors.d.ts +6 -0
- package/dist/study/parse/actors.js +14 -0
- package/dist/study/parse/subject-state.js +4 -4
- package/dist/study/participant-assignment.js +2 -2
- package/dist/study/persona.d.ts +7 -1
- package/dist/study/persona.js +13 -5
- package/dist/study/plan-base.js +4 -3
- package/dist/verify/artifacts.d.ts +7 -1
- package/dist/verify/artifacts.js +15 -4
- package/dist/verify/verify.js +29 -8
- package/docs/architecture/actor-contract.md +3 -1
- package/docs/architecture/observer.md +24 -7
- package/docs/architecture/state-driven-executor.md +7 -4
- package/docs/contracts/run-bundle.md +16 -2
- package/docs/contracts/study-analysis.md +8 -5
- package/docs/decisions/0002-observer-is-one-self-contained-file.md +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,34 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.135.0: Encoded keys in a study's own text stay out of the run (2026-10-14)
|
|
12
|
+
|
|
13
|
+
humanish 0.135.0 keeps a base64- or percent-encoded key in a study's own text out of the copies of
|
|
14
|
+
that text the run records. The run redacted keys as written, and verify also decodes those
|
|
15
|
+
encodings, so a study whose title, mission, instructions, persona or scripted scenario held an
|
|
16
|
+
encoded key produced a run graded `blocked`. A participant that repeats such a key in its own words
|
|
17
|
+
still does (#1892). `actor.model`, `subject.envValues` and the analysis question now refuse such a
|
|
18
|
+
value before anything runs, which a study that ran before may now meet. verify's per-file scan also
|
|
19
|
+
finds a key behind an HTML reference and a JSON escape. The Observer page and its live feed carry
|
|
20
|
+
each participant's items on their own, so `humanish observe` writes the page of a run whose traces
|
|
21
|
+
together pass V8's longest string: on a synthetic run of 25 participants with 30 MiB traces it
|
|
22
|
+
exits 0 where it exited 2.
|
|
23
|
+
|
|
24
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.135.0)
|
|
25
|
+
|
|
26
|
+
## 0.134.0: Participants held to an end keep reloading until it (2026-10-14)
|
|
27
|
+
|
|
28
|
+
humanish 0.134.0 keeps a participant held to a scheduled end running when it reloads a page that
|
|
29
|
+
changes only on reload. 20 reloads in a row on an unchanged screen ended its session as `gave_up`.
|
|
30
|
+
Now humanish watches the screen after each further reload, as after a wait, so a participant that
|
|
31
|
+
reloads every few seconds costs about what one that waits costs. In 60-minute live runs with a
|
|
32
|
+
persona that refreshes every few seconds, 0.133.1 ended 3 of 4 within 7 minutes; this release ran
|
|
33
|
+
all 4 to their end at a median $3.23 an hour. Trace files are now written as compact JSON, about a
|
|
34
|
+
quarter smaller, and a run whose trace still passes the 32 MiB read limit finishes and grades
|
|
35
|
+
`local_only` where it failed.
|
|
36
|
+
|
|
37
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.134.0)
|
|
38
|
+
|
|
11
39
|
## 0.133.1: Hosted runs keep writing run.json past 512 MiB of traces (2026-10-14)
|
|
12
40
|
|
|
13
41
|
humanish 0.133.1 keeps a hosted run writing run.json once its finished participants' traces
|
|
@@ -21,6 +21,8 @@ export interface ActionBatch {
|
|
|
21
21
|
* stopped the batch.
|
|
22
22
|
*/
|
|
23
23
|
readonly hint: string | undefined;
|
|
24
|
+
/** An action of the batch other than a wait or screenshot may have reached the desktop. */
|
|
25
|
+
readonly sent: boolean;
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* The waits of a turn the loop watched in place of sending them to the desktop (watch.ts): the
|
|
@@ -128,6 +128,12 @@ export async function runActionBatch(session, actions, watched) {
|
|
|
128
128
|
const { actionHistory, trace } = session;
|
|
129
129
|
const notes = [];
|
|
130
130
|
const hint = () => (notes.length === 0 ? undefined : notes.join(" "));
|
|
131
|
+
const attemptsBefore = trace.counts.materialActions;
|
|
132
|
+
const done = () => ({
|
|
133
|
+
execution,
|
|
134
|
+
hint: hint(),
|
|
135
|
+
sent: trace.counts.materialActions > attemptsBefore,
|
|
136
|
+
});
|
|
131
137
|
const idleTurn = isIdleTurn(actions);
|
|
132
138
|
let waits = 0;
|
|
133
139
|
for (const [index, requested] of actions.entries()) {
|
|
@@ -187,13 +193,13 @@ export async function runActionBatch(session, actions, watched) {
|
|
|
187
193
|
const { reason } = error;
|
|
188
194
|
trace.record("notice", () => notice("warn", "action rejected before dispatch", session.redactNarration(`action: ${title}; code: action_rejected; disposition: not_dispatched;${reason === undefined ? "" : ` reason: ${reason};`} remaining batch actions not dispatched: ${actions.length - index - 1}`)));
|
|
189
195
|
notes.push(rejectedActionHint(title, reason));
|
|
190
|
-
return
|
|
196
|
+
return done();
|
|
191
197
|
}
|
|
192
198
|
if (isComputerUseExecutorError(error) || !isCommandExitError(error))
|
|
193
199
|
throw error;
|
|
194
|
-
// A skipped action changes nothing on screen, so a
|
|
195
|
-
// progress and
|
|
196
|
-
//
|
|
200
|
+
// A skipped action changes nothing on screen, so a run that repeats a failing action makes
|
|
201
|
+
// no progress and ends through the no-progress backstop (gave_up), also when the
|
|
202
|
+
// participant is held to its scheduled end (backstop.ts consecutiveUnsent).
|
|
197
203
|
recordCommandFailure(session, title, error);
|
|
198
204
|
continue;
|
|
199
205
|
}
|
|
@@ -212,7 +218,7 @@ export async function runActionBatch(session, actions, watched) {
|
|
|
212
218
|
...(requested.kind === "wait" && requested.ms !== undefined ? { namedMs: requested.ms } : {}),
|
|
213
219
|
}));
|
|
214
220
|
}
|
|
215
|
-
return
|
|
221
|
+
return done();
|
|
216
222
|
}
|
|
217
223
|
function countAttempt(session, action, title, status) {
|
|
218
224
|
if (!isMaterialAttempt(action, status))
|
|
@@ -24,9 +24,27 @@ export interface Backstop {
|
|
|
24
24
|
readonly recentFingerprints: readonly string[];
|
|
25
25
|
readonly consecutiveIdle: number;
|
|
26
26
|
readonly consecutiveNoProgress: number;
|
|
27
|
+
/**
|
|
28
|
+
* No-progress turns in a row whose actions all failed or were refused before reaching the
|
|
29
|
+
* desktop. They end a held participant's session at noProgressSteps, since a reload that never
|
|
30
|
+
* reached the desktop is not how it waits; one such turn in a longer streak does not.
|
|
31
|
+
*/
|
|
32
|
+
readonly consecutiveUnsent: number;
|
|
27
33
|
readonly idleProgressForgivenessUsed: number;
|
|
34
|
+
/** A no-progress streak in this session reached noProgressSteps. */
|
|
35
|
+
readonly reachedLimit: boolean;
|
|
28
36
|
}
|
|
29
37
|
export declare function startBackstop(observation: CuaObservation): Backstop;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a turn that acts repeats a recent action past the no-progress limit: the streak before it
|
|
40
|
+
* is one short of the limit or longer, or, once a streak in the session reached the limit, the turn
|
|
41
|
+
* before it was a no-progress turn. The loop watches the screen after such a turn of a held
|
|
42
|
+
* participant, since its streak no longer ends its session. A change a reload brings, or an item
|
|
43
|
+
* the participant opens, resets the streak; without the second case each one would give the
|
|
44
|
+
* participant noProgressSteps more requests back to back. A repeat after a turn that changed the
|
|
45
|
+
* screen, such as a second scroll, is not past the limit.
|
|
46
|
+
*/
|
|
47
|
+
export declare function repeatsPastLimit(backstop: Backstop, actions: readonly CuaAction[], limits: BackstopLimits): boolean;
|
|
30
48
|
export interface BackstopLimits {
|
|
31
49
|
readonly idleSteps: number;
|
|
32
50
|
readonly noProgressSteps: number;
|
|
@@ -43,8 +61,9 @@ export interface BackstopStep {
|
|
|
43
61
|
/**
|
|
44
62
|
* Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
|
|
45
63
|
* participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
|
|
46
|
-
* toward neither streak and earns no nudge
|
|
47
|
-
*
|
|
64
|
+
* toward neither streak and earns no nudge, and the no-progress streak does not end the session.
|
|
65
|
+
* A turn that acts is otherwise folded as usual. `screenChanged` says the turn's watch woke on a
|
|
66
|
+
* change it saw, which the progress key can miss.
|
|
48
67
|
*/
|
|
49
68
|
export declare function advanceBackstop(previous: Backstop, turn: {
|
|
50
69
|
actions: readonly CuaAction[];
|
|
@@ -52,4 +71,6 @@ export declare function advanceBackstop(previous: Backstop, turn: {
|
|
|
52
71
|
heardNewSpeech: boolean;
|
|
53
72
|
waitingIsTheTask?: boolean;
|
|
54
73
|
screenChanged?: boolean;
|
|
74
|
+
/** An action of the turn may have reached the desktop (ActionBatch.sent). */
|
|
75
|
+
sent: boolean;
|
|
55
76
|
}, limits: BackstopLimits): BackstopStep;
|
|
@@ -119,14 +119,38 @@ export function startBackstop(observation) {
|
|
|
119
119
|
recentFingerprints: [],
|
|
120
120
|
consecutiveIdle: 0,
|
|
121
121
|
consecutiveNoProgress: 0,
|
|
122
|
+
consecutiveUnsent: 0,
|
|
122
123
|
idleProgressForgivenessUsed: 0,
|
|
124
|
+
reachedLimit: false,
|
|
123
125
|
};
|
|
124
126
|
}
|
|
127
|
+
function repeatsRecentAction(backstop, actions) {
|
|
128
|
+
const fingerprint = actionFingerprint(actions);
|
|
129
|
+
return fingerprint.length > 0 && backstop.recentFingerprints.includes(fingerprint);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether a turn that acts repeats a recent action past the no-progress limit: the streak before it
|
|
133
|
+
* is one short of the limit or longer, or, once a streak in the session reached the limit, the turn
|
|
134
|
+
* before it was a no-progress turn. The loop watches the screen after such a turn of a held
|
|
135
|
+
* participant, since its streak no longer ends its session. A change a reload brings, or an item
|
|
136
|
+
* the participant opens, resets the streak; without the second case each one would give the
|
|
137
|
+
* participant noProgressSteps more requests back to back. A repeat after a turn that changed the
|
|
138
|
+
* screen, such as a second scroll, is not past the limit.
|
|
139
|
+
*/
|
|
140
|
+
export function repeatsPastLimit(backstop, actions, limits) {
|
|
141
|
+
const limit = backstop.reachedLimit
|
|
142
|
+
? Math.min(2, limits.noProgressSteps)
|
|
143
|
+
: limits.noProgressSteps;
|
|
144
|
+
return (!isIdleTurn(actions) &&
|
|
145
|
+
backstop.consecutiveNoProgress + 1 >= limit &&
|
|
146
|
+
repeatsRecentAction(backstop, actions));
|
|
147
|
+
}
|
|
125
148
|
/**
|
|
126
149
|
* Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
|
|
127
150
|
* 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
|
|
129
|
-
*
|
|
151
|
+
* toward neither streak and earns no nudge, and the no-progress streak does not end the session.
|
|
152
|
+
* A turn that acts is otherwise folded as usual. `screenChanged` says the turn's watch woke on a
|
|
153
|
+
* change it saw, which the progress key can miss.
|
|
130
154
|
*/
|
|
131
155
|
export function advanceBackstop(previous, turn, limits) {
|
|
132
156
|
const idle = isIdleTurn(turn.actions);
|
|
@@ -151,9 +175,11 @@ export function advanceBackstop(previous, turn, limits) {
|
|
|
151
175
|
// just tried. An agent doing varied work is never counted stuck, however blind the hash is;
|
|
152
176
|
// an agent re-clicking the same dead control trips it as fast as it did before, arguably
|
|
153
177
|
// faster, since that is the actual signature of being stuck.
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
178
|
+
const repeatingRecentAction = repeatsRecentAction(previous, turn.actions);
|
|
179
|
+
const recentFingerprints = [
|
|
180
|
+
...previous.recentFingerprints,
|
|
181
|
+
actionFingerprint(turn.actions),
|
|
182
|
+
].slice(-ACTION_REPEAT_WINDOW);
|
|
157
183
|
// Corroboration governs the frame-staleness backstop only. The idle backstop below is a direct
|
|
158
184
|
// behavioral signal already (the agent took nothing but screenshots and waits), so it keeps
|
|
159
185
|
// reading the frame on its own: a repeated screenshot is exactly what an idle streak is, and
|
|
@@ -177,6 +203,7 @@ export function advanceBackstop(previous, turn, limits) {
|
|
|
177
203
|
consecutiveIdle += 1;
|
|
178
204
|
}
|
|
179
205
|
const consecutiveNoProgress = progressed ? 0 : previous.consecutiveNoProgress + 1;
|
|
206
|
+
const consecutiveUnsent = progressed || turn.sent ? 0 : previous.consecutiveUnsent + 1;
|
|
180
207
|
// An unchanged screen can be legitimate waiting (for example, a shared-world lobby).
|
|
181
208
|
// Recovery may suggest another approach, but must not instruct early abandonment while
|
|
182
209
|
// the task still calls for waiting. The counters, time and spend guards own hard stops.
|
|
@@ -196,10 +223,14 @@ export function advanceBackstop(previous, turn, limits) {
|
|
|
196
223
|
"If your task calls for waiting, you may continue within the remaining session time. " +
|
|
197
224
|
"When the relevant controls become actionable, continue your task; describe any blocker you actually encounter.");
|
|
198
225
|
}
|
|
226
|
+
// A held participant past the no-progress limit keeps its session: on a page that shows new items
|
|
227
|
+
// only after a reload, reloading an unchanged screen is how it waits. Repeats that never reached
|
|
228
|
+
// the desktop still end it.
|
|
229
|
+
const stuckTurns = turn.waitingIsTheTask === true ? consecutiveUnsent : consecutiveNoProgress;
|
|
199
230
|
const tripReason = consecutiveIdle >= limits.idleSteps
|
|
200
231
|
? `gave up: ${consecutiveIdle} consecutive turns with no material UI action (only screenshot/wait)`
|
|
201
|
-
:
|
|
202
|
-
? `gave up: ${
|
|
232
|
+
: stuckTurns >= limits.noProgressSteps
|
|
233
|
+
? `gave up: ${stuckTurns} consecutive turns that repeated a recent action on an unchanged UI state`
|
|
203
234
|
: undefined;
|
|
204
235
|
return {
|
|
205
236
|
backstop: {
|
|
@@ -207,7 +238,9 @@ export function advanceBackstop(previous, turn, limits) {
|
|
|
207
238
|
recentFingerprints,
|
|
208
239
|
consecutiveIdle,
|
|
209
240
|
consecutiveNoProgress,
|
|
241
|
+
consecutiveUnsent,
|
|
210
242
|
idleProgressForgivenessUsed,
|
|
243
|
+
reachedLimit: previous.reachedLimit || consecutiveNoProgress >= limits.noProgressSteps,
|
|
211
244
|
},
|
|
212
245
|
idle,
|
|
213
246
|
progressed,
|
|
@@ -2,6 +2,7 @@ import type { ActorTraceItem, ParticipantDeclaredOutcome, ParticipantImpressions
|
|
|
2
2
|
import type { AffordanceObservation } from "../../affordance.js";
|
|
3
3
|
import { TaskTracker } from "../../../study/tasks.js";
|
|
4
4
|
import type { DebriefTrigger, Stop } from "./ending.js";
|
|
5
|
+
import { StreakRecord } from "./streak.js";
|
|
5
6
|
import { TraceRecorder } from "./trace.js";
|
|
6
7
|
import type { CuaExecutor, CuaProvider, CuaSafetyCheck, LoopRunOptions } from "./types.js";
|
|
7
8
|
import { UsageLedger } from "./usage.js";
|
|
@@ -55,6 +56,8 @@ export declare class LoopSession {
|
|
|
55
56
|
readonly trace: TraceRecorder;
|
|
56
57
|
readonly usage: UsageLedger;
|
|
57
58
|
readonly taskTracker: TaskTracker | undefined;
|
|
59
|
+
/** The no-progress streak a held participant is in, for the trace. */
|
|
60
|
+
readonly streak: StreakRecord;
|
|
58
61
|
readonly actionHistory: ActionHistory;
|
|
59
62
|
/** What the loop was doing, for failure notices. */
|
|
60
63
|
phase: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TaskTracker } from "../../../study/tasks.js";
|
|
2
|
+
import { StreakRecord } from "./streak.js";
|
|
2
3
|
import { TraceRecorder } from "./trace.js";
|
|
3
4
|
import { UsageLedger } from "./usage.js";
|
|
4
5
|
import { resolveWaitSettings } from "../wait.js";
|
|
@@ -47,6 +48,8 @@ export class LoopSession {
|
|
|
47
48
|
usage;
|
|
48
49
|
// The funnel is recorded, never consulted: task completion does not steer the loop.
|
|
49
50
|
taskTracker;
|
|
51
|
+
/** The no-progress streak a held participant is in, for the trace. */
|
|
52
|
+
streak = new StreakRecord(this);
|
|
50
53
|
actionHistory = {
|
|
51
54
|
lastActionTitle: undefined,
|
|
52
55
|
lastMaterialActionTitle: undefined,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LoopSession } from "./session.js";
|
|
2
|
+
/** The title of the notice that records one streak. */
|
|
3
|
+
export declare const STREAK_TITLE = "repeated an action on an unchanged screen";
|
|
4
|
+
/** The streak the participant is in, recorded when a turn ends it or the session ends. */
|
|
5
|
+
export declare class StreakRecord {
|
|
6
|
+
private readonly session;
|
|
7
|
+
private open;
|
|
8
|
+
constructor(session: LoopSession);
|
|
9
|
+
/**
|
|
10
|
+
* Fold one turn's streak length, the backstop's consecutiveNoProgress after the turn. A held
|
|
11
|
+
* participant's idle turn leaves the length as it was, and the streak's time span with it. Only
|
|
12
|
+
* the streak of a participant with a scheduled end is followed: any other one ends the session
|
|
13
|
+
* at noProgressSteps. The end may have passed, since the turn that reaches it is still counted.
|
|
14
|
+
*/
|
|
15
|
+
fold(turns: number): void;
|
|
16
|
+
/** Record the open streak when it reached noProgressSteps. */
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { notice } from "./trace.js";
|
|
2
|
+
// The record of a held participant's no-progress streaks. Past noProgressSteps a streak no longer
|
|
3
|
+
// ends the session of a participant held to its scheduled end (backstop.ts), so the trace says how
|
|
4
|
+
// many turns it had and when: analysis reads the notice as friction, and the review lists it.
|
|
5
|
+
/** The title of the notice that records one streak. */
|
|
6
|
+
export const STREAK_TITLE = "repeated an action on an unchanged screen";
|
|
7
|
+
/** The streak the participant is in, recorded when a turn ends it or the session ends. */
|
|
8
|
+
export class StreakRecord {
|
|
9
|
+
session;
|
|
10
|
+
open;
|
|
11
|
+
constructor(session) {
|
|
12
|
+
this.session = session;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Fold one turn's streak length, the backstop's consecutiveNoProgress after the turn. A held
|
|
16
|
+
* participant's idle turn leaves the length as it was, and the streak's time span with it. Only
|
|
17
|
+
* the streak of a participant with a scheduled end is followed: any other one ends the session
|
|
18
|
+
* at noProgressSteps. The end may have passed, since the turn that reaches it is still counted.
|
|
19
|
+
*/
|
|
20
|
+
fold(turns) {
|
|
21
|
+
const { open, session } = this;
|
|
22
|
+
const end = session.settings.clock?.end;
|
|
23
|
+
if (turns === 0)
|
|
24
|
+
this.close();
|
|
25
|
+
else if (open?.turns !== turns && end !== undefined && end.kind !== "session_cap") {
|
|
26
|
+
const now = session.now();
|
|
27
|
+
this.open = { turns, fromMs: open?.fromMs ?? now, toMs: now };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Record the open streak when it reached noProgressSteps. */
|
|
31
|
+
close() {
|
|
32
|
+
const { open, session } = this;
|
|
33
|
+
this.open = undefined;
|
|
34
|
+
if (open === undefined || open.turns < session.noProgressSteps)
|
|
35
|
+
return;
|
|
36
|
+
const at = (ms) => sessionClock(ms - session.startedAtMs);
|
|
37
|
+
session.trace.record("notice", () => notice("warn", STREAK_TITLE, `${open.turns} turns in a row repeated a recent action on an unchanged screen, from ${at(open.fromMs)} to ${at(open.toMs)} into the session. The participant is held to its scheduled end, so ${session.noProgressSteps} such turns did not end its session, and from then on the screen was watched after each one before the next request.`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** `40:35`, or `1:02:03` from an hour. */
|
|
41
|
+
function sessionClock(ms) {
|
|
42
|
+
const seconds = Math.floor(Math.max(0, ms) / 1_000);
|
|
43
|
+
const minutes = Math.floor(seconds / 60);
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
const ss = String(seconds % 60).padStart(2, "0");
|
|
46
|
+
return hours > 0 ? `${hours}:${String(minutes % 60).padStart(2, "0")}:${ss}` : `${minutes}:${ss}`;
|
|
47
|
+
}
|
|
@@ -312,7 +312,11 @@ export interface CuaLoopOptions {
|
|
|
312
312
|
signal?: AbortSignal;
|
|
313
313
|
/** Idle streak (no material action) that trips the backstop. Default 24. */
|
|
314
314
|
idleSteps?: number;
|
|
315
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* No-progress streak that trips the backstop. Default 20. It does not end a participant held to
|
|
317
|
+
* its scheduled end: from this many repeats on, the screen is watched after each further one that
|
|
318
|
+
* follows a turn with no progress.
|
|
319
|
+
*/
|
|
316
320
|
noProgressSteps?: number;
|
|
317
321
|
/**
|
|
318
322
|
* The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs. In a turn with another
|
|
@@ -4,7 +4,7 @@ import { type Stop } from "./ending.js";
|
|
|
4
4
|
import type { DesktopObserver } from "./observation.js";
|
|
5
5
|
import type { LoopSession } from "./session.js";
|
|
6
6
|
import type { CuaAction, CuaObservation } from "./types.js";
|
|
7
|
-
/** A watch that ran for one
|
|
7
|
+
/** A watch that ran for one turn: how it ended, and what the loop does next. */
|
|
8
8
|
export interface Watched {
|
|
9
9
|
readonly wake: ActorWatch["wake"];
|
|
10
10
|
/**
|
|
@@ -23,20 +23,38 @@ export interface Watched {
|
|
|
23
23
|
readonly waits: WatchedWaits;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* The watches of one participant's
|
|
26
|
+
* The watches of one participant's turns. Between turns it keeps the run of watched turns the
|
|
27
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.
|
|
28
|
+
* run longer, and a turn with another action that is not watched ends the run.
|
|
29
29
|
*/
|
|
30
30
|
export declare class IdleWatcher {
|
|
31
31
|
private readonly session;
|
|
32
32
|
private readonly observer;
|
|
33
33
|
/** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
|
|
34
34
|
private grownFromMs;
|
|
35
|
+
/** The last turn only waited or took screenshots. */
|
|
36
|
+
private quiet;
|
|
35
37
|
constructor(session: LoopSession, observer: DesktopObserver);
|
|
36
38
|
/**
|
|
37
39
|
* Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
|
|
38
40
|
* 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.
|
|
41
|
+
* which leaves the run as it is. A participant held to its scheduled end is watched for a turn
|
|
42
|
+
* with no wait time after another turn that only waited or took screenshots, as for a wait with
|
|
43
|
+
* no length: it waits for its end, and without the watch it would send its requests back to back.
|
|
44
|
+
* The first such turn, at the start or after an action, is sent at once, so a participant that
|
|
45
|
+
* looks again after acting does not wait idleWaitMs for it. `reference` is the observation the
|
|
46
|
+
* participant chose to wait on.
|
|
40
47
|
*/
|
|
41
48
|
watch(turnNumber: number, reference: CuaObservation, actions: readonly CuaAction[]): Promise<Watched | undefined>;
|
|
49
|
+
/**
|
|
50
|
+
* Watch the screen after a held participant's turn that repeated a recent action past its
|
|
51
|
+
* no-progress limit (repeatsPastLimit), as after a wait with no length, before its next request.
|
|
52
|
+
* The run goes on, so a participant that reloads an unchanged page turn after turn waits as long
|
|
53
|
+
* as one that only waits. `reference` is the observation the participant acted on, so a change
|
|
54
|
+
* its action made wakes it. Undefined, with nothing run, when no action of the turn reached the
|
|
55
|
+
* desktop (`sent`, ActionBatch.sent): such a repeat is not how the participant waits, and
|
|
56
|
+
* noProgressSteps of them end its session (backstop.ts consecutiveUnsent).
|
|
57
|
+
*/
|
|
58
|
+
watchAfterRepeat(turnNumber: number, reference: CuaObservation, sent: boolean): Promise<Watched | undefined>;
|
|
59
|
+
private run;
|
|
42
60
|
}
|
|
@@ -2,20 +2,22 @@ import { watchFrames, } from "../../../evidence/frame-change.js";
|
|
|
2
2
|
import { plural } from "../../../run/text.js";
|
|
3
3
|
import { isIdleTurn } from "./actions.js";
|
|
4
4
|
import { progressKeyOf } from "./backstop.js";
|
|
5
|
-
import { clockEndStop } from "./clock.js";
|
|
5
|
+
import { clockEndStop, heldToScheduledEnd } from "./clock.js";
|
|
6
6
|
import { timeLimit } from "./ending.js";
|
|
7
7
|
import { CuaAbortError, CuaStallError, raceSessionDeadline } from "./race.js";
|
|
8
8
|
import { spendStop } from "./spend.js";
|
|
9
9
|
/**
|
|
10
|
-
* The watches of one participant's
|
|
10
|
+
* The watches of one participant's turns. Between turns it keeps the run of watched turns the
|
|
11
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.
|
|
12
|
+
* run longer, and a turn with another action that is not watched ends the run.
|
|
13
13
|
*/
|
|
14
14
|
export class IdleWatcher {
|
|
15
15
|
session;
|
|
16
16
|
observer;
|
|
17
17
|
/** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
|
|
18
18
|
grownFromMs;
|
|
19
|
+
/** The last turn only waited or took screenshots. */
|
|
20
|
+
quiet = false;
|
|
19
21
|
constructor(session, observer) {
|
|
20
22
|
this.session = session;
|
|
21
23
|
this.observer = observer;
|
|
@@ -23,11 +25,18 @@ export class IdleWatcher {
|
|
|
23
25
|
/**
|
|
24
26
|
* Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
|
|
25
27
|
* 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.
|
|
28
|
+
* which leaves the run as it is. A participant held to its scheduled end is watched for a turn
|
|
29
|
+
* with no wait time after another turn that only waited or took screenshots, as for a wait with
|
|
30
|
+
* no length: it waits for its end, and without the watch it would send its requests back to back.
|
|
31
|
+
* The first such turn, at the start or after an action, is sent at once, so a participant that
|
|
32
|
+
* looks again after acting does not wait idleWaitMs for it. `reference` is the observation the
|
|
33
|
+
* participant chose to wait on.
|
|
27
34
|
*/
|
|
28
35
|
async watch(turnNumber, reference, actions) {
|
|
29
36
|
const { session } = this;
|
|
30
|
-
|
|
37
|
+
const quietBefore = this.quiet;
|
|
38
|
+
this.quiet = isIdleTurn(actions);
|
|
39
|
+
if (!this.quiet) {
|
|
31
40
|
this.grownFromMs = undefined;
|
|
32
41
|
return undefined;
|
|
33
42
|
}
|
|
@@ -46,9 +55,43 @@ export class IdleWatcher {
|
|
|
46
55
|
namedMs += ms;
|
|
47
56
|
return [ms];
|
|
48
57
|
});
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
if (lengths.every((ms) => ms === 0) && !(quietBefore && heldToScheduledEnd(session)))
|
|
59
|
+
return undefined;
|
|
60
|
+
return this.run(turnNumber, reference, {
|
|
61
|
+
lengths,
|
|
62
|
+
askedMs: asked.reduce((sum, ms) => sum + ms, 0),
|
|
63
|
+
...(lengths.length > 0
|
|
64
|
+
? { lead: "Your wait" }
|
|
65
|
+
: {
|
|
66
|
+
lead: "The wait before your screenshot",
|
|
67
|
+
unasked: "The turn took only screenshots after a turn of only waits or screenshots, and the participant is held to its scheduled end, so the screen was watched as for a wait with no length.",
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Watch the screen after a held participant's turn that repeated a recent action past its
|
|
73
|
+
* no-progress limit (repeatsPastLimit), as after a wait with no length, before its next request.
|
|
74
|
+
* The run goes on, so a participant that reloads an unchanged page turn after turn waits as long
|
|
75
|
+
* as one that only waits. `reference` is the observation the participant acted on, so a change
|
|
76
|
+
* its action made wakes it. Undefined, with nothing run, when no action of the turn reached the
|
|
77
|
+
* desktop (`sent`, ActionBatch.sent): such a repeat is not how the participant waits, and
|
|
78
|
+
* noProgressSteps of them end its session (backstop.ts consecutiveUnsent).
|
|
79
|
+
*/
|
|
80
|
+
async watchAfterRepeat(turnNumber, reference, sent) {
|
|
81
|
+
this.quiet = false;
|
|
82
|
+
if (!sent)
|
|
51
83
|
return undefined;
|
|
84
|
+
return this.run(turnNumber, reference, {
|
|
85
|
+
lengths: [],
|
|
86
|
+
askedMs: 0,
|
|
87
|
+
lead: "The wait after your last action",
|
|
88
|
+
unasked: "The turn repeated a recent action on an unchanged screen past the no-progress limit and the participant is held to its scheduled end, so the screen was watched before the next request.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async run(turnNumber, reference, asked) {
|
|
92
|
+
const { session } = this;
|
|
93
|
+
const { lengths, unasked } = asked;
|
|
94
|
+
const plannedMs = lengths.reduce((sum, ms) => sum + ms, 0);
|
|
52
95
|
const capMs = Math.max(plannedMs, session.waits.watchMaxMs);
|
|
53
96
|
// Never shorter than idleWaitMs, so a participant naming a few milliseconds on a static screen
|
|
54
97
|
// does not send requests back to back, and never shorter than the turn's own planned length, so
|
|
@@ -63,21 +106,29 @@ export class IdleWatcher {
|
|
|
63
106
|
// watch short.
|
|
64
107
|
const grows = ending.wake === "limit" || (ending.seen?.verdict === "settled" && ending.seen.small);
|
|
65
108
|
this.grownFromMs = grows ? limitMs : undefined;
|
|
109
|
+
const watch = {
|
|
110
|
+
limitMs,
|
|
111
|
+
askedMs: asked.askedMs,
|
|
112
|
+
wake: ending.wake,
|
|
113
|
+
looks: ending.looks,
|
|
114
|
+
...(ending.area === undefined ? {} : { area: { ...ending.area } }),
|
|
115
|
+
};
|
|
116
|
+
if (unasked !== undefined)
|
|
117
|
+
session.trace.record("notice", () => ({
|
|
118
|
+
lifecycle: "completed",
|
|
119
|
+
title: "watched the screen",
|
|
120
|
+
text: unasked,
|
|
121
|
+
watch: { ...watch },
|
|
122
|
+
}));
|
|
66
123
|
return {
|
|
67
124
|
wake: ending.wake,
|
|
68
125
|
...(ending.observation === undefined ? {} : { observation: ending.observation }),
|
|
69
126
|
...(ending.stop === undefined ? {} : { stop: ending.stop }),
|
|
70
|
-
hint: hintFor(ending),
|
|
127
|
+
hint: hintFor(ending, asked.lead),
|
|
71
128
|
waits: {
|
|
72
129
|
...splitWatch(lengths, limitMs, ending.ms),
|
|
73
130
|
sessionEnded: ending.stop !== undefined,
|
|
74
|
-
watch
|
|
75
|
-
limitMs,
|
|
76
|
-
askedMs: asked.reduce((sum, ms) => sum + ms, 0),
|
|
77
|
-
wake: ending.wake,
|
|
78
|
-
looks: ending.looks,
|
|
79
|
-
...(ending.area === undefined ? {} : { area: { ...ending.area } }),
|
|
80
|
-
},
|
|
131
|
+
watch,
|
|
81
132
|
},
|
|
82
133
|
};
|
|
83
134
|
}
|
|
@@ -198,17 +249,21 @@ function compareLook(frames, referenceKey, observation, atMs) {
|
|
|
198
249
|
}
|
|
199
250
|
/**
|
|
200
251
|
* Each wait's part of a watch that lasted `ms`: the limit is shared among the waits in proportion
|
|
201
|
-
* to their planned lengths, and the watch covers them in order. Later waits get 0.
|
|
252
|
+
* to their planned lengths, and the watch covers them in order. Later waits get 0. Waits that all
|
|
253
|
+
* planned 0 ms (a held participant's, watched as a wait with no length) give the first all of it.
|
|
202
254
|
*/
|
|
203
|
-
function splitWatch(
|
|
255
|
+
function splitWatch(planned, limitMs, ms) {
|
|
256
|
+
const lengths = planned.every((length) => length === 0)
|
|
257
|
+
? planned.map((_, index) => (index === 0 ? 1 : 0))
|
|
258
|
+
: planned;
|
|
204
259
|
const plannedMs = lengths.reduce((sum, length) => sum + length, 0);
|
|
205
260
|
const starts = [];
|
|
206
261
|
const ends = [];
|
|
207
|
-
let
|
|
262
|
+
let sharedMs = 0;
|
|
208
263
|
for (const length of lengths) {
|
|
209
|
-
starts.push(Math.floor((limitMs *
|
|
210
|
-
|
|
211
|
-
ends.push(Math.floor((limitMs *
|
|
264
|
+
starts.push(Math.floor((limitMs * sharedMs) / plannedMs));
|
|
265
|
+
sharedMs += length;
|
|
266
|
+
ends.push(Math.floor((limitMs * sharedMs) / plannedMs));
|
|
212
267
|
}
|
|
213
268
|
const found = ends.findIndex((shareEnd, index) => shareEnd > starts[index] && ms <= shareEnd);
|
|
214
269
|
const endedOn = found === -1 ? lengths.length - 1 : found;
|
|
@@ -218,13 +273,14 @@ function splitWatch(lengths, limitMs, ms) {
|
|
|
218
273
|
};
|
|
219
274
|
}
|
|
220
275
|
/**
|
|
221
|
-
* The next request's note
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
276
|
+
* The next request's note, its subject `lead` naming the wait ("Your wait", or for a watch no wait
|
|
277
|
+
* asked for, the wait before the screenshot or after the action). A clock ending says only how
|
|
278
|
+
* long the wait lasted. A small change is named by its place, so a participant is not told the
|
|
279
|
+
* screen changed when what changed was a counter or a timestamp. The note says nothing of the next
|
|
280
|
+
* wait's length: a participant told it names that length and keeps waiting.
|
|
225
281
|
*/
|
|
226
|
-
function hintFor(ending) {
|
|
227
|
-
const lasted =
|
|
282
|
+
function hintFor(ending, lead) {
|
|
283
|
+
const lasted = `${lead} lasted ${duration(ending.ms)}`;
|
|
228
284
|
const { seen } = ending;
|
|
229
285
|
switch (ending.wake) {
|
|
230
286
|
case "changed":
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { closingReportSchema } from "../closing-report.js";
|
|
2
2
|
import { ComputerUseProviderError } from "./provider-error.js";
|
|
3
3
|
import { runActionBatch } from "./loop/actions.js";
|
|
4
|
-
import { advanceBackstop, startBackstop, } from "./loop/backstop.js";
|
|
4
|
+
import { advanceBackstop, repeatsPastLimit, startBackstop, } from "./loop/backstop.js";
|
|
5
5
|
import { clockEndStop, clockHint, heldToScheduledEnd } from "./loop/clock.js";
|
|
6
6
|
import { requestDebrief } from "./loop/debrief.js";
|
|
7
7
|
import * as stops from "./loop/ending.js";
|
|
@@ -62,6 +62,7 @@ export async function runComputerUseLoopWithTaps(options) {
|
|
|
62
62
|
catch (error) {
|
|
63
63
|
stop = session.conclude(stops.stopForError(session, error));
|
|
64
64
|
}
|
|
65
|
+
session.streak.close();
|
|
65
66
|
// A structured stop earns the debrief even if recording its evidence then failed.
|
|
66
67
|
const debrief = session.debriefTrigger === undefined
|
|
67
68
|
? undefined
|
|
@@ -144,12 +145,21 @@ async function runTurns(session, conversation) {
|
|
|
144
145
|
? { ...ended, debriefTrigger: { kind: "participant_end", observation } }
|
|
145
146
|
: ended;
|
|
146
147
|
}
|
|
147
|
-
const
|
|
148
|
-
const
|
|
148
|
+
const limits = { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps };
|
|
149
|
+
const repeat = repeatsPastLimit(backstop, turn.actions, limits) && heldToScheduledEnd(session);
|
|
150
|
+
const waited = repeat ? undefined : await watcher.watch(turnNumber, observation, turn.actions);
|
|
151
|
+
const batch = await runActionBatch(session, turn.actions, waited?.waits);
|
|
152
|
+
const watched = repeat
|
|
153
|
+
? await watcher.watchAfterRepeat(turnNumber, observation, batch.sent)
|
|
154
|
+
: waited;
|
|
149
155
|
conversation.previousExecution = batch.execution;
|
|
150
156
|
// An earlier reply's unknown usage still stops the session before its closing request.
|
|
151
|
-
if (watched?.stop !== undefined)
|
|
157
|
+
if (watched?.stop !== undefined) {
|
|
158
|
+
// The repeat was sent before its watch ended the session, so its streak counts it.
|
|
159
|
+
if (repeat)
|
|
160
|
+
session.streak.fold(backstop.consecutiveNoProgress + 1);
|
|
152
161
|
return unknownSpendStop(session) ?? watched.stop;
|
|
162
|
+
}
|
|
153
163
|
const checkpoint = await observer.checkpoint(turnNumber, watched?.observation);
|
|
154
164
|
if ("stop" in checkpoint)
|
|
155
165
|
return checkpoint.stop;
|
|
@@ -158,11 +168,12 @@ async function runTurns(session, conversation) {
|
|
|
158
168
|
actions: turn.actions,
|
|
159
169
|
observation,
|
|
160
170
|
heardNewSpeech: observer.heardNewSpeech,
|
|
171
|
+
sent: batch.sent,
|
|
161
172
|
waitingIsTheTask: heldToScheduledEnd(session),
|
|
162
173
|
screenChanged: watched?.wake === "changed" ||
|
|
163
174
|
watched?.wake === "speech" ||
|
|
164
175
|
watched?.wake === "condition",
|
|
165
|
-
},
|
|
176
|
+
}, limits);
|
|
166
177
|
backstop = step.backstop;
|
|
167
178
|
const stalled = applyBackstop(session, conversation, step, [
|
|
168
179
|
watched?.hint,
|
|
@@ -187,7 +198,7 @@ function keepWorking(session, conversation, backstop, observation) {
|
|
|
187
198
|
conversation.askedToEnd = true;
|
|
188
199
|
// The turn executed nothing, so the next request acknowledges nothing.
|
|
189
200
|
conversation.previousExecution = undefined;
|
|
190
|
-
const step = advanceBackstop(backstop, { actions: [], observation, heardNewSpeech: false }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
|
|
201
|
+
const step = advanceBackstop(backstop, { actions: [], observation, heardNewSpeech: false, sent: false }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
|
|
191
202
|
const stalled = applyBackstop(session, conversation, step, []);
|
|
192
203
|
return stalled === undefined ? { backstop: step.backstop } : { stop: stalled };
|
|
193
204
|
}
|
|
@@ -201,6 +212,7 @@ function applyBackstop(session, conversation, step, turnHints) {
|
|
|
201
212
|
session.trace.bump("idleTurns");
|
|
202
213
|
if (!step.progressed)
|
|
203
214
|
session.trace.bump("noProgressTurns");
|
|
215
|
+
session.streak.fold(step.backstop.consecutiveNoProgress);
|
|
204
216
|
const hints = [...turnHints.filter((hint) => hint !== undefined), ...step.hints];
|
|
205
217
|
if (hints.length > 0)
|
|
206
218
|
conversation.contextHint = hints.join(" ");
|