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 +13 -0
- package/dist/actors/codex/restricted-participant.d.ts +2 -0
- package/dist/actors/codex/restricted-participant.js +6 -2
- package/dist/actors/computer-use/loop/actions.js +1 -0
- package/dist/actors/computer-use/loop/types.d.ts +6 -6
- package/dist/actors/computer-use/loop/watch.js +17 -15
- package/dist/actors/computer-use/openai-context.js +26 -14
- package/dist/actors/computer-use/openai-conversation.d.ts +17 -2
- package/dist/actors/computer-use/openai-conversation.js +47 -7
- package/dist/actors/computer-use/openai-wire.d.ts +21 -0
- package/dist/actors/computer-use/openai-wire.js +82 -9
- package/dist/actors/computer-use/wait.d.ts +8 -6
- package/dist/actors/computer-use/wait.js +10 -8
- package/dist/actors/contract.d.ts +4 -0
- package/dist/actors/local-agent/cli.js +5 -5
- package/dist/evidence/redaction.d.ts +2 -0
- package/dist/evidence/redaction.js +8 -0
- package/dist/evidence/sensitive-json.d.ts +9 -0
- package/dist/evidence/sensitive-json.js +96 -0
- package/dist/routes/computer-use/local-vm.js +9 -3
- package/dist/routes/computer-use/participant-model.js +3 -0
- package/dist/study/bundle-estimate.d.ts +5 -0
- package/dist/study/bundle-estimate.js +15 -1
- package/dist/study/types.d.ts +5 -4
- package/dist/verify/verify.js +4 -2
- package/docs/architecture/actor-contract.md +1 -0
- package/docs/architecture/browser-control.md +2 -2
- package/docs/contracts/schemas.md +22 -10
- package/package.json +1 -1
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
|
|
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({
|
|
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
|
|
332
|
-
*
|
|
333
|
-
* an action in the same turn such a wait lasts 500 ms. A whole
|
|
334
|
-
* longer than an explicit maxWaitMs; any other value throws a
|
|
335
|
-
* starts. Default 5000. The session never applies more than its
|
|
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
|
|
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
|
-
?
|
|
56
|
-
: Math.min(capMs, Math.max(
|
|
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
|
|
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
|
|
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.
|
|
221
|
-
*
|
|
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
|
|
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)}
|
|
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
|
|
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
|
|
245
|
-
return `${lasted}; no change on the screen held still long enough to end it
|
|
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,
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// step: the opening screenshot goes first, then
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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,
|
|
118
|
-
...
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
32
|
-
* a click or keypress this way, so a longer one would slow every run. A watch looks again
|
|
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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
37
|
-
* a click or keypress this way, so a longer one would slow every run. A watch looks again
|
|
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
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
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,
|
|
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
|
|
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("")
|
package/dist/study/types.d.ts
CHANGED
|
@@ -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,
|
|
418
|
-
* while it watches such a turn, in whole milliseconds
|
|
419
|
-
* maxWaitMs. Default 5000. After an action in the same
|
|
420
|
-
* computer
|
|
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
|
/**
|
package/dist/verify/verify.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1086
|
-
length: the named waits' lengths, together up to `actor.maxWaitMs` (else
|
|
1087
|
-
600000), and `idleWaitMs` for each wait that names none
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
|
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
|