humanish 0.132.0 → 0.133.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -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/json-split.d.ts +21 -0
- package/dist/evidence/json-split.js +57 -0
- 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 +73 -0
- package/dist/routes/computer-use/local-vm.js +9 -3
- package/dist/routes/computer-use/participant-model.js +3 -0
- package/dist/run/sandbox-ids.d.ts +6 -1
- package/dist/run/sandbox-ids.js +65 -5
- package/dist/study/bundle-estimate.d.ts +2 -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,30 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.133.1: Hosted runs keep writing run.json past 512 MiB of traces (2026-10-14)
|
|
12
|
+
|
|
13
|
+
humanish 0.133.1 keeps a hosted run writing run.json once its finished participants' traces
|
|
14
|
+
together pass V8's longest string, 536,870,888 characters. The sandbox id scrub serialized the whole
|
|
15
|
+
bundle into one string on every write, so past that size every write threw and run.json, the trace
|
|
16
|
+
files, review.json and events.ndjson stopped updating. It now scrubs the bundle and each participant
|
|
17
|
+
apart and writes the same bytes. On synthetic runs at a recorded hosted run's density, a snapshot
|
|
18
|
+
write of 10 participants of 10 MiB took 3.8 seconds where it took 4.7, at half the peak memory.
|
|
19
|
+
|
|
20
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.133.1)
|
|
21
|
+
|
|
22
|
+
## 0.133.0: An OpenAI participant names how long it waits (2026-10-14)
|
|
23
|
+
|
|
24
|
+
humanish 0.133.0 lets an OpenAI computer-use participant name how long it waits, with a
|
|
25
|
+
`wait_minutes` tool, and cuts what a participant costs while nothing on its screen changes. A turn of
|
|
26
|
+
only waits is watched, and each further quiet wait in a row lasts twice as long, starting from the
|
|
27
|
+
length the participant named, up to 10 minutes. In live 60-minute runs against 0.132.0, median model
|
|
28
|
+
spend per participant-hour fell 13% on a resident portal, 52% on a support queue, 46% on an issue
|
|
29
|
+
list that shows new issues only on reload, and 78% on a dispatch board, and all 6 participants on
|
|
30
|
+
the issue list reloaded and reported every new issue. verify and analysis also no longer fail on a
|
|
31
|
+
run whose traces together pass 512 MiB.
|
|
32
|
+
|
|
33
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.133.0)
|
|
34
|
+
|
|
11
35
|
## 0.132.0: An OpenAI participant's closing report reads the prompt cache (2026-10-13)
|
|
12
36
|
|
|
13
37
|
humanish 0.132.0 cuts the cost of an OpenAI computer-use participant's closing report. The closing
|
|
@@ -22,6 +22,8 @@ export interface RestrictedParticipantOptions {
|
|
|
22
22
|
* defaults the loop applies without it (resolveWaitSettings).
|
|
23
23
|
*/
|
|
24
24
|
maxWaitMs?: number;
|
|
25
|
+
/** The participant's idleWaitMs, the shortest a call of only waits lasts, stated the same way. */
|
|
26
|
+
idleWaitMs?: number;
|
|
25
27
|
}
|
|
26
28
|
/** What doctor's hosted-participant check found, before any turn. */
|
|
27
29
|
export interface ParticipantReadiness {
|
|
@@ -41,7 +41,7 @@ function deferred() {
|
|
|
41
41
|
return { promise, resolve, reject };
|
|
42
42
|
}
|
|
43
43
|
// prose-check: model prompt (Codex reads this tool description, not a person)
|
|
44
|
-
const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits
|
|
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
|
/**
|