humanish 0.132.0 → 0.133.1

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 (33) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/actors/codex/restricted-participant.d.ts +2 -0
  3. package/dist/actors/codex/restricted-participant.js +6 -2
  4. package/dist/actors/computer-use/loop/actions.js +1 -0
  5. package/dist/actors/computer-use/loop/types.d.ts +6 -6
  6. package/dist/actors/computer-use/loop/watch.js +17 -15
  7. package/dist/actors/computer-use/openai-context.js +26 -14
  8. package/dist/actors/computer-use/openai-conversation.d.ts +17 -2
  9. package/dist/actors/computer-use/openai-conversation.js +47 -7
  10. package/dist/actors/computer-use/openai-wire.d.ts +21 -0
  11. package/dist/actors/computer-use/openai-wire.js +82 -9
  12. package/dist/actors/computer-use/wait.d.ts +8 -6
  13. package/dist/actors/computer-use/wait.js +10 -8
  14. package/dist/actors/contract.d.ts +4 -0
  15. package/dist/actors/local-agent/cli.js +5 -5
  16. package/dist/evidence/json-split.d.ts +21 -0
  17. package/dist/evidence/json-split.js +57 -0
  18. package/dist/evidence/redaction.d.ts +2 -0
  19. package/dist/evidence/redaction.js +8 -0
  20. package/dist/evidence/sensitive-json.d.ts +9 -0
  21. package/dist/evidence/sensitive-json.js +73 -0
  22. package/dist/routes/computer-use/local-vm.js +9 -3
  23. package/dist/routes/computer-use/participant-model.js +3 -0
  24. package/dist/run/sandbox-ids.d.ts +6 -1
  25. package/dist/run/sandbox-ids.js +65 -5
  26. package/dist/study/bundle-estimate.d.ts +2 -0
  27. package/dist/study/bundle-estimate.js +15 -1
  28. package/dist/study/types.d.ts +5 -4
  29. package/dist/verify/verify.js +4 -2
  30. package/docs/architecture/actor-contract.md +1 -0
  31. package/docs/architecture/browser-control.md +2 -2
  32. package/docs/contracts/schemas.md +22 -10
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ 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.1: Hosted runs keep writing run.json past 512 MiB of traces (2026-10-14)
12
+
13
+ humanish 0.133.1 keeps a hosted run writing run.json once its finished participants' traces
14
+ together pass V8's longest string, 536,870,888 characters. The sandbox id scrub serialized the whole
15
+ bundle into one string on every write, so past that size every write threw and run.json, the trace
16
+ files, review.json and events.ndjson stopped updating. It now scrubs the bundle and each participant
17
+ apart and writes the same bytes. On synthetic runs at a recorded hosted run's density, a snapshot
18
+ write of 10 participants of 10 MiB took 3.8 seconds where it took 4.7, at half the peak memory.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.133.1)
21
+
22
+ ## 0.133.0: An OpenAI participant names how long it waits (2026-10-14)
23
+
24
+ humanish 0.133.0 lets an OpenAI computer-use participant name how long it waits, with a
25
+ `wait_minutes` tool, and cuts what a participant costs while nothing on its screen changes. A turn of
26
+ only waits is watched, and each further quiet wait in a row lasts twice as long, starting from the
27
+ length the participant named, up to 10 minutes. In live 60-minute runs against 0.132.0, median model
28
+ spend per participant-hour fell 13% on a resident portal, 52% on a support queue, 46% on an issue
29
+ list that shows new issues only on reload, and 78% on a dispatch board, and all 6 participants on
30
+ the issue list reloaded and reported every new issue. verify and analysis also no longer fail on a
31
+ run whose traces together pass 512 MiB.
32
+
33
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.133.0)
34
+
11
35
  ## 0.132.0: An OpenAI participant's closing report reads the prompt cache (2026-10-13)
12
36
 
13
37
  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
  /**