humanish 0.132.0 → 0.133.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 CHANGED
@@ -8,6 +8,19 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.133.0: An OpenAI participant names how long it waits (2026-10-14)
12
+
13
+ humanish 0.133.0 lets an OpenAI computer-use participant name how long it waits, with a
14
+ `wait_minutes` tool, and cuts what a participant costs while nothing on its screen changes. A turn of
15
+ only waits is watched, and each further quiet wait in a row lasts twice as long, starting from the
16
+ length the participant named, up to 10 minutes. In live 60-minute runs against 0.132.0, median model
17
+ spend per participant-hour fell 13% on a resident portal, 52% on a support queue, 46% on an issue
18
+ list that shows new issues only on reload, and 78% on a dispatch board, and all 6 participants on
19
+ the issue list reloaded and reported every new issue. verify and analysis also no longer fail on a
20
+ run whose traces together pass 512 MiB.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.133.0)
23
+
11
24
  ## 0.132.0: An OpenAI participant's closing report reads the prompt cache (2026-10-13)
12
25
 
13
26
  humanish 0.132.0 cuts the cost of an OpenAI computer-use participant's closing report. The closing
@@ -22,6 +22,8 @@ export interface RestrictedParticipantOptions {
22
22
  * defaults the loop applies without it (resolveWaitSettings).
23
23
  */
24
24
  maxWaitMs?: number;
25
+ /** The participant's idleWaitMs, the shortest a call of only waits lasts, stated the same way. */
26
+ idleWaitMs?: number;
25
27
  }
26
28
  /** What doctor's hosted-participant check found, before any turn. */
27
29
  export interface ParticipantReadiness {
@@ -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, 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.`;
44
+ const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs, idleWaitMs, }) => `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 and that asks for some wait time asks to wait its waits' ms together; an ask under ${idleWaitMs} ms counts as ${idleWaitMs} ms, and named ms count up to ${watchMaxMs} ms${speechEnabled ? ". It returns when speech is heard" : ""}. 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,11 @@ function participantSession(options, speechEnabled, effort, call) {
192
192
  reasoningEffort: effort,
193
193
  tool: {
194
194
  name: "humanish_ui",
195
- description: toolDescription(speechEnabled, resolveWaitSettings({ maxWaitMs: options.maxWaitMs, speechEnabled })),
195
+ description: toolDescription(speechEnabled, resolveWaitSettings({
196
+ maxWaitMs: options.maxWaitMs,
197
+ idleWaitMs: options.idleWaitMs,
198
+ speechEnabled,
199
+ })),
196
200
  inputSchema: participantToolSchema(speechEnabled),
197
201
  call,
198
202
  },
@@ -209,6 +209,7 @@ export async function runActionBatch(session, actions, watched) {
209
209
  ? { coord: { x: action.x, y: action.y } }
210
210
  : {}),
211
211
  ...(watched !== undefined && waitIndex === watched.endedOn ? { watch: watched.watch } : {}),
212
+ ...(requested.kind === "wait" && requested.ms !== undefined ? { namedMs: requested.ms } : {}),
212
213
  }));
213
214
  }
214
215
  return { execution, hint: hint() };
@@ -328,12 +328,12 @@ export interface CuaLoopOptions {
328
328
  maxWaitMs?: number;
329
329
  /**
330
330
  * How long a `wait` that names no duration lasts, in ms, when its turn only waits or takes
331
- * screenshots: the study's actor.idleWaitMs. It is also how often the loop looks at the screen
332
- * while such a turn is watched, at most every 30000 ms when the executor is speechEnabled. After
333
- * an action in the same turn such a wait lasts 500 ms. A whole number from 1000 to 600000 and no
334
- * longer than an explicit maxWaitMs; any other value throws a RangeError before the session
335
- * starts. Default 5000. The session never applies more than its maxWaitMs, and the trace records
336
- * the applied lengths as `waitSettings`.
331
+ * screenshots: the study's actor.idleWaitMs. It is also the shortest such a turn is watched for,
332
+ * and how often the loop looks at the screen while it is watched, at most every 30000 ms when the
333
+ * executor is speechEnabled. After an action in the same turn such a wait lasts 500 ms. A whole
334
+ * number from 1000 to 600000 and no longer than an explicit maxWaitMs; any other value throws a
335
+ * RangeError before the session starts. Default 5000. The session never applies more than its
336
+ * maxWaitMs, and the trace records the applied lengths as `waitSettings`.
337
337
  */
338
338
  idleWaitMs?: number;
339
339
  /**
@@ -50,10 +50,13 @@ export class IdleWatcher {
50
50
  if (plannedMs === 0)
51
51
  return undefined;
52
52
  const capMs = Math.max(plannedMs, session.waits.watchMaxMs);
53
- // Never shorter than the turn's own planned length, so a long named wait is not cut short.
53
+ // Never shorter than idleWaitMs, so a participant naming a few milliseconds on a static screen
54
+ // does not send requests back to back, and never shorter than the turn's own planned length, so
55
+ // a long named wait is not cut short.
56
+ const shortestMs = Math.max(plannedMs, session.waits.idleWaitMs);
54
57
  const limitMs = this.grownFromMs === undefined
55
- ? plannedMs
56
- : Math.min(capMs, Math.max(plannedMs, 2 * this.grownFromMs));
58
+ ? shortestMs
59
+ : Math.min(capMs, Math.max(shortestMs, 2 * this.grownFromMs));
57
60
  const ending = await watchScreen(session, this.observer, turnNumber, reference, limitMs);
58
61
  // A small change that held still (a status label, or a counter the rule did not hold back)
59
62
  // wakes the participant and keeps the doubling, so a repeating small change cannot keep every
@@ -64,7 +67,7 @@ export class IdleWatcher {
64
67
  wake: ending.wake,
65
68
  ...(ending.observation === undefined ? {} : { observation: ending.observation }),
66
69
  ...(ending.stop === undefined ? {} : { stop: ending.stop }),
67
- hint: hintFor(ending, grows && limitMs < capMs ? capMs : undefined),
70
+ hint: hintFor(ending),
68
71
  waits: {
69
72
  ...splitWatch(lengths, limitMs, ending.ms),
70
73
  sessionEnded: ending.stop !== undefined,
@@ -215,21 +218,18 @@ function splitWatch(lengths, limitMs, ms) {
215
218
  };
216
219
  }
217
220
  /**
218
- * The next request's note. A clock ending says nothing: the clock text that follows says it. A
221
+ * The next request's note. A clock ending says only how long the wait lasted. A
219
222
  * small change is named by its place, so a participant is not told the screen changed when what
220
- * changed was a counter or a timestamp. `growsToMs` adds the growth sentence when the next watch
221
- * doubles.
223
+ * changed was a counter or a timestamp. The note says nothing of the next wait's length: a
224
+ * participant told it names that length and keeps waiting.
222
225
  */
223
- function hintFor(ending, growsToMs) {
226
+ function hintFor(ending) {
224
227
  const lasted = `Your wait lasted ${duration(ending.ms)}`;
225
- const grows = growsToMs === undefined
226
- ? ""
227
- : ` A wait right after it can last twice as long, at most ${duration(growsToMs)}.`;
228
228
  const { seen } = ending;
229
229
  switch (ending.wake) {
230
230
  case "changed":
231
231
  return seen?.verdict === "settled" && seen.small
232
- ? `${lasted} and ended when a small part of the screen changed and then stayed the same for over a minute, ${place(seen.area)}.${grows}`
232
+ ? `${lasted} and ended when a small part of the screen changed and then stayed the same for over a minute, ${place(seen.area)}.`
233
233
  : `${lasted} and ended when the screen changed.`;
234
234
  case "speech":
235
235
  return `${lasted} and ended when you heard speech.`;
@@ -239,13 +239,15 @@ function hintFor(ending, growsToMs) {
239
239
  // never says the screen stayed the same.
240
240
  case "limit":
241
241
  if (seen?.verdict === "changing" && seen.lookSoon)
242
- return `${lasted}; a large part of the screen was changing at its last check.${grows}`;
242
+ return `${lasted}; a large part of the screen was changing at its last check.`;
243
243
  if (seen?.verdict !== "settled" && seen?.held !== undefined)
244
- return `${lasted}; a small part of the screen changed and stayed the same, ${place(seen.held)}. A small change does not end a wait.${grows}`;
245
- return `${lasted}; no change on the screen held still long enough to end it.${grows}`;
244
+ return `${lasted}; a small part of the screen changed and stayed the same, ${place(seen.held)}. A small change does not end a wait.`;
245
+ return `${lasted}; no change on the screen held still long enough to end it.`;
246
246
  case "stalled":
247
247
  return `${lasted}; the screen could not be checked during it.`;
248
+ // The clock text that follows says why the wait ended.
248
249
  case "clock":
250
+ return `${lasted}.`;
249
251
  case "stopped":
250
252
  return undefined;
251
253
  }
@@ -4,16 +4,17 @@ import { asArray, asRecord, inputMessage, openAiActionToCua } from "./openai-wir
4
4
  // provider still threads on previous_response_id feed it too, so a switch partway through a
5
5
  // session keeps everything before the switch.
6
6
  //
7
- // The Responses API pairs items: every computer_call needs its computer_call_output, and a
8
- // reasoning item travels with the item it preceded. The conversation is therefore cut only at
9
- // exchange boundaries: one reply's output items together with the items that answered them.
10
- // When the estimate passes the budget, the conversation is cut down to half the budget in one
11
- // step: the opening screenshot goes first, then the oldest exchanges become lines of a progress
12
- // note that keeps their reasoning summaries, messages and actions, and the text that answered
13
- // them, such as a note that an action was not run. The note is an assistant message, so text the
14
- // model wrote (which can quote a web page) keeps the trust it had when the model wrote it. The newest MIN_KEPT_EXCHANGES exchanges are never collapsed, so a
15
- // request passes the budget when they alone exceed it; the trace's per-request estimate shows when
16
- // that happens.
7
+ // The Responses API pairs items: every computer_call needs its computer_call_output, every
8
+ // function_call (wait_minutes) its function_call_output, and a reasoning item travels with the
9
+ // item it preceded. The conversation is therefore cut only at exchange boundaries: one reply's
10
+ // output items together with the items that answered them. When the estimate passes the budget, the
11
+ // conversation is cut down to half the budget in one step: the opening screenshot goes first, then
12
+ // the oldest exchanges become lines of a progress note that keeps their reasoning summaries,
13
+ // messages, actions and function calls, and the text that answered them, such as a note that an
14
+ // action was not run or how a wait ended. The note is an assistant message, so text the model wrote
15
+ // (which can quote a web page) keeps the trust it had when the model wrote it. The newest
16
+ // MIN_KEPT_EXCHANGES exchanges are never collapsed, so a request passes the budget when they alone
17
+ // exceed it; the trace's per-request estimate shows when that happens.
17
18
  //
18
19
  // A cut rewrites the start of the prompt, so the provider's prompt cache misses on the request
19
20
  // after it. Between cuts nothing already sent changes: each request starts with the whole of the
@@ -156,12 +157,15 @@ function describeExchange(exchange) {
156
157
  const said = [];
157
158
  const did = [];
158
159
  const told = [];
159
- // The answers are the call outputs, whose content is a screenshot, and hint messages.
160
+ // The answers are the computer_call outputs, whose content is a screenshot, the function call
161
+ // outputs, which say how a wait ended as text beside any screenshot, and hint messages.
160
162
  for (const raw of exchange.answers) {
161
163
  const item = asRecord(raw);
162
164
  if (item.type === "computer_call_output")
163
165
  continue;
164
- for (const entry of asArray(item.content)) {
166
+ if (typeof item.output === "string" && item.output.length > 0)
167
+ told.push(item.output);
168
+ for (const entry of [...asArray(item.content), ...asArray(item.output)]) {
165
169
  const text = asRecord(entry).text;
166
170
  if (typeof text === "string" && text.length > 0)
167
171
  told.push(text);
@@ -189,6 +193,11 @@ function describeExchange(exchange) {
189
193
  did.push(describeAction(mapped));
190
194
  }
191
195
  }
196
+ // A function call is noted as it was made, such as wait_minutes {"minutes":3}.
197
+ if (item.type === "function_call" && typeof item.name === "string")
198
+ did.push(typeof item.arguments === "string"
199
+ ? `${item.name} ${clip(item.arguments, 120)}`
200
+ : item.name);
192
201
  }
193
202
  const lead = `Turn ${exchange.turn}: `;
194
203
  const before = [
@@ -224,14 +233,17 @@ function clip(text, limit) {
224
233
  const flat = text.replace(/\s+/g, " ").trim();
225
234
  return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
226
235
  }
227
- /** The data URLs of the images an input item carries. */
236
+ /**
237
+ * The data URLs of the images an input item carries: a computer_call output's screenshot, and the
238
+ * image parts of a message's content or a function call output's output.
239
+ */
228
240
  function imagesOf(item) {
229
241
  const record = asRecord(item);
230
242
  const output = asRecord(record.output);
231
243
  const urls = [];
232
244
  if (typeof output.image_url === "string")
233
245
  urls.push(output.image_url);
234
- for (const part of asArray(record.content)) {
246
+ for (const part of [...asArray(record.content), ...asArray(record.output)]) {
235
247
  const url = asRecord(part).image_url;
236
248
  if (typeof url === "string")
237
249
  urls.push(url);
@@ -40,6 +40,10 @@ export declare class OpenAiConversation {
40
40
  private lastResponseId;
41
41
  /** computer_call ids the next request owes an output for. */
42
42
  private pendingCallIds;
43
+ /** The function calls the next request owes an output for. */
44
+ private pendingFunctionCalls;
45
+ /** Whether the latest reply acted, so its waits went to the desktop and were not watched. */
46
+ private pendingActed;
43
47
  /** Accepted replies so far; the next request is number `replies + 1`. */
44
48
  private replies;
45
49
  /** The whole conversation, which explicit-context requests carry within its token budget. */
@@ -53,7 +57,7 @@ export declare class OpenAiConversation {
53
57
  constructor(settings: OpenAiConversationSettings);
54
58
  /**
55
59
  * The next participant turn's request, carrying `req`'s screen: the opening request, or the
56
- * outputs the latest reply's computer calls are owed.
60
+ * outputs the latest reply's computer and function calls are owed.
57
61
  */
58
62
  request(req: CuaTurnRequest): ConversationRequest;
59
63
  /**
@@ -79,9 +83,20 @@ export declare class OpenAiConversation {
79
83
  record(): ActorConversation;
80
84
  /**
81
85
  * The items a request adds to the conversation: the opening message with the first screen, or
82
- * an output for each pending computer_call with the latest screenshot and any hint.
86
+ * an output for each pending call with the latest screenshot, and any hint. Each computer_call
87
+ * output carries the screenshot. Function call outputs follow in reply order: with no
88
+ * computer_call the first carries the screenshot, and every other one carries text only, so the
89
+ * screenshot is sent once. A turn's hint goes into the first function call output, so it reads
90
+ * as how the wait ended; a closing request's hint asks for the closing account and stays a user
91
+ * message. A call to a tool that does not exist gets an output that says so (callNote).
83
92
  */
84
93
  private newItems;
94
+ /**
95
+ * What a call's output says when no note does, so every reply says how the wait went: a wait
96
+ * among actions ran for its length (a shortened or unsent one has a note), and a further call in
97
+ * a reply of only waits was part of the one watch the first call's output reports.
98
+ */
99
+ private waitOutcome;
85
100
  private context;
86
101
  /**
87
102
  * A participant request: the opening request, or a continuation carrying `sent`. A threaded
@@ -1,6 +1,7 @@
1
1
  import { closingReportSchema, impressionsReplySchema, strictOutputSchema, } from "../closing-report.js";
2
+ import { isIdleTurn } from "./loop/actions.js";
2
3
  import { CarriedConversation, estimateTokens } from "./openai-context.js";
3
- import { asArray, asRecord, buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, inputMessage, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
4
+ import { asArray, asRecord, buildCallOutput, buildContinuationRequest, buildFunctionOutput, buildInitialRequest, hintItems, inputMessage, openingMessage, parseOpenAiResponse, WAIT_TOOL_NAME, } from "./openai-wire.js";
4
5
  /**
5
6
  * The closing request's output limit: its own cap, or the declared limit when that is lower. It
6
7
  * counts reasoning too, and leaves room for six impressions after the summary and friction reports.
@@ -25,6 +26,10 @@ export class OpenAiConversation {
25
26
  lastResponseId;
26
27
  /** computer_call ids the next request owes an output for. */
27
28
  pendingCallIds = [];
29
+ /** The function calls the next request owes an output for. */
30
+ pendingFunctionCalls = [];
31
+ /** Whether the latest reply acted, so its waits went to the desktop and were not watched. */
32
+ pendingActed = false;
28
33
  /** Accepted replies so far; the next request is number `replies + 1`. */
29
34
  replies = 0;
30
35
  /** The whole conversation, which explicit-context requests carry within its token budget. */
@@ -43,7 +48,7 @@ export class OpenAiConversation {
43
48
  }
44
49
  /**
45
50
  * The next participant turn's request, carrying `req`'s screen: the opening request, or the
46
- * outputs the latest reply's computer calls are owed.
51
+ * outputs the latest reply's computer and function calls are owed.
47
52
  */
48
53
  request(req) {
49
54
  const sent = this.newItems(req);
@@ -63,7 +68,7 @@ export class OpenAiConversation {
63
68
  const strict = this.openClosing !== undefined;
64
69
  // Before any accepted reply the request is the opening one, which carries no hint of its own.
65
70
  const ask = this.replies === 0 ? hintItems(req.contextHint) : [];
66
- const sent = this.openClosing ?? [...this.newItems(req), ...ask, replyForm(closing)];
71
+ const sent = this.openClosing ?? [...this.newItems(req, closing), ...ask, replyForm(closing)];
67
72
  this.openClosing = undefined;
68
73
  const reply = await send(this.closingBody(req, sent, closing, strict));
69
74
  // Recorded before the reply is accepted, as a participant turn is.
@@ -108,16 +113,43 @@ export class OpenAiConversation {
108
113
  }
109
114
  /**
110
115
  * The items a request adds to the conversation: the opening message with the first screen, or
111
- * an output for each pending computer_call with the latest screenshot and any hint.
116
+ * an output for each pending call with the latest screenshot, and any hint. Each computer_call
117
+ * output carries the screenshot. Function call outputs follow in reply order: with no
118
+ * computer_call the first carries the screenshot, and every other one carries text only, so the
119
+ * screenshot is sent once. A turn's hint goes into the first function call output, so it reads
120
+ * as how the wait ended; a closing request's hint asks for the closing account and stays a user
121
+ * message. A call to a tool that does not exist gets an output that says so (callNote).
112
122
  */
113
- newItems(req) {
123
+ newItems(req, closing) {
114
124
  if (this.replies === 0)
115
125
  return [openingMessage(req.instructions, req.observation.screenshot)];
126
+ const { screenshot } = req.observation;
127
+ const calls = this.pendingFunctionCalls;
128
+ const hintInCall = closing === undefined && calls.length > 0;
116
129
  return [
117
- ...this.pendingCallIds.map((id) => buildCallOutput(id, req.observation.screenshot, req.acknowledgedSafetyChecks)),
118
- ...hintItems(req.contextHint),
130
+ ...this.pendingCallIds.map((id) => buildCallOutput(id, screenshot, req.acknowledgedSafetyChecks)),
131
+ ...calls.map((call, index) => {
132
+ const withScreen = index === 0 && this.pendingCallIds.length === 0;
133
+ const text = [callNote(call), index === 0 && hintInCall ? req.contextHint : undefined]
134
+ .filter((part) => part !== undefined && part !== "")
135
+ .join(" ");
136
+ return withScreen
137
+ ? buildFunctionOutput(call.callId, text, screenshot)
138
+ : buildFunctionOutput(call.callId, text || this.waitOutcome(), null);
139
+ }),
140
+ ...(hintInCall ? [] : hintItems(req.contextHint)),
119
141
  ];
120
142
  }
143
+ /**
144
+ * What a call's output says when no note does, so every reply says how the wait went: a wait
145
+ * among actions ran for its length (a shortened or unsent one has a note), and a further call in
146
+ * a reply of only waits was part of the one watch the first call's output reports.
147
+ */
148
+ waitOutcome() {
149
+ return this.pendingActed
150
+ ? "The wait ran for its length with your other actions."
151
+ : "This wait was watched together with the first one in the reply.";
152
+ }
121
153
  context(instructions) {
122
154
  const { settings } = this;
123
155
  return {
@@ -183,10 +215,18 @@ export class OpenAiConversation {
183
215
  if (parsed.turn.responseId !== undefined)
184
216
  this.lastResponseId = parsed.turn.responseId;
185
217
  this.pendingCallIds = parsed.callIds;
218
+ this.pendingFunctionCalls = parsed.functionCalls;
219
+ this.pendingActed = !isIdleTurn(parsed.turn.actions);
186
220
  this.replies += 1;
187
221
  this.carried.accept(this.replies, sent, parsed.outputItems);
188
222
  }
189
223
  }
224
+ /** What a function call's output says about the call itself: that no such tool exists. */
225
+ function callNote(call) {
226
+ return call.name === WAIT_TOOL_NAME
227
+ ? undefined
228
+ : `There is no tool named ${JSON.stringify(call.name)}.`;
229
+ }
190
230
  /**
191
231
  * What form a closing reply takes, said in the request itself because text.format would rewrite
192
232
  * the cached prefix: the schema the strict retry sets.
@@ -12,8 +12,15 @@ export declare function openAiActionToCua(action: unknown): CuaAction | null;
12
12
  export interface ParsedOpenAiResponse {
13
13
  turn: CuaTurn;
14
14
  callIds: string[];
15
+ /** The function calls, in reply order, each owed a function_call_output. */
16
+ functionCalls: FunctionCall[];
15
17
  outputItems: unknown[];
16
18
  }
19
+ /** A function call a reply made: its id, and its name, which is WAIT_TOOL_NAME unless it erred. */
20
+ export interface FunctionCall {
21
+ readonly callId: string;
22
+ readonly name: string;
23
+ }
17
24
  /**
18
25
  * Parse a Responses API response into a provider-neutral CuaTurn plus the
19
26
  * computer_call ids (needed to build the next turn's call outputs) and the raw
@@ -23,6 +30,15 @@ export interface ParsedOpenAiResponse {
23
30
  */
24
31
  export declare function parseOpenAiResponse(raw: unknown): ParsedOpenAiResponse;
25
32
  export type OpenAiReasoningSummary = "auto" | "concise" | "detailed";
33
+ /**
34
+ * A function the participant names its wait length with; the computer tool's `wait` names none.
35
+ * The minutes set where the doubling of idle turns in a row starts (loop/watch.ts). Declared on
36
+ * every request, so adding it changes no request's cached prefix after the first. Its name and
37
+ * description say nothing of the screen or of later waits: participants told that a wait ends when
38
+ * the screen changes treated waiting as watching and stopped reloading a page that updates only on
39
+ * reload, and participants told a schedule copied it. Each watch's note says how long it lasted.
40
+ */
41
+ export declare const WAIT_TOOL_NAME = "wait_minutes";
26
42
  export interface OpenAiCuContext {
27
43
  /**
28
44
  * The server keeps no conversation (explicit_context): the request asks not to be stored and
@@ -59,6 +75,11 @@ export declare function openingMessage(instructions: string, screenshot?: Buffer
59
75
  * the mapper self-validating and isolable.
60
76
  */
61
77
  export declare function buildCallOutput(callId: string, screenshot: Buffer | undefined, acknowledged?: CuaSafetyCheck[]): Record<string, unknown>;
78
+ /**
79
+ * The answer to a function call: `text`, followed by the screen. `screenshot` null sends the text
80
+ * alone; undefined is a missing frame, refused as buildCallOutput refuses it.
81
+ */
82
+ export declare function buildFunctionOutput(callId: string, text: string, screenshot: Buffer | undefined | null): Record<string, unknown>;
62
83
  export interface ContinuationRequestArgs {
63
84
  ctx: OpenAiCuContext;
64
85
  previousResponseId: string | undefined;
@@ -102,6 +102,25 @@ export function openAiActionToCua(action) {
102
102
  return null;
103
103
  }
104
104
  }
105
+ /**
106
+ * A wait_minutes call as a wait of the minutes it names, in whole milliseconds and at least 1.
107
+ * The session's caps then apply as to any named wait, and the trace records the length as named.
108
+ * Arguments that do not parse, or minutes that are not a finite number above zero, make a wait
109
+ * that names no length, as the computer tool's `wait` does.
110
+ */
111
+ function waitMinutesAction(args) {
112
+ let minutes;
113
+ try {
114
+ minutes = asRecord(JSON.parse(asString(args))).minutes;
115
+ }
116
+ catch {
117
+ return { kind: "wait" };
118
+ }
119
+ if (typeof minutes !== "number" || !(minutes > 0))
120
+ return { kind: "wait" };
121
+ const ms = Math.max(1, Math.round(minutes * 60_000));
122
+ return Number.isFinite(ms) ? { kind: "wait", ms } : { kind: "wait" };
123
+ }
105
124
  // Collect plain strings or { text } entries from a reasoning summary/content array.
106
125
  function collectTextEntries(value) {
107
126
  const out = [];
@@ -143,6 +162,7 @@ export function parseOpenAiResponse(raw) {
143
162
  const output = asArray(root.output);
144
163
  const actions = [];
145
164
  const callIds = [];
165
+ const functionCalls = [];
146
166
  const reasoningParts = [];
147
167
  const messageParts = [];
148
168
  const safetyChecks = [];
@@ -194,6 +214,17 @@ export function parseOpenAiResponse(raw) {
194
214
  }
195
215
  break;
196
216
  }
217
+ case "function_call": {
218
+ const callId = optionalString(item.call_id);
219
+ if (callId === undefined)
220
+ break;
221
+ const name = asString(item.name);
222
+ functionCalls.push({ callId, name });
223
+ // A call to a tool that does not exist is a wait with no length, and its output says there
224
+ // is no such tool. A reply of only such calls is then an idle turn, so the session goes on.
225
+ actions.push(name === WAIT_TOOL_NAME ? waitMinutesAction(item.arguments) : { kind: "wait" });
226
+ break;
227
+ }
197
228
  default:
198
229
  break;
199
230
  }
@@ -240,8 +271,34 @@ export function parseOpenAiResponse(raw) {
240
271
  ...(message.length > 0 ? { message } : {}),
241
272
  ...(usage === undefined ? {} : { usage }),
242
273
  };
243
- return { turn, callIds, outputItems: output };
274
+ return { turn, callIds, functionCalls, outputItems: output };
244
275
  }
276
+ /**
277
+ * A function the participant names its wait length with; the computer tool's `wait` names none.
278
+ * The minutes set where the doubling of idle turns in a row starts (loop/watch.ts). Declared on
279
+ * every request, so adding it changes no request's cached prefix after the first. Its name and
280
+ * description say nothing of the screen or of later waits: participants told that a wait ends when
281
+ * the screen changes treated waiting as watching and stopped reloading a page that updates only on
282
+ * reload, and participants told a schedule copied it. Each watch's note says how long it lasted.
283
+ */
284
+ export const WAIT_TOOL_NAME = "wait_minutes";
285
+ const WAIT_TOOL = {
286
+ type: "function",
287
+ name: WAIT_TOOL_NAME,
288
+ description: "Wait, then get a new screenshot. The reply says how long the wait lasted and why it ended.",
289
+ parameters: {
290
+ type: "object",
291
+ properties: {
292
+ minutes: {
293
+ type: "number",
294
+ description: "How long you want to wait, in minutes.",
295
+ },
296
+ },
297
+ required: ["minutes"],
298
+ additionalProperties: false,
299
+ },
300
+ strict: true,
301
+ };
245
302
  // The fields shared by the initial and continuation requests: the tool spec,
246
303
  // truncation policy, reasoning effort, and (when configured) the safety id.
247
304
  function sharedRequestFields(ctx) {
@@ -256,7 +313,7 @@ function sharedRequestFields(ctx) {
256
313
  // The Responses API `computer` tool takes no display/environment fields; the model infers
257
314
  // resolution from the screenshots it is sent. (Sending display_* returns a 400
258
315
  // "Unknown parameter tools[0].display_width", confirmed against the live API 2026-06.)
259
- tools: [{ type: "computer" }],
316
+ tools: [{ type: "computer" }, WAIT_TOOL],
260
317
  truncation: "auto",
261
318
  // `summary` asks for the provider-sanctioned reasoning summary items; the
262
319
  // capture side never scrapes or reconstructs raw chain-of-thought. Parsed by
@@ -318,16 +375,10 @@ export function openingMessage(instructions, screenshot) {
318
375
  * the mapper self-validating and isolable.
319
376
  */
320
377
  export function buildCallOutput(callId, screenshot, acknowledged) {
321
- if (screenshot === undefined) {
322
- throw new Error("openai-responses-cu requires observation.screenshot (it is a vision provider; pair a state-only executor with a non-vision provider)");
323
- }
324
378
  return {
325
379
  type: "computer_call_output",
326
380
  call_id: callId,
327
- output: {
328
- type: "computer_screenshot",
329
- image_url: `data:image/png;base64,${screenshot.toString("base64")}`,
330
- },
381
+ output: { type: "computer_screenshot", image_url: screenshotUrl(screenshot) },
331
382
  ...(acknowledged && acknowledged.length > 0
332
383
  ? {
333
384
  acknowledged_safety_checks: acknowledged.map(({ id, code, message }) => ({
@@ -339,6 +390,28 @@ export function buildCallOutput(callId, screenshot, acknowledged) {
339
390
  : {}),
340
391
  };
341
392
  }
393
+ /**
394
+ * The answer to a function call: `text`, followed by the screen. `screenshot` null sends the text
395
+ * alone; undefined is a missing frame, refused as buildCallOutput refuses it.
396
+ */
397
+ export function buildFunctionOutput(callId, text, screenshot) {
398
+ return {
399
+ type: "function_call_output",
400
+ call_id: callId,
401
+ output: screenshot === null
402
+ ? text
403
+ : [
404
+ ...(text ? [{ type: "input_text", text }] : []),
405
+ { type: "input_image", image_url: screenshotUrl(screenshot) },
406
+ ],
407
+ };
408
+ }
409
+ function screenshotUrl(screenshot) {
410
+ if (screenshot === undefined) {
411
+ throw new Error("openai-responses-cu requires observation.screenshot (it is a vision provider; pair a state-only executor with a non-vision provider)");
412
+ }
413
+ return `data:image/png;base64,${screenshot.toString("base64")}`;
414
+ }
342
415
  /** An optional context hint as input items (none when there is no hint). */
343
416
  export function hintItems(contextHint) {
344
417
  return contextHint ? [inputMessage("user", [{ type: "input_text", text: contextHint }])] : [];
@@ -21,16 +21,18 @@ export declare const CUA_WAIT_LIMITS: Readonly<{
21
21
  defaultWatchMaxMs: 600000;
22
22
  /**
23
23
  * How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
24
- * when a study does not set actor.idleWaitMs. OpenAI's computer-use `wait` names no duration. A
25
- * participant told to wait about a minute sent ten in one turn, which lasts 50 s at this length;
26
- * one wait a turn makes a one-minute wait about six turns. It is also how often a watch looks.
24
+ * when a study does not set actor.idleWaitMs. OpenAI's computer tool `wait` names no duration;
25
+ * its wait_minutes function names one in minutes. A participant told to wait about a minute
26
+ * sent ten computer tool waits in one turn, which lasts 50 s at this length; one wait a turn
27
+ * makes a one-minute wait about six turns. It is also the shortest a watch lasts and how often
28
+ * it looks.
27
29
  */
28
30
  defaultIdleMs: 5000;
29
31
  /**
30
32
  * How long a wait that names no duration lasts after the participant acts in the same turn, so
31
- * the screen can settle before the next screenshot. Nearly every OpenAI computer-use wait follows
32
- * a click or keypress this way, so a longer one would slow every run. A watch looks again this
33
- * soon after a large part of the screen moved.
33
+ * the screen can settle before the next screenshot. Nearly every OpenAI computer tool wait
34
+ * follows a click or keypress this way, so a longer one would slow every run. A watch looks again
35
+ * this soon after a large part of the screen moved.
34
36
  */
35
37
  settleMs: 500;
36
38
  /**
@@ -2,8 +2,8 @@
2
2
  // longest (actor.maxWaitMs) and how long a wait that names no duration lasts while the participant
3
3
  // only waits (actor.idleWaitMs). The loop asks planWait for each wait. A turn with another action
4
4
  // sends its waits to the desktop in steps one desktop call carries; a turn that only waits watches
5
- // the screen instead (loop/watch.ts), for its waits' lengths, the named ones together up to
6
- // watchMaxMs, or longer as the watches of idle turns in a row grow.
5
+ // the screen instead (loop/watch.ts), for its waits' lengths and at least idleWaitMs, the named
6
+ // ones together up to watchMaxMs, or longer as the watches of idle turns in a row grow.
7
7
  export const CUA_WAIT_LIMITS = Object.freeze({
8
8
  /**
9
9
  * The longest wait one desktop call is given. A browser-control request must be answered within
@@ -26,16 +26,18 @@ export const CUA_WAIT_LIMITS = Object.freeze({
26
26
  defaultWatchMaxMs: 600_000,
27
27
  /**
28
28
  * How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
29
- * when a study does not set actor.idleWaitMs. OpenAI's computer-use `wait` names no duration. A
30
- * participant told to wait about a minute sent ten in one turn, which lasts 50 s at this length;
31
- * one wait a turn makes a one-minute wait about six turns. It is also how often a watch looks.
29
+ * when a study does not set actor.idleWaitMs. OpenAI's computer tool `wait` names no duration;
30
+ * its wait_minutes function names one in minutes. A participant told to wait about a minute
31
+ * sent ten computer tool waits in one turn, which lasts 50 s at this length; one wait a turn
32
+ * makes a one-minute wait about six turns. It is also the shortest a watch lasts and how often
33
+ * it looks.
32
34
  */
33
35
  defaultIdleMs: 5_000,
34
36
  /**
35
37
  * How long a wait that names no duration lasts after the participant acts in the same turn, so
36
- * the screen can settle before the next screenshot. Nearly every OpenAI computer-use wait follows
37
- * a click or keypress this way, so a longer one would slow every run. A watch looks again this
38
- * soon after a large part of the screen moved.
38
+ * the screen can settle before the next screenshot. Nearly every OpenAI computer tool wait
39
+ * follows a click or keypress this way, so a longer one would slow every run. A watch looks again
40
+ * this soon after a large part of the screen moved.
39
41
  */
40
42
  settleMs: 500,
41
43
  /**
@@ -91,6 +91,10 @@ export interface ActorTraceItem {
91
91
  * ran in place of a turn that only waited. Additive: absent on plain waits, on other items and
92
92
  * producers, and in older bundles. */
93
93
  watch?: ActorWatch;
94
+ /** On a computer-use wait's `ui_action`: the length in milliseconds the participant named for
95
+ * it, before any cap. Absent on a wait that named no length, such as the OpenAI computer
96
+ * tool's `wait`, on other items and producers, and in older bundles. */
97
+ namedMs?: number;
94
98
  }
95
99
  /**
96
100
  * A watch over the screen while a computer-use participant only waited: the loop looked every
@@ -190,11 +190,11 @@ export function promptFor(request, screenshotPath) {
190
190
  "giving up, and put your closing words in message. When done=true, set outcome: reached if " +
191
191
  "the task is finished, blocked if something in the app stopped you, not_reached if you are " +
192
192
  "stopping for another reason. Otherwise outcome is null.",
193
- "A turn whose actions are only waits ends when the screen changes and holds still, or when its " +
194
- "waits' ms pass. While no change on the screen holds still, each further turn in a row that " +
195
- "only waits lasts twice as long as the one before, up to the study's longest wait, and the note " +
196
- "from the harness says how long you waited and why it ended. A wait among other actions lasts " +
197
- "its ms.",
193
+ "A turn whose actions are only waits and that asks for some wait time asks to wait its waits' " +
194
+ "ms together; an ask shorter than a wait whose ms is null counts as one, and named ms count " +
195
+ "up to the study's longest wait. The note from the harness says how long you waited and why " +
196
+ "it ended. A wait among other actions lasts its ms, and the note says when the study's limit " +
197
+ "for one wait shortened it.",
198
198
  `Reply with ONLY a JSON object of this shape: ${shape}`,
199
199
  hint,
200
200
  ].join("\n");
@@ -14,6 +14,8 @@ export declare const REDACTION_MARKERS: {
14
14
  export declare function sensitivePatterns(): readonly RegExp[];
15
15
  /** True if the text contains a credential. Local paths and a URL on an E2B host alone do not count. */
16
16
  export declare function containsCredential(text: string): boolean;
17
+ /** True if the text contains a URL on an E2B host, as containsSensitive reads one. */
18
+ export declare function containsSandboxUrl(text: string): boolean;
17
19
  /** True if the text contains any secret-shaped token or known local path. */
18
20
  export declare function containsSensitive(text: string): boolean;
19
21
  /**
@@ -76,6 +76,10 @@ const E2B_PUBLIC_PAGE = [
76
76
  // A stream URL's auth key rides its `password` parameter, 16 characters from the SDK, which the
77
77
  // credential parameter pattern matches as well.
78
78
  const SANDBOX_URL = new RegExp(String.raw `(?!https:\/\/(?:${E2B_PUBLIC_PAGE})[.,;:!?'")]*(?:\s|$))https?:\\?\/\\?\/(?:[^/\s@]*@)*[^/\s\\"'<>@]*e2b[^)\s]+`, "gi");
79
+ // src/evidence/sensitive-json.ts scans a JSON array one element at a time and relies on which of
80
+ // these patterns can match across the `},{` between two elements. A new pattern that can cross
81
+ // those characters needs a case in tests/evidence/sensitive-json.test.ts.
82
+ //
79
83
  // A pattern that matches a credential by its context puts the context in a `keep` group, which
80
84
  // redaction leaves in place: `?token=[REDACTED_SECRET]`. Context goes in a group and not in a
81
85
  // lookbehind, because a pattern that starts with a lookbehind is tried at every position of the
@@ -174,6 +178,10 @@ const CREDENTIAL_PATTERNS = SECRET_PATTERNS.filter((pattern) => pattern !== SAND
174
178
  export function containsCredential(text) {
175
179
  return CREDENTIAL_PATTERNS.some((pattern) => matchesPattern(pattern, text));
176
180
  }
181
+ /** True if the text contains a URL on an E2B host, as containsSensitive reads one. */
182
+ export function containsSandboxUrl(text) {
183
+ return matchesPattern(SANDBOX_URL, text);
184
+ }
177
185
  /** True if the text contains any secret-shaped token or known local path. */
178
186
  export function containsSensitive(text) {
179
187
  return (containsSecret(text) || LOCAL_PATH_PATTERNS.some(([pattern]) => matchesPattern(pattern, text)));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * containsSensitive(JSON.stringify(value)), with each element of the array `value[key]` serialized
3
+ * and scanned on its own, so no one string holds two elements. Only an array of plain objects is
4
+ * scanned that way. Any other array, and a value, array or element with a toJSON or a prototype of
5
+ * its own, is scanned as the whole text, which can be longer than one string holds. Getters and
6
+ * proxies are not detected, so the answer is the whole text's for values as JSON.parse returns
7
+ * them.
8
+ */
9
+ export declare function containsSensitiveJson(value: unknown, key: string): boolean;
@@ -0,0 +1,96 @@
1
+ // containsSensitive over a JSON document that may not fit in one string. V8's longest string is
2
+ // 536,870,888 characters on Node 24, and a run with every participant's trace loaded can pass it.
3
+ //
4
+ // The document is scanned as pieces: the text before the array and its first element, each
5
+ // element after that, and the last element with the text after the array. Every cut then falls on
6
+ // the `,` between two objects, `},{`. Most patterns stop at a quote, so a match stays inside one
7
+ // JSON string. Those that read a name and its value cross only the quotes around its `:`. A
8
+ // credential variable's unquoted value runs over brackets and braces up to a comma, so at a cut it
9
+ // stops, but after the last element it can run over the array's `]`. That is why the text after
10
+ // the array joins the last element.
11
+ //
12
+ // The sandbox URL can cross a cut. Its user info runs over quotes, braces and commas up to an `@`:
13
+ // `https://host"},{"contact":"ops@e2b-host`. From the end of its scheme to one character past
14
+ // `e2b`, that match holds no slash and no whitespace. So a match across a cut starts at a scheme
15
+ // with no slash or whitespace after it up to the cut, and reaches the next piece only up to its
16
+ // first slash or whitespace. Between that scheme and the cut, the match can only be in its user
17
+ // info. The scheme and every `@` there are inside JSON strings, so a host that starts after one
18
+ // stops at its string's closing quote. Had that host reached `e2b` first, the scan of its piece or
19
+ // an earlier junction would have matched. So the scan keeps only the scheme between pieces. Each
20
+ // cut's junction scans the scheme, `"}` in place of the text after it, the cut's `,` and the next
21
+ // piece up to its first slash or whitespace. A public E2B page after the scheme would need a space
22
+ // or the text's end before the cut, so neither text exempts the URL. The scan holds one piece at a
23
+ // time.
24
+ import { isRecord } from "../run/type-guards.js";
25
+ import { containsSandboxUrl, containsSensitive } from "./redaction.js";
26
+ // The sandbox URL's scheme, `https://` or `https:\/\/` as JSON may escape it, at a text's end.
27
+ const SCHEME_AT_END = /https?:\\?\/\\?\/$/i;
28
+ const SLASH_OR_SPACE = /[/\s]/;
29
+ /**
30
+ * containsSensitive(JSON.stringify(value)), with each element of the array `value[key]` serialized
31
+ * and scanned on its own, so no one string holds two elements. Only an array of plain objects is
32
+ * scanned that way. Any other array, and a value, array or element with a toJSON or a prototype of
33
+ * its own, is scanned as the whole text, which can be longer than one string holds. Getters and
34
+ * proxies are not detected, so the answer is the whole text's for values as JSON.parse returns
35
+ * them.
36
+ */
37
+ export function containsSensitiveJson(value, key) {
38
+ // JSON.stringify writes only own enumerable keys, and `around` cuts the text at this one.
39
+ const elements = isPlainRecord(value) && Object.keys(value).includes(key) ? value[key] : undefined;
40
+ // Array.from reads a hole as undefined, where every would skip it.
41
+ if (!isPlainRecord(value) ||
42
+ !isPlainArray(elements) ||
43
+ !Array.from(elements).every(isPlainRecord))
44
+ return wholeTextSensitive(value);
45
+ const [prefix, suffix] = around(value, key);
46
+ if (elements.length === 0)
47
+ return containsSensitive(prefix + suffix);
48
+ // The scheme of a URL no slash or whitespace has followed, from an earlier piece.
49
+ let scheme;
50
+ for (const [index, element] of elements.entries()) {
51
+ const own = JSON.stringify(element);
52
+ const piece = (index === 0 ? prefix : "") + own + (index === elements.length - 1 ? suffix : "");
53
+ if (containsSensitive(piece))
54
+ return true;
55
+ if (scheme !== undefined && containsSandboxUrl(`${scheme}"},${head(piece)}`))
56
+ return true;
57
+ scheme = schemeAfter(scheme, piece);
58
+ }
59
+ return false;
60
+ }
61
+ const wholeTextSensitive = (value) => containsSensitive(JSON.stringify(value) ?? "");
62
+ // JSON.stringify calls a toJSON with the value's key in its parent, and an element serialized
63
+ // alone gets "" in place of its index. It writes a boxed primitive as the primitive. A plain object
64
+ // or array with no toJSON is written as its own keys or items wherever it sits.
65
+ const hasToJson = (value) => typeof value.toJSON === "function";
66
+ function isPlainRecord(value) {
67
+ if (!isRecord(value) || hasToJson(value))
68
+ return false;
69
+ const prototype = Object.getPrototypeOf(value);
70
+ return prototype === Object.prototype || prototype === null;
71
+ }
72
+ const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype && !hasToJson(value);
73
+ /** The document's text before the first element of `value[key]` and after its last. */
74
+ function around(value, key) {
75
+ const keys = Object.keys(value);
76
+ const at = keys.indexOf(key);
77
+ const entries = (from, to) => Object.fromEntries(keys.slice(from, to).map((name) => [name, value[name]]));
78
+ const before = JSON.stringify({ ...entries(0, at), [key]: [] });
79
+ const after = JSON.stringify(entries(at + 1));
80
+ return [before.slice(0, -"]}".length), after === "{}" ? "]}" : `],${after.slice(1)}`];
81
+ }
82
+ /** `piece` up to and including its first slash or whitespace, or all of it when it has none. */
83
+ function head(piece) {
84
+ const at = piece.search(SLASH_OR_SPACE);
85
+ return at === -1 ? piece : piece.slice(0, at + 1);
86
+ }
87
+ /** The scheme a sandbox URL could continue from after `piece`, given the one before it. */
88
+ function schemeAfter(before, piece) {
89
+ let last = piece.length - 1;
90
+ while (last >= 0 && !SLASH_OR_SPACE.test(piece[last]))
91
+ last -= 1;
92
+ if (last === -1)
93
+ return before;
94
+ // `https:\/\/` is the longest scheme: ten characters ending at the slash.
95
+ return SCHEME_AT_END.exec(piece.slice(Math.max(0, last - 9), last + 1))?.[0];
96
+ }
@@ -1,5 +1,6 @@
1
1
  import { collectDesktopRecording } from "../../evidence/desktop-recording-artifact.js";
2
2
  import path from "node:path";
3
+ import { participantList } from "../../study/study-fields.js";
3
4
  import { inboxRecipientFor, } from "./participant-desktop.js";
4
5
  import { createLocalFirecrackerDesktop, } from "../../substrates/local/firecracker-desktop.js";
5
6
  import { localBrowserDefaults, localBrowserUnsupportedReason, } from "../../substrates/local/runtime-config.js";
@@ -139,11 +140,16 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
139
140
  };
140
141
  }
141
142
  /** The Codex account participant, whose unconfirmed cleanup blocks the study's analysis. */
142
- function accountProvider(state, maxWaitMs) {
143
- return async ({ executor }) => {
143
+ function accountProvider(state, config) {
144
+ return async ({ executor, participant: { index } }) => {
145
+ // The wait settings the participant's loop runs with (plan-participants.ts), so its tool
146
+ // description states them.
147
+ const maxWaitMs = config.actor?.maxWaitMs;
148
+ const idleWaitMs = participantList(config)?.[index]?.idleWaitMs ?? config.actor?.idleWaitMs;
144
149
  const participant = createRestrictedCodexParticipant({
145
150
  speechEnabled: executor.speechEnabled === true,
146
151
  ...(maxWaitMs === undefined ? {} : { maxWaitMs }),
152
+ ...(idleWaitMs === undefined ? {} : { idleWaitMs }),
147
153
  });
148
154
  state.participants.push(participant);
149
155
  const provider = Object.assign(participant.provider, {
@@ -185,7 +191,7 @@ export function prepareLocalVmRun(options) {
185
191
  const { config: _config, assets: _assets, signal: _signal, ...runOptions } = options;
186
192
  return {
187
193
  options: account
188
- ? { ...runOptions, createProvider: accountProvider(state, config.actor?.maxWaitMs) }
194
+ ? { ...runOptions, createProvider: accountProvider(state, config) }
189
195
  : runOptions,
190
196
  localVm: {
191
197
  desktop: (run, warnings, artifactRoot) => createLocalParticipantDesktop(context, run, warnings, artifactRoot),
@@ -47,6 +47,9 @@ export async function startParticipantModel(spec, deps, executor) {
47
47
  ...(spec.planned.limits.maxWaitMs === undefined
48
48
  ? {}
49
49
  : { maxWaitMs: spec.planned.limits.maxWaitMs }),
50
+ ...(spec.planned.limits.idleWaitMs === undefined
51
+ ? {}
52
+ : { idleWaitMs: spec.planned.limits.idleWaitMs }),
50
53
  session: { env },
51
54
  });
52
55
  return { provider: codexParticipant.provider, codexParticipant };
@@ -16,6 +16,11 @@ export interface BundleEstimate {
16
16
  readonly runFileBytes: number;
17
17
  /** The largest participant trace file, which analysis also reads whole. */
18
18
  readonly largestTraceBytes: number;
19
+ /**
20
+ * Every participant's trace file together, whose items the run writer and the Observer page join
21
+ * in one string.
22
+ */
23
+ readonly tracesBytes: number;
19
24
  /** observer/index.html, which verify reads whole. */
20
25
  readonly observerPageBytes: number;
21
26
  /** Every file, screenshots included. */
@@ -16,6 +16,13 @@
16
16
  // observer-data.json leaves the lists out too and was 0.94 to 1.12 of run.json's size on the live
17
17
  // runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
18
18
  // check it.
19
+ // The Observer page and the Observer server join every participant's items in one string before
20
+ // gzip. On a hosted desktop the sandbox id scrub (publicSandboxView) does too, on every write of
21
+ // run.json and on a bundle export of the loaded run, which its 25 MiB default --max-bytes refuses
22
+ // first. V8's longest string is 536,870,888 characters
23
+ // on Node 24. A trace file's bytes are at least the characters of its items as compact JSON, so the
24
+ // estimate compares the traces' bytes and may warn a little early.
25
+ import { constants } from "node:buffer";
19
26
  import { EVIDENCE_LIMITS } from "../analysis/analysis-limits.js";
20
27
  import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
21
28
  import { formatDuration } from "../run/projection.js";
@@ -156,6 +163,7 @@ export function estimateBundle(participants, sessionMs) {
156
163
  return {
157
164
  runFileBytes,
158
165
  largestTraceBytes: largestTrace,
166
+ tracesBytes: traces,
159
167
  observerPageBytes,
160
168
  // observer-data.json is about run.json's size.
161
169
  totalBytes: observerPageBytes + screenshots * RATES.screenshotBytes + traces + 2 * runFileBytes,
@@ -178,6 +186,7 @@ export function estimateBundle(participants, sessionMs) {
178
186
  export function bundleLimitReason(estimate) {
179
187
  const runFile = estimate.runFileBytes > EVIDENCE_LIMITS.sourceBytes;
180
188
  const page = estimate.observerPageBytes > RUN_ARTIFACT_MAX_BYTES;
189
+ const joined = estimate.tracesBytes > constants.MAX_STRING_LENGTH;
181
190
  const entries = estimate.entries > estimate.entryLimit;
182
191
  const passed = [
183
192
  ...(runFile
@@ -195,6 +204,11 @@ export function bundleLimitReason(estimate) {
195
204
  `The Observer page, observer/index.html, may grow to about ${mib(estimate.observerPageBytes)}, past the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads in one file.`,
196
205
  ]
197
206
  : []),
207
+ ...(joined
208
+ ? [
209
+ `All participants' traces together may grow to about ${mib(estimate.tracesBytes)}, past the ${mib(constants.MAX_STRING_LENGTH)} that fits in one string. On a hosted desktop, writing run.json puts them in one string, and so does a bundle export allowed past its 25 MiB default; the Observer page always does. run.json may then stop updating, that export would fail and the page would not build.`,
210
+ ]
211
+ : []),
198
212
  ...(entries
199
213
  ? [
200
214
  `The run may hold about ${count(estimate.entries)} files and folders, past the ${count(estimate.entryLimit)} the public-safety scan reads in a run of ${plural(estimate.basis.participants, "participant")}.`,
@@ -222,7 +236,7 @@ export function bundleLimitReason(estimate) {
222
236
  ...(runFile ? ["shorten the mission (actor.mission)"] : []),
223
237
  ];
224
238
  // A trace is one participant's, so splitting the study helps only the run's own files.
225
- if (runFile || page || entries || changes.length === 0)
239
+ if (runFile || page || joined || entries || changes.length === 0)
226
240
  changes.push("split the participants across studies");
227
241
  const advice = changes.length < 2
228
242
  ? changes.join("")
@@ -414,10 +414,11 @@ export interface StudyActor {
414
414
  maxWaitMs?: number;
415
415
  /**
416
416
  * Computer-use and shared-world: how long a `wait` that names no duration lasts when the
417
- * participant's turn only waits or takes screenshots, and how often the loop looks at the screen
418
- * while it watches such a turn, in whole milliseconds from 1000 to 600000 and no longer than
419
- * maxWaitMs. Default 5000. After an action in the same turn such a wait lasts 500 ms. OpenAI's
420
- * computer-use `wait` never names a duration.
417
+ * participant's turn only waits or takes screenshots, the shortest such a turn is watched for,
418
+ * and how often the loop looks at the screen while it watches such a turn, in whole milliseconds
419
+ * from 1000 to 600000 and no longer than maxWaitMs. Default 5000. After an action in the same
420
+ * turn such a wait lasts 500 ms. OpenAI's computer tool `wait` never names a duration; its
421
+ * wait_minutes function does.
421
422
  */
422
423
  idleWaitMs?: number;
423
424
  /**
@@ -2,7 +2,8 @@ import path from "node:path";
2
2
  import { listAnalysisExecutions } from "../analysis/store-executions.js";
3
3
  import { loadAnalysis } from "../analysis/load.js";
4
4
  import { analysisSharingProblems } from "../analysis/sharing.js";
5
- import { containsSensitive, REDACTED_SANDBOX_ID } from "../evidence/redaction.js";
5
+ import { REDACTED_SANDBOX_ID } from "../evidence/redaction.js";
6
+ import { containsSensitiveJson } from "../evidence/sensitive-json.js";
6
7
  import { keyedSandboxIds, readRunSandboxIds } from "../run/sandbox-ids.js";
7
8
  import { validatePreparedRunArtifactPaths } from "../run/paths.js";
8
9
  import { isReadRunBundleSchema, RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, RUN_BUNDLE_V2_SCHEMA, RUN_BUNDLE_V3_SCHEMA, } from "../run/bundle.js";
@@ -112,9 +113,10 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
112
113
  addTraceScan(traces, publicSafetyFindings, unscannedArtifacts);
113
114
  // JSON escapes can hide a sensitive value from the byte scan while the
114
115
  // decoded recording exposes it to Observer, feedback, or analysis input.
116
+ // Each participant is scanned apart: together their traces can pass V8's longest string.
115
117
  if (publicSafetyFindings.length < MAX_REPORTED_FINDINGS &&
116
118
  recorded !== null &&
117
- containsSensitive(JSON.stringify(recorded))) {
119
+ containsSensitiveJson(recorded, "streams")) {
118
120
  publicSafetyFindings.push("sensitive decoded run.json");
119
121
  }
120
122
  checks.push({
@@ -176,6 +176,7 @@ export interface ActorTraceItem {
176
176
  looks: number;
177
177
  area?: { x: number; y: number; width: number; height: number }; // pixels, coordinates only
178
178
  };
179
+ namedMs?: number; // computer-use wait ui_action: the length the participant named, before any cap
179
180
  }
180
181
 
181
182
  export interface ActorCapabilities {
@@ -110,8 +110,8 @@ with speech, whose heard speech reaches the participant only with a screenshot),
110
110
  shortened` notice with both durations when it does, and sends the rest as consecutive waits of at
111
111
  most 30 seconds. The Codex participant's `humanish_ui` schema leaves a wait's length open for that
112
112
  reason; the wire still refuses a single wait over 30 seconds. A wait that names no duration, as
113
- every OpenAI computer-use wait does, reaches the desktop with a length too: 500 ms after an action
114
- in the same turn. A turn that only waits or takes screenshots sends no wait to the desktop: the
113
+ the OpenAI computer tool's `wait` does, reaches the desktop with a length too: 500 ms after an
114
+ action in the same turn. A turn that only waits or takes screenshots sends no wait to the desktop: the
115
115
  loop observes the screen every `actor.idleWaitMs` until it changes or the wait's time passes
116
116
  (`src/actors/computer-use/loop/watch.ts`), so a waiting participant costs one observe request per
117
117
  look.
@@ -1082,14 +1082,17 @@ Core-owned fields:
1082
1082
  16 pixels counts once it holds still at a look; one of 24 tiles or fewer once
1083
1083
  it has held still for more than 65 s with no tile within 4 tiles of it ticking
1084
1084
  or moving with a region of more than 24 tiles
1085
- (`src/evidence/frame-change.ts`). The first watch lasts the turn's planned
1086
- length: the named waits' lengths, together up to `actor.maxWaitMs` (else
1087
- 600000), and `idleWaitMs` for each wait that names none. Each further one in a row that ended
1088
- at its limit, or that a change of 24 tiles or fewer woke, lasts twice as long,
1089
- up to the larger of its planned length and that cap. `limitMs` is how long it
1090
- could last; `askedMs` is what the turn's waits asked for, the same sum with no
1091
- cap, so it is above `limitMs` when the named waits passed the cap and below it
1092
- when the watch grew; `wake` is `changed`, `speech`,
1085
+ (`src/evidence/frame-change.ts`). The first watch's limit is the turn's
1086
+ planned length: the named waits' lengths, together up to `actor.maxWaitMs` (else
1087
+ 600000), and `idleWaitMs` for each wait that names none, and at least
1088
+ `idleWaitMs`. Each further one in a row that ended at its limit, or that a
1089
+ change of 24 tiles or fewer woke, lasts twice as long, up to the larger of its
1090
+ planned length and that cap. An OpenAI participant names a length with the
1091
+ `wait_minutes` function tool; its computer tool's `wait` names none.
1092
+ `limitMs` is how long it could last; `askedMs` is what the turn's
1093
+ waits asked for, the same sum with no cap, so it is above `limitMs` when the
1094
+ named waits passed the cap and below it when the watch grew or the named waits
1095
+ were shorter than `idleWaitMs`; `wake` is `changed`, `speech`,
1093
1096
  `condition`, `limit`, `clock` (wind-down or end), `stalled` (a look got no
1094
1097
  frame; the next observation is a fresh one), or `stopped` (the study budget or
1095
1098
  the session's time ran out); `looks` counts its looks; `area` (`x`, `y`,
@@ -1100,6 +1103,14 @@ Core-owned fields:
1100
1103
  waits after the one carrying `watch` are titled `wait 0ms`. The look that
1101
1104
  ended a watch is the turn's `turn-NN` screenshot; other looks are not kept.
1102
1105
  Additive: absent on plain waits, other producers and older bundles
1106
+ - optional `items[].namedMs`, on the computer-use `ui_action` of each wait that
1107
+ named its length: that length in milliseconds, as the participant named it,
1108
+ before `actor.maxWaitMs` or the watch's cap applied. An OpenAI participant
1109
+ names a length only through `wait_minutes`, so its waits with `namedMs` are
1110
+ that tool's calls. Its waits without it are the computer tool's `wait`, a
1111
+ `wait_minutes` call whose minutes were not a finite number above zero, and
1112
+ a call to a function it does not have. Additive: absent on waits that named
1113
+ none, other items and producers, and older bundles
1103
1114
  - optional `items[].page` and `finalPage` (`{ origin, path }`, `TracePage` in
1104
1115
  `src/actors/contract.ts`): where a computer-use participant's browser was. The loop puts `page`
1105
1116
  on the `screenshot` item of each observation that read a URL: `turn-00-start` is the page before
@@ -1183,8 +1194,9 @@ Core-owned fields:
1183
1194
  with the outputs that answered it, and the latest reply. Past an estimated
1184
1195
  64,000 input tokens it is cut down to 32,000 in one step: the opening
1185
1196
  screenshot is dropped first, then the oldest exchanges become lines of a note,
1186
- sent as an assistant message, that keeps their reasoning summaries, messages
1187
- and actions as text, with any text that came back with their screenshots.
1197
+ sent as an assistant message, that keeps their reasoning summaries, messages,
1198
+ actions and `wait_minutes` calls as text, with any text that came back with
1199
+ their screenshots or in a function call's output, such as how a wait ended.
1188
1200
  Past 32,000 characters the note keeps its first four lines and its newest
1189
1201
  ones, and names the turns between them as not listed.
1190
1202
  The two newest exchanges are always carried whole, even when they alone pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.132.0",
3
+ "version": "0.133.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",