humanish 0.131.1 → 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.
Files changed (31) hide show
  1. package/CHANGELOG.md +25 -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/debrief.js +63 -23
  6. package/dist/actors/computer-use/loop/types.d.ts +16 -7
  7. package/dist/actors/computer-use/loop/watch.js +17 -15
  8. package/dist/actors/computer-use/openai-context.js +26 -14
  9. package/dist/actors/computer-use/openai-conversation.d.ts +38 -8
  10. package/dist/actors/computer-use/openai-conversation.js +133 -22
  11. package/dist/actors/computer-use/openai-provider.js +11 -35
  12. package/dist/actors/computer-use/openai-wire.d.ts +21 -0
  13. package/dist/actors/computer-use/openai-wire.js +82 -9
  14. package/dist/actors/computer-use/wait.d.ts +8 -6
  15. package/dist/actors/computer-use/wait.js +10 -8
  16. package/dist/actors/contract.d.ts +4 -0
  17. package/dist/actors/local-agent/cli.js +5 -5
  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 +96 -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/study/bundle-estimate.d.ts +5 -0
  25. package/dist/study/bundle-estimate.js +15 -1
  26. package/dist/study/types.d.ts +5 -4
  27. package/dist/verify/verify.js +4 -2
  28. package/docs/architecture/actor-contract.md +9 -3
  29. package/docs/architecture/browser-control.md +2 -2
  30. package/docs/contracts/schemas.md +30 -11
  31. package/package.json +1 -1
@@ -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 { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, 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. */
@@ -33,6 +38,8 @@ export class OpenAiConversation {
33
38
  /** Undefined once the account or model rejected reasoning summaries. */
34
39
  reasoningSummary;
35
40
  evidence;
41
+ /** The items of a closing request whose reply was not the stated JSON, for its strict retry. */
42
+ openClosing;
36
43
  constructor(settings) {
37
44
  this.settings = settings;
38
45
  this.mode = settings.zeroDataRetention ? "explicit_context" : "threaded";
@@ -40,16 +47,44 @@ export class OpenAiConversation {
40
47
  this.evidence = new ConversationRecord(settings.zeroDataRetention, settings.now);
41
48
  }
42
49
  /**
43
- * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
44
- * reply's computer calls are owed. A closing request asks for strict JSON and no tools.
50
+ * The next participant turn's request, carrying `req`'s screen: the opening request, or the
51
+ * outputs the latest reply's computer and function calls are owed.
45
52
  */
46
- request(req, closing) {
53
+ request(req) {
47
54
  const sent = this.newItems(req);
48
55
  return {
49
- body: () => closing === undefined ? this.turnBody(req, sent) : this.closingBody(req, sent, closing),
50
- accept: (reply) => this.accept(reply, closing, sent),
56
+ body: () => this.turnBody(req, sent),
57
+ accept: (reply) => this.accept(reply, sent),
51
58
  };
52
59
  }
60
+ /**
61
+ * The read-only closing request carrying `req`'s screen, sent once with `send`. It goes out
62
+ * without text.format, which is part of the cached prefix, so it reads the session from the
63
+ * cache. A completed reply that is not JSON the schema accepts comes back marked closingRetry and
64
+ * leaves the conversation where it was, so the next closing request is the same one with the
65
+ * strict schema set. Returns the reply's turn with the account it gave.
66
+ */
67
+ async close(req, closing, send) {
68
+ const strict = this.openClosing !== undefined;
69
+ // Before any accepted reply the request is the opening one, which carries no hint of its own.
70
+ const ask = this.replies === 0 ? hintItems(req.contextHint) : [];
71
+ const sent = this.openClosing ?? [...this.newItems(req, closing), ...ask, replyForm(closing)];
72
+ this.openClosing = undefined;
73
+ const reply = await send(this.closingBody(req, sent, closing, strict));
74
+ // Recorded before the reply is accepted, as a participant turn is.
75
+ if (closing === "impressions")
76
+ this.evidence.requested(this.mode, this.carried, sent, closing);
77
+ const completed = asRecord(reply).status === "completed";
78
+ const parsed = parseOpenAiResponse(reply);
79
+ const account = completed ? closingAccount(parsed.turn.message, closing, strict) : undefined;
80
+ // A refusal would most likely stand under the strict schema too, so it is not asked again.
81
+ if (completed && account === undefined && !strict && !refused(reply)) {
82
+ this.openClosing = sent;
83
+ return { ...parsed.turn, closingRetry: true };
84
+ }
85
+ this.moveOn(parsed, sent);
86
+ return { ...parsed.turn, ...account };
87
+ }
53
88
  /**
54
89
  * The organization rejected server-side state: carry the conversation on the client from the
55
90
  * request being sent, and record which answer made the switch. False when the conversation is
@@ -78,16 +113,43 @@ export class OpenAiConversation {
78
113
  }
79
114
  /**
80
115
  * The items a request adds to the conversation: the opening message with the first screen, or
81
- * 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).
82
122
  */
83
- newItems(req) {
123
+ newItems(req, closing) {
84
124
  if (this.replies === 0)
85
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;
86
129
  return [
87
- ...this.pendingCallIds.map((id) => buildCallOutput(id, req.observation.screenshot, req.acknowledgedSafetyChecks)),
88
- ...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)),
89
141
  ];
90
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
+ }
91
153
  context(instructions) {
92
154
  const { settings } = this;
93
155
  return {
@@ -111,8 +173,9 @@ export class OpenAiConversation {
111
173
  */
112
174
  turnBody(req, sent) {
113
175
  const ctx = this.context(req.instructions);
176
+ // `sent` is the opening message, and on a closing request also the ask and the reply's form.
114
177
  if (this.replies === 0)
115
- return buildInitialRequest(ctx, req.observation.screenshot);
178
+ return { ...buildInitialRequest(ctx, req.observation.screenshot), input: [...sent] };
116
179
  return buildContinuationRequest({
117
180
  ctx,
118
181
  previousResponseId: this.lastResponseId,
@@ -120,36 +183,84 @@ export class OpenAiConversation {
120
183
  ...(this.mode === "explicit_context" ? { explicitContextItems: this.carried.carried() } : {}),
121
184
  });
122
185
  }
123
- /** A read-only closing request: no tools, its own output limit, a strict schema. */
124
- closingBody(req, sent, closing) {
186
+ /**
187
+ * A read-only closing request: the next turn's body with tools off and its own output limit,
188
+ * and the strict schema only when `schema` is set.
189
+ */
190
+ closingBody(req, sent, closing, schema) {
125
191
  return {
126
192
  ...this.turnBody(req, sent),
127
193
  tool_choice: "none",
128
194
  max_output_tokens: Math.min(this.settings.maxOutputTokens ?? CLOSING_OUTPUT_LIMIT, CLOSING_OUTPUT_LIMIT),
129
- text: { format: CLOSING_FORMATS[closing] },
195
+ ...(schema ? { text: { format: CLOSING_FORMATS[closing] } } : {}),
130
196
  };
131
197
  }
132
198
  /**
133
- * Move the conversation on to a reply to a request that added `sent`. A participant reply cut
134
- * off by the output limit is set aside, so the next request is the same one again
135
- * (outputLimitRetry); its actions never run, so no call output is owed for them.
199
+ * A participant turn's reply to a request that added `sent`. A reply cut off by the output
200
+ * limit is set aside, so the next request is the same one again (outputLimitRetry); its actions
201
+ * never run, so no call output is owed for them.
136
202
  */
137
- accept(reply, closing, sent) {
203
+ accept(reply, sent) {
138
204
  // Recorded before the reply is accepted: accepting can cut the carried history, and the record
139
205
  // counts what this request carried.
140
- if (closing !== "report")
141
- this.evidence.requested(this.mode, this.carried, sent, closing);
206
+ this.evidence.requested(this.mode, this.carried, sent);
142
207
  const parsed = parseOpenAiResponse(reply);
143
- if (closing === undefined && parsed.turn.interruption === "output_limit")
208
+ if (parsed.turn.interruption === "output_limit")
144
209
  return parsed.turn;
210
+ this.moveOn(parsed, sent);
211
+ return parsed.turn;
212
+ }
213
+ /** Move the conversation on to a reply to a request that added `sent`. */
214
+ moveOn(parsed, sent) {
145
215
  if (parsed.turn.responseId !== undefined)
146
216
  this.lastResponseId = parsed.turn.responseId;
147
217
  this.pendingCallIds = parsed.callIds;
218
+ this.pendingFunctionCalls = parsed.functionCalls;
219
+ this.pendingActed = !isIdleTurn(parsed.turn.actions);
148
220
  this.replies += 1;
149
221
  this.carried.accept(this.replies, sent, parsed.outputItems);
150
- return parsed.turn;
151
222
  }
152
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
+ }
230
+ /**
231
+ * What form a closing reply takes, said in the request itself because text.format would rewrite
232
+ * the cached prefix: the schema the strict retry sets.
233
+ */
234
+ function replyForm(closing) {
235
+ const text = `Reply with only a JSON object that matches this JSON Schema, with no other text: ${JSON.stringify(CLOSING_FORMATS[closing].schema)}`;
236
+ return inputMessage("user", [{ type: "input_text", text }]);
237
+ }
238
+ /**
239
+ * The account a closing reply's text gives: JSON the request's schema accepts, or none. A reply
240
+ * sent without text.format must give every field the stated schema requires. With text.format the
241
+ * provider holds the reply to that schema, and the check is the one any provider's report gets.
242
+ */
243
+ function closingAccount(message, closing, strict) {
244
+ // Without text.format a model may set the object in a code fence or after a message of its own.
245
+ const text = message ?? "";
246
+ let reply;
247
+ try {
248
+ reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
249
+ }
250
+ catch {
251
+ return undefined;
252
+ }
253
+ if (closing === "report") {
254
+ const report = (strict ? closingReportSchema : closingReportSchema.required()).safeParse(reply);
255
+ return report.success ? { closingReport: report.data } : undefined;
256
+ }
257
+ const impressions = impressionsReplySchema.safeParse(reply);
258
+ return impressions.success ? { impressions: impressions.data.impressions } : undefined;
259
+ }
260
+ /** Whether a reply's messages include a refusal, which carries no output_text. */
261
+ function refused(reply) {
262
+ return asArray(asRecord(reply).output).some((item) => asArray(asRecord(item).content).some((part) => asRecord(part).type === "refusal"));
263
+ }
153
264
  /** The trace's account of how a session carried its conversation (ActorConversation). */
154
265
  class ConversationRecord {
155
266
  now;
@@ -1,8 +1,7 @@
1
- import { closingReportSchema, impressionsReplySchema } from "../closing-report.js";
2
1
  import { ComputerUseAdmissionLimitError, isComputerUseAdmissionLimitError, } from "./admission-limit.js";
3
2
  import { ComputerUsePromptRefusedError } from "./provider-error.js";
4
3
  import { OpenAiConversation, } from "./openai-conversation.js";
5
- import { asRecord, optionalString } from "./openai-wire.js";
4
+ import { optionalString } from "./openai-wire.js";
6
5
  import { redactText } from "../../evidence/redaction.js";
7
6
  import { isMaxOutputTokens } from "../output-token-limit.js";
8
7
  import { prepareContainedOutputFile, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../../run/contained-output.js";
@@ -455,38 +454,15 @@ export function createOpenAiResponsesProvider(options) {
455
454
  }
456
455
  }
457
456
  };
458
- const requestTurn = async (req, signal, closing, spend) => {
457
+ const requestTurn = async (req, signal, spend) => {
459
458
  await capture.prepareNext();
460
- const request = conversation.request(req, closing);
461
- // A closing request makes exactly one request: no HTTP or policy-latch retries.
462
- const raw = closing === undefined
463
- ? await postTurn(request, signal, spend)
464
- : await post(request.body(), signal, 0, false);
465
- const turn = request.accept(raw);
466
- if (closing !== undefined) {
467
- // Refusals, incomplete output, malformed JSON, and invalid shapes remain no-report results.
468
- // Never promote raw JSON or a fallback paragraph into a structured finding.
469
- try {
470
- const reply = JSON.parse(turn.message ?? "");
471
- if (asRecord(raw).status === "completed") {
472
- if (closing === "report") {
473
- const report = closingReportSchema.safeParse(reply);
474
- if (report.success)
475
- return { ...turn, closingReport: report.data };
476
- }
477
- else {
478
- const impressions = impressionsReplySchema.safeParse(reply);
479
- if (impressions.success)
480
- return { ...turn, impressions: impressions.data.impressions };
481
- }
482
- }
483
- }
484
- catch {
485
- /* A failed optional closing account keeps its usage and no report. */
486
- }
487
- }
488
- return turn;
459
+ const request = conversation.request(req);
460
+ return request.accept(await postTurn(request, signal, spend));
489
461
  };
462
+ // Each closing request is posted once: no HTTP or policy-latch retries. A reply with no valid
463
+ // account keeps its usage and gives no report: refusals, incomplete output, malformed JSON and
464
+ // invalid shapes are never promoted into a structured finding.
465
+ const requestClosing = (req, signal, closing) => conversation.close(req, closing, (body) => post(body, signal, 0, false));
490
466
  return {
491
467
  id: "openai-responses-cu",
492
468
  version: model,
@@ -505,7 +481,7 @@ export function createOpenAiResponsesProvider(options) {
505
481
  get interactionUsageIncomplete() {
506
482
  return interactionUsageIncomplete;
507
483
  },
508
- nextTurn: (req, signal, spend) => requestTurn(req, signal, undefined, spend),
484
+ nextTurn: (req, signal, spend) => requestTurn(req, signal, spend),
509
485
  get conversation() {
510
486
  return conversation.record();
511
487
  },
@@ -513,7 +489,7 @@ export function createOpenAiResponsesProvider(options) {
513
489
  // explicit_context one carries its newest screens whole and its oldest turns summarized past
514
490
  // the budget, which the trace's conversation record counts. A participant that worked a long
515
491
  // session writes its closing account from what it carries, as a person writes one from memory.
516
- debrief: (req, signal) => requestTurn(req, signal, "report"),
517
- requestImpressions: (req, signal) => requestTurn(req, signal, "impressions"),
492
+ debrief: (req, signal) => requestClosing(req, signal, "report"),
493
+ requestImpressions: (req, signal) => requestClosing(req, signal, "impressions"),
518
494
  };
519
495
  }
@@ -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;