humanish 0.116.0 → 0.118.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.
Files changed (120) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
  3. package/dist/actors/codex/restricted-participant-policy.js +4 -32
  4. package/dist/actors/codex/restricted-participant.d.ts +5 -0
  5. package/dist/actors/codex/restricted-participant.js +3 -2
  6. package/dist/actors/computer-use/actor.d.ts +4 -0
  7. package/dist/actors/computer-use/actor.js +2 -0
  8. package/dist/actors/computer-use/loop/actions.d.ts +4 -4
  9. package/dist/actors/computer-use/loop/actions.js +25 -18
  10. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  11. package/dist/actors/computer-use/loop/session.js +9 -1
  12. package/dist/actors/computer-use/loop/trace.js +1 -0
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -14
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/wait.d.ts +74 -0
  17. package/dist/actors/computer-use/wait.js +100 -0
  18. package/dist/actors/contract.d.ts +14 -0
  19. package/dist/analysis/admission.d.ts +7 -2
  20. package/dist/analysis/admission.js +12 -3
  21. package/dist/analysis/automatic-config.d.ts +6 -1
  22. package/dist/analysis/automatic-config.js +12 -11
  23. package/dist/analysis/execute.d.ts +19 -7
  24. package/dist/analysis/execute.js +55 -17
  25. package/dist/browser-control/protocol.d.ts +86 -6
  26. package/dist/browser-control/protocol.js +13 -6
  27. package/dist/cli/commands/analyze.js +2 -1
  28. package/dist/cli/commands/study-format.js +8 -11
  29. package/dist/cli/commands/study-route-computer-use.js +2 -2
  30. package/dist/cli/commands/study-route-scripted.js +2 -2
  31. package/dist/cli/commands/study-route-shared-world.js +5 -3
  32. package/dist/cli/commands/study-route-terminal.js +2 -2
  33. package/dist/cli/commands/study-run.js +3 -3
  34. package/dist/cli/commands/study.js +2 -2
  35. package/dist/cli/doctor.js +11 -1
  36. package/dist/cli/io.js +3 -2
  37. package/dist/comms/lease-store.d.ts +1 -1
  38. package/dist/comms/lease-store.js +3 -2
  39. package/dist/comms/receiving-types.d.ts +2 -0
  40. package/dist/comms/receiving-types.js +2 -0
  41. package/dist/guest/desktop-executor.js +3 -1
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
  44. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  45. package/dist/routes/computer-use/fanout-records.js +2 -1
  46. package/dist/routes/computer-use/local-vm.js +5 -2
  47. package/dist/routes/computer-use/participant-model.js +9 -0
  48. package/dist/routes/computer-use/participant-runs.js +3 -9
  49. package/dist/routes/computer-use/plan.d.ts +3 -1
  50. package/dist/routes/computer-use/plan.js +29 -9
  51. package/dist/routes/computer-use/single-bundle.js +7 -4
  52. package/dist/routes/computer-use/subject-projection.js +2 -0
  53. package/dist/routes/computer-use/types.d.ts +1 -1
  54. package/dist/routes/scripted/plan.d.ts +4 -1
  55. package/dist/routes/scripted/plan.js +25 -2
  56. package/dist/routes/scripted/setup.js +1 -4
  57. package/dist/routes/scripted/types.d.ts +1 -1
  58. package/dist/routes/shared-world/participant-specs.js +2 -2
  59. package/dist/routes/shared-world/plan.d.ts +3 -1
  60. package/dist/routes/shared-world/plan.js +39 -7
  61. package/dist/routes/shared-world/route.js +2 -2
  62. package/dist/routes/terminal/live-sandbox.d.ts +1 -1
  63. package/dist/routes/terminal/live-sandbox.js +14 -11
  64. package/dist/routes/terminal/sandbox.d.ts +4 -3
  65. package/dist/routes/terminal/sandbox.js +5 -5
  66. package/dist/run/bundle-shape.js +2 -1
  67. package/dist/run/bundle.d.ts +5 -3
  68. package/dist/run/projection.js +7 -2
  69. package/dist/run/route-shell.js +10 -5
  70. package/dist/run/subject-name.d.ts +11 -0
  71. package/dist/run/subject-name.js +11 -0
  72. package/dist/study/automatic-analysis-plan.d.ts +28 -0
  73. package/dist/study/automatic-analysis-plan.js +57 -0
  74. package/dist/study/composition-rules.js +1 -1
  75. package/dist/study/config.js +10 -21
  76. package/dist/study/doctor.js +17 -3
  77. package/dist/study/keys.d.ts +2 -0
  78. package/dist/study/keys.js +2 -0
  79. package/dist/study/migrate/v2.js +2 -1
  80. package/dist/study/parse/actors.d.ts +1 -1
  81. package/dist/study/parse/actors.js +9 -1
  82. package/dist/study/parse/front.d.ts +1 -1
  83. package/dist/study/parse/front.js +7 -1
  84. package/dist/study/plan-base.d.ts +2 -0
  85. package/dist/study/plan-base.js +3 -0
  86. package/dist/study/plan-participants.d.ts +4 -0
  87. package/dist/study/plan-participants.js +6 -5
  88. package/dist/study/plan-types.d.ts +8 -3
  89. package/dist/study/plan.d.ts +4 -4
  90. package/dist/study/plan.js +21 -12
  91. package/dist/study/preflight-result.js +2 -2
  92. package/dist/study/preflight.d.ts +2 -1
  93. package/dist/study/preflight.js +12 -3
  94. package/dist/study/routing.d.ts +5 -4
  95. package/dist/study/routing.js +16 -7
  96. package/dist/study/summary.d.ts +3 -3
  97. package/dist/study/summary.js +3 -5
  98. package/dist/study/types.d.ts +14 -0
  99. package/dist/study/validation.d.ts +7 -2
  100. package/dist/study/validation.js +30 -14
  101. package/dist/study/warnings.js +12 -0
  102. package/dist/subject/serve.d.ts +3 -0
  103. package/dist/subject/serve.js +4 -1
  104. package/dist/substrates/detached.js +14 -7
  105. package/dist/substrates/e2b/desktop-executor.d.ts +0 -2
  106. package/dist/substrates/e2b/desktop-executor.js +3 -3
  107. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  108. package/dist/substrates/e2b/lifetime.js +134 -10
  109. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  110. package/dist/tui-app.js +119 -118
  111. package/dist/verify/artifacts.d.ts +19 -4
  112. package/dist/verify/artifacts.js +39 -8
  113. package/dist/verify/verify.js +6 -15
  114. package/docs/architecture/browser-control.md +10 -4
  115. package/docs/contracts/run-bundle.md +7 -6
  116. package/docs/contracts/schemas.md +33 -19
  117. package/docs/contracts/study-analysis.md +2 -2
  118. package/docs/principles/invariants-and-defaults.md +1 -1
  119. package/docs/product/automatic-analysis.md +4 -3
  120. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,46 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.118.0: Up to 100 participants, E2B concurrent sandboxes, study check plans like run, analysis range (2026-10-09)
12
+
13
+ humanish 0.118.0 lets a computer-use study have up to 100 participants, up from 16, holds a
14
+ shared-world study to the same 100, and runs them within the number of sandboxes the E2B plan runs
15
+ at once. The new setting `HUMANISH_E2B_MAX_CONCURRENT_SANDBOXES` gives that number (20 when unset,
16
+ E2B Hobby's limit; Pro runs 100). A study with more participants than that runs them in waves, an
17
+ `execution.concurrency` above it is refused before any sandbox is created, and `doctor` shows it. A
18
+ live run of more than 16 participants records its automatic analysis as skipped with
19
+ `AUTOMATIC_ANALYSIS_PARTICIPANT_LIMIT`, because one analysis reads at most 16 participants. `study
20
+ check` now plans a study the way `humanish run` does and fails a study the run refuses, and a
21
+ shared-world or scripted clone study whose sandbox deadline passes the ceiling is refused at plan
22
+ time. The analysis line that `run`, `study check` and the TUI print ends its expected range at the
23
+ largest run admission admits (`$0.75 to $2.83` for one participant at the default $3 cap, where it
24
+ read `$3.79`) and states the rule as admission applies it. An in-process `local-app` run records
25
+ `subject.source: local-app`, a desktop-cli dry run names its product, and `verify` names an unlisted
26
+ folder and the 10,000-entry stop in their own sentences. In `humanish tui`, the key legend says what
27
+ Enter and Esc do while an action is armed, a study's median cost includes its analyses, and Esc
28
+ after entering a key returns to the screen the keys were opened from.
29
+
30
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.118.0)
31
+
32
+ ## 0.117.0: Long participant waits, OpenAI wait length, serve.build fails fast, TUI confirmations (2026-10-09)
33
+
34
+ humanish 0.117.0 lets a participant wait through a long hosted study, gives an OpenAI
35
+ participant's wait a usable length, and fails a broken `serve.build` within seconds. A
36
+ participant's wait longer than one desktop call now lasts as long as it asked, up to the new study
37
+ field `actor.maxWaitMs` (1 second to 10 minutes; 2 minutes by default, 30 seconds on a desktop with
38
+ speech), and goes to the desktop in steps of at most 30 seconds. A Codex participant's longer wait
39
+ was cut to 30 seconds before. An `openai-computer-use` participant's `wait` names no duration and
40
+ lasted half a second on a hosted desktop; it now lasts `actor.idleWaitMs` (5 seconds by default) in
41
+ a turn that only waits, and 500 ms after an action in the same turn. The participant's `actor.json`
42
+ records these lengths as `waitSettings`. A `serve.install`, `serve.build` or seed step that bash
43
+ cannot parse fails within seconds with bash's error, where the run waited out the step's limit (10
44
+ minutes for `serve.build`), and a failed `serve.build` on the computer-use route has its own error
45
+ code. In `humanish tui`, a two-Enter confirmation ends when the cursor leaves it. The terminal
46
+ route's check after its sandbox kill carries the run's E2B key, and browser Back in the Observer
47
+ clears a stale saved-moment message.
48
+
49
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.117.0)
50
+
11
51
  ## 0.116.0: Hosted keyboard input, E2B sandbox ceiling, bounded run reads, run header, TUI live starts (2026-10-09)
12
52
 
13
53
  humanish 0.116.0 delivers a hosted participant's arrow keys and punctuation, lets the operator
@@ -10,8 +10,6 @@ export declare const PARTICIPANT_LIMITS: Readonly<{
10
10
  narration: 2000;
11
11
  output: number;
12
12
  actions: 4;
13
- /** The longest wait one action lasts; a longer one is shortened to it. */
14
- waitMs: 30000;
15
13
  requestMs: 180000;
16
14
  cleanupMs: 5000;
17
15
  }>;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { BROWSER_CONTROL_LIMITS, browserControlActionSchema, browserOnlyControlActionSchema, validateBrowserControlAction, } from "../../browser-control/protocol.js";
2
+ import { participantActionSchema, participantSpeechActionSchema, } from "../../browser-control/protocol.js";
3
3
  import { closingReportSchema, strictOutputSchema, } from "../closing-report.js";
4
4
  /** The declared profile. A participant replaces cliVersion with its detected CLI release, or with
5
5
  * this host's newest qualified release before its first launch. */
@@ -21,15 +21,13 @@ export const PARTICIPANT_LIMITS = Object.freeze({
21
21
  narration: 2000,
22
22
  output: 256 * 1024,
23
23
  actions: 4,
24
- /** The longest wait one action lasts; a longer one is shortened to it. */
25
- waitMs: BROWSER_CONTROL_LIMITS.waitMs,
26
24
  requestMs: 180_000,
27
25
  cleanupMs: 5000,
28
26
  });
29
27
  const toolInput = (speechEnabled) => z.strictObject({
30
28
  narration: z.string().max(PARTICIPANT_LIMITS.narration),
31
29
  actions: z
32
- .array(speechEnabled ? browserControlActionSchema : browserOnlyControlActionSchema)
30
+ .array(speechEnabled ? participantSpeechActionSchema : participantActionSchema)
33
31
  .min(1)
34
32
  .max(PARTICIPANT_LIMITS.actions),
35
33
  });
@@ -43,41 +41,15 @@ const participantFinal = z.strictObject({
43
41
  ...closingReportSchema.shape,
44
42
  });
45
43
  export const PARTICIPANT_FINAL_SCHEMA = strictOutputSchema(participantFinal);
46
- /**
47
- * Shorten each wait longer than one browser-control request may wait. A failed tool call ends the
48
- * native run, so a wait that asks too much would otherwise end the whole session.
49
- */
50
- function shortenWaits(value) {
51
- const actions = value?.actions;
52
- if (typeof value !== "object" || !Array.isArray(actions))
53
- return { value, shortened: [] };
54
- const shortened = [];
55
- const limit = PARTICIPANT_LIMITS.waitMs;
56
- const next = actions.map((action, index) => {
57
- const wait = action;
58
- if (wait?.kind !== "wait" ||
59
- typeof wait.ms !== "number" ||
60
- !Number.isFinite(wait.ms) ||
61
- wait.ms <= limit)
62
- return action;
63
- shortened.push({ index, requestedMs: wait.ms, ms: limit });
64
- return { ...wait, ms: limit };
65
- });
66
- return shortened.length === 0
67
- ? { value, shortened }
68
- : { value: { ...value, actions: next }, shortened };
69
- }
70
44
  /** humanish tool arguments, validated before the shared executor sees a batch. */
71
45
  export function parseParticipantTool(value, speechEnabled = false) {
72
46
  if (Buffer.byteLength(JSON.stringify(value) ?? "") > PARTICIPANT_LIMITS.output)
73
47
  throw new Error("invalid_response");
74
- const { value: admitted, shortened } = shortenWaits(value);
75
- const v = toolInput(speechEnabled).parse(admitted);
48
+ const v = toolInput(speechEnabled).parse(value);
76
49
  if (Buffer.byteLength(v.narration) > PARTICIPANT_LIMITS.narration)
77
50
  throw new Error("invalid_response");
78
51
  return {
79
- actions: v.actions.map(validateBrowserControlAction),
80
- ...(shortened.length === 0 ? {} : { shortenedWaits: shortened }),
52
+ actions: v.actions,
81
53
  pendingSafetyChecks: [],
82
54
  done: false,
83
55
  ...(v.narration ? { message: v.narration } : {}),
@@ -17,6 +17,11 @@ export interface RestrictedParticipantOptions {
17
17
  reasoningEffort?: ReasoningEffort;
18
18
  /** Admit speech actions and heard-speaker evidence for a speech-capable desktop only. */
19
19
  speechEnabled?: boolean;
20
+ /**
21
+ * The study's actor.maxWaitMs, which the loop applies and the tool description states. Default
22
+ * defaultMaxWaitMs(speechEnabled), the same default the loop applies.
23
+ */
24
+ maxWaitMs?: number;
20
25
  }
21
26
  /** What doctor's hosted-participant check found, before any turn. */
22
27
  export interface ParticipantReadiness {
@@ -3,6 +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 { defaultMaxWaitMs } from "../computer-use/wait.js";
6
7
  import { createRestrictedCodexSession, } from "./restricted-session.js";
7
8
  import { PARTICIPANT_PROFILE, PARTICIPANT_LIMITS as L, PARTICIPANT_FINAL_SCHEMA, participantToolSchema, parseParticipantTool, parseParticipantFinal, } from "./restricted-participant-policy.js";
8
9
  import { truncatedFrameWarning, unknownNotificationsWarning } from "./restricted-notifications.js";
@@ -40,7 +41,7 @@ function deferred() {
40
41
  return { promise, resolve, reject };
41
42
  }
42
43
  // prose-check: model prompt (Codex reads this tool description, not a person)
43
- const toolDescription = (speechEnabled) => `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 wait action lasts at most ${L.waitMs} ms; a longer wait is shortened to ${L.waitMs} ms, so wait longer with another call.${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.`;
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 wait action lasts at most ${maxWaitMs} ms; a longer wait is shortened to ${maxWaitMs} ms, so wait longer with another call.${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.`;
44
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.`;
45
46
  /** Turns and failures from the native run, handed in order to the next provider request. */
46
47
  class ParticipantEvents {
@@ -191,7 +192,7 @@ function participantSession(options, speechEnabled, effort, call) {
191
192
  reasoningEffort: effort,
192
193
  tool: {
193
194
  name: "humanish_ui",
194
- description: toolDescription(speechEnabled),
195
+ description: toolDescription(speechEnabled, options.maxWaitMs ?? defaultMaxWaitMs(speechEnabled)),
195
196
  inputSchema: participantToolSchema(speechEnabled),
196
197
  call,
197
198
  },
@@ -38,6 +38,10 @@ export interface CuaActorSessionOptions {
38
38
  acknowledgeSafetyChecks?: (checks: CuaSafetyCheck[]) => CuaSafetyCheck[] | null;
39
39
  idleSteps?: number;
40
40
  noProgressSteps?: number;
41
+ /** The longest one wait action lasts, forwarded to the loop. See LoopRunOptions.maxWaitMs. */
42
+ maxWaitMs?: number;
43
+ /** How long a wait with no duration lasts in an idle turn. See LoopRunOptions.idleWaitMs. */
44
+ idleWaitMs?: number;
41
45
  /**
42
46
  * Redact persisted screenshots (blur+downscale). Default `false`: full fidelity for local use.
43
47
  * Set true for unowned subjects or share-as-is bundles. The provider always sees raw frames.
@@ -31,6 +31,8 @@ export async function runCuaActorSession(options) {
31
31
  ...(options.signal === undefined ? {} : { signal: options.signal }),
32
32
  ...(options.idleSteps === undefined ? {} : { idleSteps: options.idleSteps }),
33
33
  ...(options.noProgressSteps === undefined ? {} : { noProgressSteps: options.noProgressSteps }),
34
+ ...(options.maxWaitMs === undefined ? {} : { maxWaitMs: options.maxWaitMs }),
35
+ ...(options.idleWaitMs === undefined ? {} : { idleWaitMs: options.idleWaitMs }),
34
36
  ...(options.acknowledgeSafetyChecks === undefined
35
37
  ? {}
36
38
  : { acknowledgeSafetyChecks: options.acknowledgeSafetyChecks }),
@@ -1,5 +1,5 @@
1
1
  import type { LoopSession } from "./session.js";
2
- import type { CuaAction, CuaTurnRequest, ShortenedWait } from "./types.js";
2
+ import type { CuaAction, CuaTurnRequest } from "./types.js";
3
3
  /** A public-safe one-line action label. Never includes raw typed text. */
4
4
  export declare function describeCuaAction(action: CuaAction): string;
5
5
  /**
@@ -16,8 +16,8 @@ export interface ActionBatch {
16
16
  /** Host input acknowledgements for the provider's next request. */
17
17
  readonly execution: NonNullable<CuaTurnRequest["previousExecution"]>;
18
18
  /**
19
- * Guidance for the next request, set when the provider shortened a wait or a pre-dispatch
20
- * rejection stopped the batch.
19
+ * Guidance for the next request, set when the loop shortened a wait or a pre-dispatch rejection
20
+ * stopped the batch.
21
21
  */
22
22
  readonly hint: string | undefined;
23
23
  }
@@ -26,4 +26,4 @@ export interface ActionBatch {
26
26
  * leaves the desktop usable; a failed desktop command skips its action; any other failure ends
27
27
  * the session.
28
28
  */
29
- export declare function runActionBatch(session: LoopSession, actions: readonly CuaAction[], shortenedWaits?: readonly ShortenedWait[]): Promise<ActionBatch>;
29
+ export declare function runActionBatch(session: LoopSession, actions: readonly CuaAction[]): Promise<ActionBatch>;
@@ -3,6 +3,7 @@ import { commandFailureInfo, isCommandExitError } from "../../../substrates/comm
3
3
  import { ComputerUseExecutorError, isComputerUseExecutorError, } from "../executor-error.js";
4
4
  import { CuaAbortError, CuaDeadlineError, CuaStallError, raceCallBound, raceSessionDeadline, requestScope, } from "./race.js";
5
5
  import { notice } from "./trace.js";
6
+ import { planWait } from "../wait.js";
6
7
  // The participant's actions: their public labels, what counts as a material action, and how one
7
8
  // turn's batch is dispatched to the desktop.
8
9
  /** Click/scroll coordinates are bucketed to this many pixels before fingerprinting, so a one-pixel
@@ -109,31 +110,31 @@ function rejectedActionHint(title, reason) {
109
110
  const remedy = reason === undefined ? "" : ` ${REJECTION_REMEDIES[reason]}`;
110
111
  return `Your action (${title}) was rejected before dispatch. No input from that action or the rest of its batch was sent.${remedy} Choose your next action from the fresh screenshot; do not assume the rejected action succeeded.`;
111
112
  }
112
- function shortenedWaitHint(wait) {
113
- return `Your wait of ${wait.requestedMs}ms was shortened to ${wait.ms}ms, the longest one wait action lasts. To keep waiting, send another wait.`;
114
- }
115
- /** Record a wait the provider shortened, and return the participant's note about it. */
116
- function recordShortenedWait(session, title, wait) {
117
- session.trace.record("notice", () => notice("warn", "wait shortened", `action: ${title}; requested: ${wait.requestedMs}ms; waited: ${wait.ms}ms, the longest one wait action lasts; the session continued`));
118
- return shortenedWaitHint(wait);
113
+ /** Record a wait the loop shortened, and return the participant's note about it. */
114
+ function recordShortenedWait(session, title, requestedMs) {
115
+ const ms = session.waits.maxWaitMs;
116
+ session.trace.record("notice", () => notice("warn", "wait shortened", `action: ${title}; requested: ${requestedMs}ms; waited: ${ms}ms, the longest one wait action lasts (actor.maxWaitMs); the session continued`));
117
+ return `Your wait of ${requestedMs}ms was shortened to ${ms}ms, the longest one wait action lasts. To keep waiting, send another wait.`;
119
118
  }
120
119
  /**
121
120
  * Dispatch one turn's actions in order. A declared pre-dispatch rejection stops the batch and
122
121
  * leaves the desktop usable; a failed desktop command skips its action; any other failure ends
123
122
  * the session.
124
123
  */
125
- export async function runActionBatch(session, actions, shortenedWaits = []) {
124
+ export async function runActionBatch(session, actions) {
126
125
  const execution = { actions: [] };
127
126
  const { actionHistory, trace } = session;
128
127
  const notes = [];
129
128
  const hint = () => (notes.length === 0 ? undefined : notes.join(" "));
130
- for (const [index, action] of actions.entries()) {
129
+ const idleTurn = isIdleTurn(actions);
130
+ for (const [index, requested] of actions.entries()) {
131
131
  if (session.signal?.aborted)
132
132
  throw new CuaAbortError();
133
+ const wait = requested.kind === "wait" ? planWait(session.waits, requested.ms, idleTurn) : undefined;
134
+ const action = wait === undefined ? requested : { kind: "wait", ms: wait.ms };
133
135
  const title = describeCuaAction(action);
134
- const shortened = shortenedWaits.find((wait) => wait.index === index);
135
- if (shortened !== undefined)
136
- notes.push(recordShortenedWait(session, title, shortened));
136
+ if (wait?.shortenedFromMs !== undefined)
137
+ notes.push(recordShortenedWait(session, title, wait.shortenedFromMs));
137
138
  actionHistory.lastActionTitle = title;
138
139
  actionHistory.recentActionTitles.push(title);
139
140
  if (actionHistory.recentActionTitles.length > RECENT_ACTION_TITLES)
@@ -146,7 +147,7 @@ export async function runActionBatch(session, actions, shortenedWaits = []) {
146
147
  session.phase = `executing ${title}`;
147
148
  let status;
148
149
  try {
149
- status = await dispatchAction(session, action, title);
150
+ status = await dispatchAction(session, action, title, wait?.steps);
150
151
  }
151
152
  catch (error) {
152
153
  const declared = isComputerUseExecutorError(error)
@@ -201,17 +202,23 @@ function countAttempt(session, action, title, status) {
201
202
  }
202
203
  /**
203
204
  * Execute one action. Idle actions only look: a `wait` that hangs inside the SDK has in
204
- * effect waited, so a stalled one is skipped with a notice and loses nothing the participant
205
- * chose. An action is recorded as completed only after execute() resolves.
205
+ * effect waited, so a stalled one is skipped with a notice, its remaining steps are not sent, and
206
+ * it loses nothing the participant chose. An action is recorded as completed only after every
207
+ * desktop call it takes resolves. A wait is sent as `waitSteps`, its planWait steps.
206
208
  */
207
- async function dispatchAction(session, action, title) {
209
+ async function dispatchAction(session, action, title, waitSteps) {
208
210
  if (!isIdleAction(action)) {
209
211
  await executeAction(session, action, title);
210
212
  return "completed";
211
213
  }
212
- const boundMs = session.observationTimeoutMs + (action.kind === "wait" ? (action.ms ?? 0) : 0);
214
+ const calls = waitSteps?.map((ms) => ({ kind: "wait", ms })) ?? [action];
213
215
  try {
214
- await executeAction(session, action, title, boundMs);
216
+ for (const call of calls) {
217
+ if (session.signal?.aborted)
218
+ throw new CuaAbortError();
219
+ const boundMs = session.observationTimeoutMs + (call.kind === "wait" ? (call.ms ?? 0) : 0);
220
+ await executeAction(session, call, title, boundMs);
221
+ }
215
222
  return "completed";
216
223
  }
217
224
  catch (error) {
@@ -5,6 +5,7 @@ import type { DebriefTrigger, Stop } from "./ending.js";
5
5
  import { TraceRecorder } from "./trace.js";
6
6
  import type { CuaExecutor, CuaProvider, CuaSafetyCheck, LoopRunOptions } from "./types.js";
7
7
  import { UsageLedger } from "./usage.js";
8
+ import { type WaitSettings } from "../wait.js";
8
9
  export type ScreenshotRef = NonNullable<ActorTraceItem["screenshotRef"]>;
9
10
  /**
10
11
  * The options with no default, read once at entry and passed through. Defaulted options and
@@ -34,6 +35,8 @@ export declare class LoopSession {
34
35
  readonly timeoutMs: number;
35
36
  readonly idleSteps: number;
36
37
  readonly noProgressSteps: number;
38
+ /** How long each wait lasts: the study's wait fields or their defaults. */
39
+ readonly waits: WaitSettings;
37
40
  readonly redactScreenshots: boolean;
38
41
  readonly now: () => number;
39
42
  readonly acknowledgeSafetyChecks: (checks: CuaSafetyCheck[]) => CuaSafetyCheck[] | null;
@@ -1,6 +1,7 @@
1
1
  import { TaskTracker } from "../../../study/tasks.js";
2
2
  import { TraceRecorder } from "./trace.js";
3
3
  import { UsageLedger } from "./usage.js";
4
+ import { resolveWaitSettings } from "../wait.js";
4
5
  // The state one loop session shares across its phases: the options as read at entry, the session
5
6
  // clock, the trace being recorded, the usage ledger, and what the participant has done so far.
6
7
  // Waiting is a legitimate strategy. A persona verifying a sign-up by email polls its inbox
@@ -24,6 +25,8 @@ export class LoopSession {
24
25
  timeoutMs;
25
26
  idleSteps;
26
27
  noProgressSteps;
28
+ /** How long each wait lasts: the study's wait fields or their defaults. */
29
+ waits;
27
30
  redactScreenshots;
28
31
  // Injected functions are wrapped so they are called without a receiver, as plain functions.
29
32
  now;
@@ -67,7 +70,7 @@ export class LoopSession {
67
70
  scrubText;
68
71
  constructor(options) {
69
72
  // Read once, in this order: a caller mutating its options object mid-run changes nothing.
70
- const { instructions, provider, executor, persona, redaction, timeoutMs, now, signal, idleSteps = DEFAULT_IDLE_STEPS, noProgressSteps = DEFAULT_NO_PROGRESS_STEPS, acknowledgeSafetyChecks = () => null, redactScreenshots = false, scrubText = (text) => text, writeScreenshot = async (name) => `screenshots/${name}`, stopWhen, dwell, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tasks, maxUsd, overRunBudget, estimateTurnCostUsd, requireReportedUsageForSpendCap = false, onObservedUrl, onMessage, onScreenshot, onTrace, } = options;
73
+ const { instructions, provider, executor, persona, redaction, timeoutMs, now, signal, idleSteps = DEFAULT_IDLE_STEPS, noProgressSteps = DEFAULT_NO_PROGRESS_STEPS, maxWaitMs, idleWaitMs, acknowledgeSafetyChecks = () => null, redactScreenshots = false, scrubText = (text) => text, writeScreenshot = async (name) => `screenshots/${name}`, stopWhen, dwell, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tasks, maxUsd, overRunBudget, estimateTurnCostUsd, requireReportedUsageForSpendCap = false, onObservedUrl, onMessage, onScreenshot, onTrace, } = options;
71
74
  this.settings = {
72
75
  instructions,
73
76
  persona,
@@ -89,6 +92,11 @@ export class LoopSession {
89
92
  this.timeoutMs = timeoutMs;
90
93
  this.idleSteps = idleSteps;
91
94
  this.noProgressSteps = noProgressSteps;
95
+ this.waits = resolveWaitSettings({
96
+ maxWaitMs,
97
+ idleWaitMs,
98
+ speechEnabled: executor.speechEnabled === true,
99
+ });
92
100
  this.redactScreenshots = redactScreenshots;
93
101
  this.now = () => now();
94
102
  this.acknowledgeSafetyChecks = (checks) => acknowledgeSafetyChecks(checks);
@@ -123,6 +123,7 @@ export function loopResult(session, stop, debrief) {
123
123
  : { maxOutputTokens: provider.modelSettings.maxOutputTokens }),
124
124
  },
125
125
  }),
126
+ waitSettings: { ...session.waits },
126
127
  counts,
127
128
  items: recorder.items,
128
129
  ...(session.actionHistory.affordances.length > 0
@@ -55,15 +55,6 @@ export type CuaAction = {
55
55
  } | {
56
56
  kind: "screenshot";
57
57
  };
58
- /** A wait action whose duration the provider shortened before the loop dispatched it. */
59
- export interface ShortenedWait {
60
- /** The action's index in its turn. */
61
- index: number;
62
- /** What the participant asked for. */
63
- requestedMs: number;
64
- /** What the action waits: the `ms` it carries. */
65
- ms: number;
66
- }
67
58
  /** A captured desktop state: the (optional) frame plus a coarse signature for progress. */
68
59
  export interface CuaObservation {
69
60
  /**
@@ -171,11 +162,6 @@ export interface CuaTurn {
171
162
  message?: string;
172
163
  /** Actions to perform this turn. Empty means the model is done. */
173
164
  actions: CuaAction[];
174
- /**
175
- * Waits the provider shortened to the longest wait its desktop accepts, by index into
176
- * `actions`. The loop records each one and tells the participant on its next request.
177
- */
178
- shortenedWaits?: ReadonlyArray<ShortenedWait>;
179
165
  /** Safety checks the provider flagged this turn. Non-empty pauses the run. */
180
166
  pendingSafetyChecks: CuaSafetyCheck[];
181
167
  /** Token accounting for this turn, if available. */
@@ -304,6 +290,22 @@ export interface CuaLoopOptions {
304
290
  idleSteps?: number;
305
291
  /** No-progress streak that trips the backstop. Default 20. */
306
292
  noProgressSteps?: number;
293
+ /**
294
+ * The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs. A longer wait is
295
+ * shortened to it, the trace records a `wait shortened` notice, and the participant is told on its
296
+ * next request. A wait longer than one desktop call carries is sent in steps. A whole number from
297
+ * 1000 to 600000; any other value throws a RangeError before the session starts. Default
298
+ * defaultMaxWaitMs: 120000, or 30000 when the executor is speechEnabled.
299
+ */
300
+ maxWaitMs?: number;
301
+ /**
302
+ * How long a `wait` that names no duration lasts, in ms, when its turn only waits or takes
303
+ * screenshots: the study's actor.idleWaitMs. After an action in the same turn such a wait lasts
304
+ * 500 ms. A whole number from 1000 to 600000 and no longer than an explicit maxWaitMs; any other
305
+ * value throws a RangeError before the session starts. Default 5000. The session never applies
306
+ * more than its maxWaitMs, and the trace records the applied lengths as `waitSettings`.
307
+ */
308
+ idleWaitMs?: number;
307
309
  /**
308
310
  * If the model flags safety checks, decide which to acknowledge. Returning the
309
311
  * list proceeds (the acks are echoed back on the next turn's request); returning
@@ -1,5 +1,5 @@
1
1
  import type { CuaLoopOptions, CuaLoopResult, LoopRunOptions } from "./loop/types.js";
2
- export type { CuaAction, CuaExecutor, CuaLiveMetadata, CuaLoopOptions, CuaLoopResult, CuaObservation, CuaProvider, CuaSafetyCheck, CuaSpendGate, CuaTurn, CuaTurnRequest, ShortenedWait, } from "./loop/types.js";
2
+ export type { CuaAction, CuaExecutor, CuaLiveMetadata, CuaLoopOptions, CuaLoopResult, CuaObservation, CuaProvider, CuaSafetyCheck, CuaSpendGate, CuaTurn, CuaTurnRequest, } from "./loop/types.js";
3
3
  export { describeCuaAction } from "./loop/actions.js";
4
4
  export { stableProgressKey } from "./loop/backstop.js";
5
5
  export { IMPRESSIONS_ASK } from "./loop/impressions.js";
@@ -133,7 +133,7 @@ async function runTurns(session, conversation) {
133
133
  ? { ...ended, debriefTrigger: { kind: "participant_end", observation } }
134
134
  : ended;
135
135
  }
136
- const batch = await runActionBatch(session, turn.actions, turn.shortenedWaits);
136
+ const batch = await runActionBatch(session, turn.actions);
137
137
  conversation.previousExecution = batch.execution;
138
138
  const checkpoint = await observer.checkpoint(turnNumber);
139
139
  if ("stop" in checkpoint)
@@ -0,0 +1,74 @@
1
+ import type { ActorWaitSettings } from "../contract.js";
2
+ export declare const CUA_WAIT_LIMITS: Readonly<{
3
+ /**
4
+ * The longest wait one desktop call is given. A browser-control request must be answered within
5
+ * its 35 s deadline, and a hosted desktop runs a wait as a sandbox command that times out after
6
+ * 60 s, so the loop sends a longer wait as consecutive steps of at most this.
7
+ */
8
+ stepMs: 30000;
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
12
+ * arrive, and the participant still sees a fresh screenshot at least that often.
13
+ */
14
+ defaultMaxMs: 120000;
15
+ /**
16
+ * How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
17
+ * when a study does not set actor.idleWaitMs. OpenAI's computer-use `wait` names no duration. A
18
+ * 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.
20
+ */
21
+ defaultIdleMs: 5000;
22
+ /**
23
+ * How long a wait that names no duration lasts after the participant acts in the same turn, so
24
+ * 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.
26
+ */
27
+ settleMs: 500;
28
+ /** The smallest actor.maxWaitMs or actor.idleWaitMs a study may set. */
29
+ leastMs: 1000;
30
+ /** The largest actor.maxWaitMs or actor.idleWaitMs a study may set. */
31
+ mostMs: 600000;
32
+ }>;
33
+ /** The wait lengths one session applies; the trace records them as `waitSettings`. */
34
+ export type WaitSettings = Readonly<ActorWaitSettings>;
35
+ /** One wait as the desktop runs it. */
36
+ export interface PlannedWait {
37
+ /** How long the wait lasts. */
38
+ readonly ms: number;
39
+ /** The desktop calls it takes, in order, each at most CUA_WAIT_LIMITS.stepMs. */
40
+ readonly steps: readonly number[];
41
+ /** What the participant asked for, when that was longer than maxWaitMs. */
42
+ readonly shortenedFromMs?: number;
43
+ }
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
+ /**
52
+ * Why these wait fields cannot run, or null. `prefix` names their owner in the message: `actor.`
53
+ * in a study file, empty for loop options.
54
+ */
55
+ export declare function waitFieldsReason(fields: {
56
+ readonly maxWaitMs?: unknown;
57
+ readonly idleWaitMs?: unknown;
58
+ }, prefix: string): string | null;
59
+ /**
60
+ * The wait lengths a session applies: the study's values, or their defaults. The idle wait never
61
+ * outlasts the longest wait, which on a desktop with speech defaults to 30 s. Throws a RangeError
62
+ * for fields waitFieldsReason refuses.
63
+ */
64
+ export declare function resolveWaitSettings(options: {
65
+ readonly maxWaitMs?: number | undefined;
66
+ readonly idleWaitMs?: number | undefined;
67
+ readonly speechEnabled: boolean;
68
+ }): WaitSettings;
69
+ /**
70
+ * How long one wait lasts and the desktop calls it takes. A wait that names no duration lasts
71
+ * idleWaitMs when its turn only waits or takes screenshots (`idleTurn`), and settleWaitMs after an
72
+ * action. A longer wait than maxWaitMs is shortened to it. 70 s is sent as 30 s, 30 s and 10 s.
73
+ */
74
+ export declare function planWait(settings: WaitSettings, requestedMs: number | undefined, idleTurn: boolean): PlannedWait;
@@ -0,0 +1,100 @@
1
+ // How long one participant wait action lasts, for every provider and desktop. A study sets the
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.
5
+ export const CUA_WAIT_LIMITS = Object.freeze({
6
+ /**
7
+ * The longest wait one desktop call is given. A browser-control request must be answered within
8
+ * its 35 s deadline, and a hosted desktop runs a wait as a sandbox command that times out after
9
+ * 60 s, so the loop sends a longer wait as consecutive steps of at most this.
10
+ */
11
+ stepMs: 30_000,
12
+ /**
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
+ * arrive, and the participant still sees a fresh screenshot at least that often.
16
+ */
17
+ defaultMaxMs: 120_000,
18
+ /**
19
+ * How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
20
+ * when a study does not set actor.idleWaitMs. OpenAI's computer-use `wait` names no duration. A
21
+ * 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.
23
+ */
24
+ defaultIdleMs: 5_000,
25
+ /**
26
+ * How long a wait that names no duration lasts after the participant acts in the same turn, so
27
+ * 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.
29
+ */
30
+ settleMs: 500,
31
+ /** The smallest actor.maxWaitMs or actor.idleWaitMs a study may set. */
32
+ leastMs: 1_000,
33
+ /** The largest actor.maxWaitMs or actor.idleWaitMs a study may set. */
34
+ mostMs: 600_000,
35
+ });
36
+ /** A whole number of milliseconds within the range actor.maxWaitMs and actor.idleWaitMs accept. */
37
+ function isWaitMs(value) {
38
+ return (typeof value === "number" &&
39
+ Number.isSafeInteger(value) &&
40
+ value >= CUA_WAIT_LIMITS.leastMs &&
41
+ value <= CUA_WAIT_LIMITS.mostMs);
42
+ }
43
+ /**
44
+ * The longest one wait action lasts when a study does not set actor.maxWaitMs. On a desktop with
45
+ * speech, heard speech reaches the participant only with a screenshot, and the desktop session ends
46
+ * when more utterances arrive between two screenshots than it holds. A wait takes no screenshot, so
47
+ * there the default keeps one wait to one desktop call.
48
+ */
49
+ export function defaultMaxWaitMs(speechEnabled) {
50
+ return speechEnabled ? CUA_WAIT_LIMITS.stepMs : CUA_WAIT_LIMITS.defaultMaxMs;
51
+ }
52
+ 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"],
55
+ ];
56
+ /**
57
+ * Why these wait fields cannot run, or null. `prefix` names their owner in the message: `actor.`
58
+ * in a study file, empty for loop options.
59
+ */
60
+ export function waitFieldsReason(fields, prefix) {
61
+ for (const [name, meaning] of FIELDS) {
62
+ if (fields[name] !== undefined && !isWaitMs(fields[name]))
63
+ return `${prefix}${name}, ${meaning}, must be a whole number of milliseconds from ${CUA_WAIT_LIMITS.leastMs} to ${CUA_WAIT_LIMITS.mostMs}.`;
64
+ }
65
+ const { maxWaitMs, idleWaitMs } = fields;
66
+ if (typeof maxWaitMs === "number" && typeof idleWaitMs === "number" && idleWaitMs > maxWaitMs)
67
+ return `${prefix}idleWaitMs (${idleWaitMs}) is longer than ${prefix}maxWaitMs (${maxWaitMs}), the longest one wait action lasts. Lower ${prefix}idleWaitMs or raise ${prefix}maxWaitMs.`;
68
+ return null;
69
+ }
70
+ /**
71
+ * The wait lengths a session applies: the study's values, or their defaults. The idle wait never
72
+ * outlasts the longest wait, which on a desktop with speech defaults to 30 s. Throws a RangeError
73
+ * for fields waitFieldsReason refuses.
74
+ */
75
+ export function resolveWaitSettings(options) {
76
+ const reason = waitFieldsReason(options, "");
77
+ if (reason !== null)
78
+ throw new RangeError(reason);
79
+ const maxWaitMs = options.maxWaitMs ?? defaultMaxWaitMs(options.speechEnabled);
80
+ return {
81
+ maxWaitMs,
82
+ idleWaitMs: Math.min(options.idleWaitMs ?? CUA_WAIT_LIMITS.defaultIdleMs, maxWaitMs),
83
+ settleWaitMs: CUA_WAIT_LIMITS.settleMs,
84
+ };
85
+ }
86
+ /**
87
+ * How long one wait lasts and the desktop calls it takes. A wait that names no duration lasts
88
+ * idleWaitMs when its turn only waits or takes screenshots (`idleTurn`), and settleWaitMs after an
89
+ * action. A longer wait than maxWaitMs is shortened to it. 70 s is sent as 30 s, 30 s and 10 s.
90
+ */
91
+ export function planWait(settings, requestedMs, idleTurn) {
92
+ const askedMs = requestedMs ?? (idleTurn ? settings.idleWaitMs : settings.settleWaitMs);
93
+ const ms = Math.min(askedMs, settings.maxWaitMs);
94
+ const { stepMs } = CUA_WAIT_LIMITS;
95
+ const steps = [];
96
+ for (let left = ms; left > stepMs; left -= stepMs)
97
+ steps.push(stepMs);
98
+ steps.push(ms - steps.length * stepMs);
99
+ return { ms, steps, ...(askedMs > ms ? { shortenedFromMs: askedMs } : {}) };
100
+ }