humanish 0.131.1 → 0.133.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -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/debrief.js +63 -23
- package/dist/actors/computer-use/loop/types.d.ts +16 -7
- 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 +38 -8
- package/dist/actors/computer-use/openai-conversation.js +133 -22
- package/dist/actors/computer-use/openai-provider.js +11 -35
- 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 +9 -3
- package/docs/architecture/browser-control.md +2 -2
- package/docs/contracts/schemas.md +30 -11
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { closingReportSchema, impressionsReplySchema, strictOutputSchema, } from "../closing-report.js";
|
|
2
|
+
import { isIdleTurn } from "./loop/actions.js";
|
|
2
3
|
import { CarriedConversation, estimateTokens } from "./openai-context.js";
|
|
3
|
-
import { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
|
|
4
|
+
import { asArray, asRecord, buildCallOutput, buildContinuationRequest, buildFunctionOutput, buildInitialRequest, hintItems, inputMessage, openingMessage, parseOpenAiResponse, WAIT_TOOL_NAME, } from "./openai-wire.js";
|
|
4
5
|
/**
|
|
5
6
|
* The closing request's output limit: its own cap, or the declared limit when that is lower. It
|
|
6
7
|
* counts reasoning too, and leaves room for six impressions after the summary and friction reports.
|
|
@@ -25,6 +26,10 @@ export class OpenAiConversation {
|
|
|
25
26
|
lastResponseId;
|
|
26
27
|
/** computer_call ids the next request owes an output for. */
|
|
27
28
|
pendingCallIds = [];
|
|
29
|
+
/** The function calls the next request owes an output for. */
|
|
30
|
+
pendingFunctionCalls = [];
|
|
31
|
+
/** Whether the latest reply acted, so its waits went to the desktop and were not watched. */
|
|
32
|
+
pendingActed = false;
|
|
28
33
|
/** Accepted replies so far; the next request is number `replies + 1`. */
|
|
29
34
|
replies = 0;
|
|
30
35
|
/** The whole conversation, which explicit-context requests carry within its token budget. */
|
|
@@ -33,6 +38,8 @@ export class OpenAiConversation {
|
|
|
33
38
|
/** Undefined once the account or model rejected reasoning summaries. */
|
|
34
39
|
reasoningSummary;
|
|
35
40
|
evidence;
|
|
41
|
+
/** The items of a closing request whose reply was not the stated JSON, for its strict retry. */
|
|
42
|
+
openClosing;
|
|
36
43
|
constructor(settings) {
|
|
37
44
|
this.settings = settings;
|
|
38
45
|
this.mode = settings.zeroDataRetention ? "explicit_context" : "threaded";
|
|
@@ -40,16 +47,44 @@ export class OpenAiConversation {
|
|
|
40
47
|
this.evidence = new ConversationRecord(settings.zeroDataRetention, settings.now);
|
|
41
48
|
}
|
|
42
49
|
/**
|
|
43
|
-
* The next request, carrying `req`'s screen: the opening request, or the
|
|
44
|
-
* reply's computer calls are owed.
|
|
50
|
+
* The next participant turn's request, carrying `req`'s screen: the opening request, or the
|
|
51
|
+
* outputs the latest reply's computer and function calls are owed.
|
|
45
52
|
*/
|
|
46
|
-
request(req
|
|
53
|
+
request(req) {
|
|
47
54
|
const sent = this.newItems(req);
|
|
48
55
|
return {
|
|
49
|
-
body: () =>
|
|
50
|
-
accept: (reply) => this.accept(reply,
|
|
56
|
+
body: () => this.turnBody(req, sent),
|
|
57
|
+
accept: (reply) => this.accept(reply, sent),
|
|
51
58
|
};
|
|
52
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The read-only closing request carrying `req`'s screen, sent once with `send`. It goes out
|
|
62
|
+
* without text.format, which is part of the cached prefix, so it reads the session from the
|
|
63
|
+
* cache. A completed reply that is not JSON the schema accepts comes back marked closingRetry and
|
|
64
|
+
* leaves the conversation where it was, so the next closing request is the same one with the
|
|
65
|
+
* strict schema set. Returns the reply's turn with the account it gave.
|
|
66
|
+
*/
|
|
67
|
+
async close(req, closing, send) {
|
|
68
|
+
const strict = this.openClosing !== undefined;
|
|
69
|
+
// Before any accepted reply the request is the opening one, which carries no hint of its own.
|
|
70
|
+
const ask = this.replies === 0 ? hintItems(req.contextHint) : [];
|
|
71
|
+
const sent = this.openClosing ?? [...this.newItems(req, closing), ...ask, replyForm(closing)];
|
|
72
|
+
this.openClosing = undefined;
|
|
73
|
+
const reply = await send(this.closingBody(req, sent, closing, strict));
|
|
74
|
+
// Recorded before the reply is accepted, as a participant turn is.
|
|
75
|
+
if (closing === "impressions")
|
|
76
|
+
this.evidence.requested(this.mode, this.carried, sent, closing);
|
|
77
|
+
const completed = asRecord(reply).status === "completed";
|
|
78
|
+
const parsed = parseOpenAiResponse(reply);
|
|
79
|
+
const account = completed ? closingAccount(parsed.turn.message, closing, strict) : undefined;
|
|
80
|
+
// A refusal would most likely stand under the strict schema too, so it is not asked again.
|
|
81
|
+
if (completed && account === undefined && !strict && !refused(reply)) {
|
|
82
|
+
this.openClosing = sent;
|
|
83
|
+
return { ...parsed.turn, closingRetry: true };
|
|
84
|
+
}
|
|
85
|
+
this.moveOn(parsed, sent);
|
|
86
|
+
return { ...parsed.turn, ...account };
|
|
87
|
+
}
|
|
53
88
|
/**
|
|
54
89
|
* The organization rejected server-side state: carry the conversation on the client from the
|
|
55
90
|
* request being sent, and record which answer made the switch. False when the conversation is
|
|
@@ -78,16 +113,43 @@ export class OpenAiConversation {
|
|
|
78
113
|
}
|
|
79
114
|
/**
|
|
80
115
|
* The items a request adds to the conversation: the opening message with the first screen, or
|
|
81
|
-
* an output for each pending
|
|
116
|
+
* an output for each pending call with the latest screenshot, and any hint. Each computer_call
|
|
117
|
+
* output carries the screenshot. Function call outputs follow in reply order: with no
|
|
118
|
+
* computer_call the first carries the screenshot, and every other one carries text only, so the
|
|
119
|
+
* screenshot is sent once. A turn's hint goes into the first function call output, so it reads
|
|
120
|
+
* as how the wait ended; a closing request's hint asks for the closing account and stays a user
|
|
121
|
+
* message. A call to a tool that does not exist gets an output that says so (callNote).
|
|
82
122
|
*/
|
|
83
|
-
newItems(req) {
|
|
123
|
+
newItems(req, closing) {
|
|
84
124
|
if (this.replies === 0)
|
|
85
125
|
return [openingMessage(req.instructions, req.observation.screenshot)];
|
|
126
|
+
const { screenshot } = req.observation;
|
|
127
|
+
const calls = this.pendingFunctionCalls;
|
|
128
|
+
const hintInCall = closing === undefined && calls.length > 0;
|
|
86
129
|
return [
|
|
87
|
-
...this.pendingCallIds.map((id) => buildCallOutput(id,
|
|
88
|
-
...
|
|
130
|
+
...this.pendingCallIds.map((id) => buildCallOutput(id, screenshot, req.acknowledgedSafetyChecks)),
|
|
131
|
+
...calls.map((call, index) => {
|
|
132
|
+
const withScreen = index === 0 && this.pendingCallIds.length === 0;
|
|
133
|
+
const text = [callNote(call), index === 0 && hintInCall ? req.contextHint : undefined]
|
|
134
|
+
.filter((part) => part !== undefined && part !== "")
|
|
135
|
+
.join(" ");
|
|
136
|
+
return withScreen
|
|
137
|
+
? buildFunctionOutput(call.callId, text, screenshot)
|
|
138
|
+
: buildFunctionOutput(call.callId, text || this.waitOutcome(), null);
|
|
139
|
+
}),
|
|
140
|
+
...(hintInCall ? [] : hintItems(req.contextHint)),
|
|
89
141
|
];
|
|
90
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* What a call's output says when no note does, so every reply says how the wait went: a wait
|
|
145
|
+
* among actions ran for its length (a shortened or unsent one has a note), and a further call in
|
|
146
|
+
* a reply of only waits was part of the one watch the first call's output reports.
|
|
147
|
+
*/
|
|
148
|
+
waitOutcome() {
|
|
149
|
+
return this.pendingActed
|
|
150
|
+
? "The wait ran for its length with your other actions."
|
|
151
|
+
: "This wait was watched together with the first one in the reply.";
|
|
152
|
+
}
|
|
91
153
|
context(instructions) {
|
|
92
154
|
const { settings } = this;
|
|
93
155
|
return {
|
|
@@ -111,8 +173,9 @@ export class OpenAiConversation {
|
|
|
111
173
|
*/
|
|
112
174
|
turnBody(req, sent) {
|
|
113
175
|
const ctx = this.context(req.instructions);
|
|
176
|
+
// `sent` is the opening message, and on a closing request also the ask and the reply's form.
|
|
114
177
|
if (this.replies === 0)
|
|
115
|
-
return buildInitialRequest(ctx, req.observation.screenshot);
|
|
178
|
+
return { ...buildInitialRequest(ctx, req.observation.screenshot), input: [...sent] };
|
|
116
179
|
return buildContinuationRequest({
|
|
117
180
|
ctx,
|
|
118
181
|
previousResponseId: this.lastResponseId,
|
|
@@ -120,36 +183,84 @@ export class OpenAiConversation {
|
|
|
120
183
|
...(this.mode === "explicit_context" ? { explicitContextItems: this.carried.carried() } : {}),
|
|
121
184
|
});
|
|
122
185
|
}
|
|
123
|
-
/**
|
|
124
|
-
|
|
186
|
+
/**
|
|
187
|
+
* A read-only closing request: the next turn's body with tools off and its own output limit,
|
|
188
|
+
* and the strict schema only when `schema` is set.
|
|
189
|
+
*/
|
|
190
|
+
closingBody(req, sent, closing, schema) {
|
|
125
191
|
return {
|
|
126
192
|
...this.turnBody(req, sent),
|
|
127
193
|
tool_choice: "none",
|
|
128
194
|
max_output_tokens: Math.min(this.settings.maxOutputTokens ?? CLOSING_OUTPUT_LIMIT, CLOSING_OUTPUT_LIMIT),
|
|
129
|
-
text: { format: CLOSING_FORMATS[closing] },
|
|
195
|
+
...(schema ? { text: { format: CLOSING_FORMATS[closing] } } : {}),
|
|
130
196
|
};
|
|
131
197
|
}
|
|
132
198
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
199
|
+
* A participant turn's reply to a request that added `sent`. A reply cut off by the output
|
|
200
|
+
* limit is set aside, so the next request is the same one again (outputLimitRetry); its actions
|
|
201
|
+
* never run, so no call output is owed for them.
|
|
136
202
|
*/
|
|
137
|
-
accept(reply,
|
|
203
|
+
accept(reply, sent) {
|
|
138
204
|
// Recorded before the reply is accepted: accepting can cut the carried history, and the record
|
|
139
205
|
// counts what this request carried.
|
|
140
|
-
|
|
141
|
-
this.evidence.requested(this.mode, this.carried, sent, closing);
|
|
206
|
+
this.evidence.requested(this.mode, this.carried, sent);
|
|
142
207
|
const parsed = parseOpenAiResponse(reply);
|
|
143
|
-
if (
|
|
208
|
+
if (parsed.turn.interruption === "output_limit")
|
|
144
209
|
return parsed.turn;
|
|
210
|
+
this.moveOn(parsed, sent);
|
|
211
|
+
return parsed.turn;
|
|
212
|
+
}
|
|
213
|
+
/** Move the conversation on to a reply to a request that added `sent`. */
|
|
214
|
+
moveOn(parsed, sent) {
|
|
145
215
|
if (parsed.turn.responseId !== undefined)
|
|
146
216
|
this.lastResponseId = parsed.turn.responseId;
|
|
147
217
|
this.pendingCallIds = parsed.callIds;
|
|
218
|
+
this.pendingFunctionCalls = parsed.functionCalls;
|
|
219
|
+
this.pendingActed = !isIdleTurn(parsed.turn.actions);
|
|
148
220
|
this.replies += 1;
|
|
149
221
|
this.carried.accept(this.replies, sent, parsed.outputItems);
|
|
150
|
-
return parsed.turn;
|
|
151
222
|
}
|
|
152
223
|
}
|
|
224
|
+
/** What a function call's output says about the call itself: that no such tool exists. */
|
|
225
|
+
function callNote(call) {
|
|
226
|
+
return call.name === WAIT_TOOL_NAME
|
|
227
|
+
? undefined
|
|
228
|
+
: `There is no tool named ${JSON.stringify(call.name)}.`;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* What form a closing reply takes, said in the request itself because text.format would rewrite
|
|
232
|
+
* the cached prefix: the schema the strict retry sets.
|
|
233
|
+
*/
|
|
234
|
+
function replyForm(closing) {
|
|
235
|
+
const text = `Reply with only a JSON object that matches this JSON Schema, with no other text: ${JSON.stringify(CLOSING_FORMATS[closing].schema)}`;
|
|
236
|
+
return inputMessage("user", [{ type: "input_text", text }]);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The account a closing reply's text gives: JSON the request's schema accepts, or none. A reply
|
|
240
|
+
* sent without text.format must give every field the stated schema requires. With text.format the
|
|
241
|
+
* provider holds the reply to that schema, and the check is the one any provider's report gets.
|
|
242
|
+
*/
|
|
243
|
+
function closingAccount(message, closing, strict) {
|
|
244
|
+
// Without text.format a model may set the object in a code fence or after a message of its own.
|
|
245
|
+
const text = message ?? "";
|
|
246
|
+
let reply;
|
|
247
|
+
try {
|
|
248
|
+
reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
if (closing === "report") {
|
|
254
|
+
const report = (strict ? closingReportSchema : closingReportSchema.required()).safeParse(reply);
|
|
255
|
+
return report.success ? { closingReport: report.data } : undefined;
|
|
256
|
+
}
|
|
257
|
+
const impressions = impressionsReplySchema.safeParse(reply);
|
|
258
|
+
return impressions.success ? { impressions: impressions.data.impressions } : undefined;
|
|
259
|
+
}
|
|
260
|
+
/** Whether a reply's messages include a refusal, which carries no output_text. */
|
|
261
|
+
function refused(reply) {
|
|
262
|
+
return asArray(asRecord(reply).output).some((item) => asArray(asRecord(item).content).some((part) => asRecord(part).type === "refusal"));
|
|
263
|
+
}
|
|
153
264
|
/** The trace's account of how a session carried its conversation (ActorConversation). */
|
|
154
265
|
class ConversationRecord {
|
|
155
266
|
now;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { closingReportSchema, impressionsReplySchema } from "../closing-report.js";
|
|
2
1
|
import { ComputerUseAdmissionLimitError, isComputerUseAdmissionLimitError, } from "./admission-limit.js";
|
|
3
2
|
import { ComputerUsePromptRefusedError } from "./provider-error.js";
|
|
4
3
|
import { OpenAiConversation, } from "./openai-conversation.js";
|
|
5
|
-
import {
|
|
4
|
+
import { optionalString } from "./openai-wire.js";
|
|
6
5
|
import { redactText } from "../../evidence/redaction.js";
|
|
7
6
|
import { isMaxOutputTokens } from "../output-token-limit.js";
|
|
8
7
|
import { prepareContainedOutputFile, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../../run/contained-output.js";
|
|
@@ -455,38 +454,15 @@ export function createOpenAiResponsesProvider(options) {
|
|
|
455
454
|
}
|
|
456
455
|
}
|
|
457
456
|
};
|
|
458
|
-
const requestTurn = async (req, signal,
|
|
457
|
+
const requestTurn = async (req, signal, spend) => {
|
|
459
458
|
await capture.prepareNext();
|
|
460
|
-
const request = conversation.request(req
|
|
461
|
-
|
|
462
|
-
const raw = closing === undefined
|
|
463
|
-
? await postTurn(request, signal, spend)
|
|
464
|
-
: await post(request.body(), signal, 0, false);
|
|
465
|
-
const turn = request.accept(raw);
|
|
466
|
-
if (closing !== undefined) {
|
|
467
|
-
// Refusals, incomplete output, malformed JSON, and invalid shapes remain no-report results.
|
|
468
|
-
// Never promote raw JSON or a fallback paragraph into a structured finding.
|
|
469
|
-
try {
|
|
470
|
-
const reply = JSON.parse(turn.message ?? "");
|
|
471
|
-
if (asRecord(raw).status === "completed") {
|
|
472
|
-
if (closing === "report") {
|
|
473
|
-
const report = closingReportSchema.safeParse(reply);
|
|
474
|
-
if (report.success)
|
|
475
|
-
return { ...turn, closingReport: report.data };
|
|
476
|
-
}
|
|
477
|
-
else {
|
|
478
|
-
const impressions = impressionsReplySchema.safeParse(reply);
|
|
479
|
-
if (impressions.success)
|
|
480
|
-
return { ...turn, impressions: impressions.data.impressions };
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
catch {
|
|
485
|
-
/* A failed optional closing account keeps its usage and no report. */
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return turn;
|
|
459
|
+
const request = conversation.request(req);
|
|
460
|
+
return request.accept(await postTurn(request, signal, spend));
|
|
489
461
|
};
|
|
462
|
+
// Each closing request is posted once: no HTTP or policy-latch retries. A reply with no valid
|
|
463
|
+
// account keeps its usage and gives no report: refusals, incomplete output, malformed JSON and
|
|
464
|
+
// invalid shapes are never promoted into a structured finding.
|
|
465
|
+
const requestClosing = (req, signal, closing) => conversation.close(req, closing, (body) => post(body, signal, 0, false));
|
|
490
466
|
return {
|
|
491
467
|
id: "openai-responses-cu",
|
|
492
468
|
version: model,
|
|
@@ -505,7 +481,7 @@ export function createOpenAiResponsesProvider(options) {
|
|
|
505
481
|
get interactionUsageIncomplete() {
|
|
506
482
|
return interactionUsageIncomplete;
|
|
507
483
|
},
|
|
508
|
-
nextTurn: (req, signal, spend) => requestTurn(req, signal,
|
|
484
|
+
nextTurn: (req, signal, spend) => requestTurn(req, signal, spend),
|
|
509
485
|
get conversation() {
|
|
510
486
|
return conversation.record();
|
|
511
487
|
},
|
|
@@ -513,7 +489,7 @@ export function createOpenAiResponsesProvider(options) {
|
|
|
513
489
|
// explicit_context one carries its newest screens whole and its oldest turns summarized past
|
|
514
490
|
// the budget, which the trace's conversation record counts. A participant that worked a long
|
|
515
491
|
// session writes its closing account from what it carries, as a person writes one from memory.
|
|
516
|
-
debrief: (req, signal) =>
|
|
517
|
-
requestImpressions: (req, signal) =>
|
|
492
|
+
debrief: (req, signal) => requestClosing(req, signal, "report"),
|
|
493
|
+
requestImpressions: (req, signal) => requestClosing(req, signal, "impressions"),
|
|
518
494
|
};
|
|
519
495
|
}
|
|
@@ -12,8 +12,15 @@ export declare function openAiActionToCua(action: unknown): CuaAction | null;
|
|
|
12
12
|
export interface ParsedOpenAiResponse {
|
|
13
13
|
turn: CuaTurn;
|
|
14
14
|
callIds: string[];
|
|
15
|
+
/** The function calls, in reply order, each owed a function_call_output. */
|
|
16
|
+
functionCalls: FunctionCall[];
|
|
15
17
|
outputItems: unknown[];
|
|
16
18
|
}
|
|
19
|
+
/** A function call a reply made: its id, and its name, which is WAIT_TOOL_NAME unless it erred. */
|
|
20
|
+
export interface FunctionCall {
|
|
21
|
+
readonly callId: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
}
|
|
17
24
|
/**
|
|
18
25
|
* Parse a Responses API response into a provider-neutral CuaTurn plus the
|
|
19
26
|
* computer_call ids (needed to build the next turn's call outputs) and the raw
|
|
@@ -23,6 +30,15 @@ export interface ParsedOpenAiResponse {
|
|
|
23
30
|
*/
|
|
24
31
|
export declare function parseOpenAiResponse(raw: unknown): ParsedOpenAiResponse;
|
|
25
32
|
export type OpenAiReasoningSummary = "auto" | "concise" | "detailed";
|
|
33
|
+
/**
|
|
34
|
+
* A function the participant names its wait length with; the computer tool's `wait` names none.
|
|
35
|
+
* The minutes set where the doubling of idle turns in a row starts (loop/watch.ts). Declared on
|
|
36
|
+
* every request, so adding it changes no request's cached prefix after the first. Its name and
|
|
37
|
+
* description say nothing of the screen or of later waits: participants told that a wait ends when
|
|
38
|
+
* the screen changes treated waiting as watching and stopped reloading a page that updates only on
|
|
39
|
+
* reload, and participants told a schedule copied it. Each watch's note says how long it lasted.
|
|
40
|
+
*/
|
|
41
|
+
export declare const WAIT_TOOL_NAME = "wait_minutes";
|
|
26
42
|
export interface OpenAiCuContext {
|
|
27
43
|
/**
|
|
28
44
|
* The server keeps no conversation (explicit_context): the request asks not to be stored and
|
|
@@ -59,6 +75,11 @@ export declare function openingMessage(instructions: string, screenshot?: Buffer
|
|
|
59
75
|
* the mapper self-validating and isolable.
|
|
60
76
|
*/
|
|
61
77
|
export declare function buildCallOutput(callId: string, screenshot: Buffer | undefined, acknowledged?: CuaSafetyCheck[]): Record<string, unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* The answer to a function call: `text`, followed by the screen. `screenshot` null sends the text
|
|
80
|
+
* alone; undefined is a missing frame, refused as buildCallOutput refuses it.
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildFunctionOutput(callId: string, text: string, screenshot: Buffer | undefined | null): Record<string, unknown>;
|
|
62
83
|
export interface ContinuationRequestArgs {
|
|
63
84
|
ctx: OpenAiCuContext;
|
|
64
85
|
previousResponseId: string | undefined;
|
|
@@ -102,6 +102,25 @@ export function openAiActionToCua(action) {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* A wait_minutes call as a wait of the minutes it names, in whole milliseconds and at least 1.
|
|
107
|
+
* The session's caps then apply as to any named wait, and the trace records the length as named.
|
|
108
|
+
* Arguments that do not parse, or minutes that are not a finite number above zero, make a wait
|
|
109
|
+
* that names no length, as the computer tool's `wait` does.
|
|
110
|
+
*/
|
|
111
|
+
function waitMinutesAction(args) {
|
|
112
|
+
let minutes;
|
|
113
|
+
try {
|
|
114
|
+
minutes = asRecord(JSON.parse(asString(args))).minutes;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { kind: "wait" };
|
|
118
|
+
}
|
|
119
|
+
if (typeof minutes !== "number" || !(minutes > 0))
|
|
120
|
+
return { kind: "wait" };
|
|
121
|
+
const ms = Math.max(1, Math.round(minutes * 60_000));
|
|
122
|
+
return Number.isFinite(ms) ? { kind: "wait", ms } : { kind: "wait" };
|
|
123
|
+
}
|
|
105
124
|
// Collect plain strings or { text } entries from a reasoning summary/content array.
|
|
106
125
|
function collectTextEntries(value) {
|
|
107
126
|
const out = [];
|
|
@@ -143,6 +162,7 @@ export function parseOpenAiResponse(raw) {
|
|
|
143
162
|
const output = asArray(root.output);
|
|
144
163
|
const actions = [];
|
|
145
164
|
const callIds = [];
|
|
165
|
+
const functionCalls = [];
|
|
146
166
|
const reasoningParts = [];
|
|
147
167
|
const messageParts = [];
|
|
148
168
|
const safetyChecks = [];
|
|
@@ -194,6 +214,17 @@ export function parseOpenAiResponse(raw) {
|
|
|
194
214
|
}
|
|
195
215
|
break;
|
|
196
216
|
}
|
|
217
|
+
case "function_call": {
|
|
218
|
+
const callId = optionalString(item.call_id);
|
|
219
|
+
if (callId === undefined)
|
|
220
|
+
break;
|
|
221
|
+
const name = asString(item.name);
|
|
222
|
+
functionCalls.push({ callId, name });
|
|
223
|
+
// A call to a tool that does not exist is a wait with no length, and its output says there
|
|
224
|
+
// is no such tool. A reply of only such calls is then an idle turn, so the session goes on.
|
|
225
|
+
actions.push(name === WAIT_TOOL_NAME ? waitMinutesAction(item.arguments) : { kind: "wait" });
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
197
228
|
default:
|
|
198
229
|
break;
|
|
199
230
|
}
|
|
@@ -240,8 +271,34 @@ export function parseOpenAiResponse(raw) {
|
|
|
240
271
|
...(message.length > 0 ? { message } : {}),
|
|
241
272
|
...(usage === undefined ? {} : { usage }),
|
|
242
273
|
};
|
|
243
|
-
return { turn, callIds, outputItems: output };
|
|
274
|
+
return { turn, callIds, functionCalls, outputItems: output };
|
|
244
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* A function the participant names its wait length with; the computer tool's `wait` names none.
|
|
278
|
+
* The minutes set where the doubling of idle turns in a row starts (loop/watch.ts). Declared on
|
|
279
|
+
* every request, so adding it changes no request's cached prefix after the first. Its name and
|
|
280
|
+
* description say nothing of the screen or of later waits: participants told that a wait ends when
|
|
281
|
+
* the screen changes treated waiting as watching and stopped reloading a page that updates only on
|
|
282
|
+
* reload, and participants told a schedule copied it. Each watch's note says how long it lasted.
|
|
283
|
+
*/
|
|
284
|
+
export const WAIT_TOOL_NAME = "wait_minutes";
|
|
285
|
+
const WAIT_TOOL = {
|
|
286
|
+
type: "function",
|
|
287
|
+
name: WAIT_TOOL_NAME,
|
|
288
|
+
description: "Wait, then get a new screenshot. The reply says how long the wait lasted and why it ended.",
|
|
289
|
+
parameters: {
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: {
|
|
292
|
+
minutes: {
|
|
293
|
+
type: "number",
|
|
294
|
+
description: "How long you want to wait, in minutes.",
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
required: ["minutes"],
|
|
298
|
+
additionalProperties: false,
|
|
299
|
+
},
|
|
300
|
+
strict: true,
|
|
301
|
+
};
|
|
245
302
|
// The fields shared by the initial and continuation requests: the tool spec,
|
|
246
303
|
// truncation policy, reasoning effort, and (when configured) the safety id.
|
|
247
304
|
function sharedRequestFields(ctx) {
|
|
@@ -256,7 +313,7 @@ function sharedRequestFields(ctx) {
|
|
|
256
313
|
// The Responses API `computer` tool takes no display/environment fields; the model infers
|
|
257
314
|
// resolution from the screenshots it is sent. (Sending display_* returns a 400
|
|
258
315
|
// "Unknown parameter tools[0].display_width", confirmed against the live API 2026-06.)
|
|
259
|
-
tools: [{ type: "computer" }],
|
|
316
|
+
tools: [{ type: "computer" }, WAIT_TOOL],
|
|
260
317
|
truncation: "auto",
|
|
261
318
|
// `summary` asks for the provider-sanctioned reasoning summary items; the
|
|
262
319
|
// capture side never scrapes or reconstructs raw chain-of-thought. Parsed by
|
|
@@ -318,16 +375,10 @@ export function openingMessage(instructions, screenshot) {
|
|
|
318
375
|
* the mapper self-validating and isolable.
|
|
319
376
|
*/
|
|
320
377
|
export function buildCallOutput(callId, screenshot, acknowledged) {
|
|
321
|
-
if (screenshot === undefined) {
|
|
322
|
-
throw new Error("openai-responses-cu requires observation.screenshot (it is a vision provider; pair a state-only executor with a non-vision provider)");
|
|
323
|
-
}
|
|
324
378
|
return {
|
|
325
379
|
type: "computer_call_output",
|
|
326
380
|
call_id: callId,
|
|
327
|
-
output: {
|
|
328
|
-
type: "computer_screenshot",
|
|
329
|
-
image_url: `data:image/png;base64,${screenshot.toString("base64")}`,
|
|
330
|
-
},
|
|
381
|
+
output: { type: "computer_screenshot", image_url: screenshotUrl(screenshot) },
|
|
331
382
|
...(acknowledged && acknowledged.length > 0
|
|
332
383
|
? {
|
|
333
384
|
acknowledged_safety_checks: acknowledged.map(({ id, code, message }) => ({
|
|
@@ -339,6 +390,28 @@ export function buildCallOutput(callId, screenshot, acknowledged) {
|
|
|
339
390
|
: {}),
|
|
340
391
|
};
|
|
341
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* The answer to a function call: `text`, followed by the screen. `screenshot` null sends the text
|
|
395
|
+
* alone; undefined is a missing frame, refused as buildCallOutput refuses it.
|
|
396
|
+
*/
|
|
397
|
+
export function buildFunctionOutput(callId, text, screenshot) {
|
|
398
|
+
return {
|
|
399
|
+
type: "function_call_output",
|
|
400
|
+
call_id: callId,
|
|
401
|
+
output: screenshot === null
|
|
402
|
+
? text
|
|
403
|
+
: [
|
|
404
|
+
...(text ? [{ type: "input_text", text }] : []),
|
|
405
|
+
{ type: "input_image", image_url: screenshotUrl(screenshot) },
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function screenshotUrl(screenshot) {
|
|
410
|
+
if (screenshot === undefined) {
|
|
411
|
+
throw new Error("openai-responses-cu requires observation.screenshot (it is a vision provider; pair a state-only executor with a non-vision provider)");
|
|
412
|
+
}
|
|
413
|
+
return `data:image/png;base64,${screenshot.toString("base64")}`;
|
|
414
|
+
}
|
|
342
415
|
/** An optional context hint as input items (none when there is no hint). */
|
|
343
416
|
export function hintItems(contextHint) {
|
|
344
417
|
return contextHint ? [inputMessage("user", [{ type: "input_text", text: contextHint }])] : [];
|
|
@@ -21,16 +21,18 @@ export declare const CUA_WAIT_LIMITS: Readonly<{
|
|
|
21
21
|
defaultWatchMaxMs: 600000;
|
|
22
22
|
/**
|
|
23
23
|
* How long a wait that names no duration lasts in a turn that only waits or takes screenshots,
|
|
24
|
-
* when a study does not set actor.idleWaitMs. OpenAI's computer
|
|
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;
|