humanish 0.131.1 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/actors/codex/restricted-participant.d.ts +2 -0
  3. package/dist/actors/codex/restricted-participant.js +6 -2
  4. package/dist/actors/computer-use/loop/actions.js +1 -0
  5. package/dist/actors/computer-use/loop/debrief.js +63 -23
  6. package/dist/actors/computer-use/loop/types.d.ts +16 -7
  7. package/dist/actors/computer-use/loop/watch.js +17 -15
  8. package/dist/actors/computer-use/openai-context.js +26 -14
  9. package/dist/actors/computer-use/openai-conversation.d.ts +38 -8
  10. package/dist/actors/computer-use/openai-conversation.js +133 -22
  11. package/dist/actors/computer-use/openai-provider.js +11 -35
  12. package/dist/actors/computer-use/openai-wire.d.ts +21 -0
  13. package/dist/actors/computer-use/openai-wire.js +82 -9
  14. package/dist/actors/computer-use/wait.d.ts +8 -6
  15. package/dist/actors/computer-use/wait.js +10 -8
  16. package/dist/actors/contract.d.ts +4 -0
  17. package/dist/actors/local-agent/cli.js +5 -5
  18. package/dist/evidence/redaction.d.ts +2 -0
  19. package/dist/evidence/redaction.js +8 -0
  20. package/dist/evidence/sensitive-json.d.ts +9 -0
  21. package/dist/evidence/sensitive-json.js +96 -0
  22. package/dist/routes/computer-use/local-vm.js +9 -3
  23. package/dist/routes/computer-use/participant-model.js +3 -0
  24. package/dist/study/bundle-estimate.d.ts +5 -0
  25. package/dist/study/bundle-estimate.js +15 -1
  26. package/dist/study/types.d.ts +5 -4
  27. package/dist/verify/verify.js +4 -2
  28. package/docs/architecture/actor-contract.md +9 -3
  29. package/docs/architecture/browser-control.md +2 -2
  30. package/docs/contracts/schemas.md +30 -11
  31. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,31 @@ 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
+
24
+ ## 0.132.0: An OpenAI participant's closing report reads the prompt cache (2026-10-13)
25
+
26
+ humanish 0.132.0 cuts the cost of an OpenAI computer-use participant's closing report. The closing
27
+ report and impressions requests set `text.format`, which is part of the prompt cache's prefix, so
28
+ each one was billed as a fresh write of the whole session: 25% to 42% of a run's model spend in the
29
+ 8 runs measured on 0.131.0. They now send the schema in their input instead and read the session
30
+ from the cache. In two live 12-minute runs the closing request cost $0.026 and $0.025 where it
31
+ would have cost $0.14. A reply that does not match the schema is asked once more with the strict
32
+ schema, after the same budget and time checks.
33
+
34
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.132.0)
35
+
11
36
  ## 0.131.1: Hosted desktops keep their screens on while participants wait (2026-10-13)
12
37
 
13
38
  humanish 0.131.1 keeps a hosted desktop's screen on while a participant waits without input.
@@ -22,6 +22,8 @@ export interface RestrictedParticipantOptions {
22
22
  * defaults the loop applies without it (resolveWaitSettings).
23
23
  */
24
24
  maxWaitMs?: number;
25
+ /** The participant's idleWaitMs, the shortest a call of only waits lasts, stated the same way. */
26
+ idleWaitMs?: number;
25
27
  }
26
28
  /** What doctor's hosted-participant check found, before any turn. */
27
29
  export interface ParticipantReadiness {
@@ -41,7 +41,7 @@ function deferred() {
41
41
  return { promise, resolve, reject };
42
42
  }
43
43
  // prose-check: model prompt (Codex reads this tool description, not a person)
44
- const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits returns when the screen changes and holds still${speechEnabled ? ", when speech is heard" : ""}, or after its waits' ms, together at most ${watchMaxMs} ms. While no change on the screen holds still, each further such call in a row waits twice as long as the one before, up to ${watchMaxMs} ms, and contextHint says how long it waited and why it returned. A wait among other actions lasts its ms, at most ${maxWaitMs} ms.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
44
+ const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs, idleWaitMs, }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits and that asks for some wait time asks to wait its waits' ms together; an ask under ${idleWaitMs} ms counts as ${idleWaitMs} ms, and named ms count up to ${watchMaxMs} ms${speechEnabled ? ". It returns when speech is heard" : ""}. contextHint says how long it waited and why it returned. A wait among other actions lasts its ms, at most ${maxWaitMs} ms.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
45
45
  const participantInstructions = (prompt, speechEnabled) => `${prompt}\n\nYou are the study participant throughout this conversation, including its closing account. Use only the supplied screenshots and humanish_ui tool to interact. The tool returns a JSON string: parse it, inspect acknowledgments${speechEnabled ? " and heardSpeech captured from the actual participant speaker sink" : ""}, and display imageUrl with Code Mode image(). Do not print the image data URL as text. Keep your persona and earlier observations throughout the session.${speechEnabled ? " A speak action plays into the participant microphone; use it only after the visible UI shows that you joined the call and the microphone is unmuted." : ""} Speak publicly about your experience, never reveal private reasoning. Completed inputs do not prove application outcomes; verify on the next screenshot. When the task ends, return only the required final JSON with outcome, summary, frictionReports and impressions. Report observed confusion and recovered mistakes as well as blockers. ${IMPRESSIONS_ASK} Do not invent observations.`;
46
46
  /** Turns and failures from the native run, handed in order to the next provider request. */
47
47
  class ParticipantEvents {
@@ -192,7 +192,11 @@ function participantSession(options, speechEnabled, effort, call) {
192
192
  reasoningEffort: effort,
193
193
  tool: {
194
194
  name: "humanish_ui",
195
- description: toolDescription(speechEnabled, resolveWaitSettings({ maxWaitMs: options.maxWaitMs, speechEnabled })),
195
+ description: toolDescription(speechEnabled, resolveWaitSettings({
196
+ maxWaitMs: options.maxWaitMs,
197
+ idleWaitMs: options.idleWaitMs,
198
+ speechEnabled,
199
+ })),
196
200
  inputSchema: participantToolSchema(speechEnabled),
197
201
  call,
198
202
  },
@@ -209,6 +209,7 @@ export async function runActionBatch(session, actions, watched) {
209
209
  ? { coord: { x: action.x, y: action.y } }
210
210
  : {}),
211
211
  ...(watched !== undefined && waitIndex === watched.endedOn ? { watch: watched.watch } : {}),
212
+ ...(requested.kind === "wait" && requested.ms !== undefined ? { namedMs: requested.ms } : {}),
212
213
  }));
213
214
  }
214
215
  return { execution, hint: hint() };
@@ -86,36 +86,68 @@ function planDebrief(session, ask, trigger) {
86
86
  }
87
87
  return { send, boundMs };
88
88
  }
89
- async function debriefExchange(session, context, trigger, ask, { send, boundMs }, record) {
89
+ async function debriefExchange(session, context, trigger, ask, plan, record) {
90
+ const { provider } = session;
91
+ const request = {
92
+ instructions: session.settings.instructions,
93
+ observation: trigger.observation,
94
+ ...(provider.requestPolicy !== "fail_closed" || context.previousExecution === undefined
95
+ ? {}
96
+ : { previousExecution: context.previousExecution }),
97
+ ...(context.previousResponseId === undefined
98
+ ? {}
99
+ : { previousResponseId: context.previousResponseId }),
100
+ ...(context.acknowledgedSafetyChecks === undefined
101
+ ? {}
102
+ : { acknowledgedSafetyChecks: context.acknowledgedSafetyChecks }),
103
+ contextHint: ask.contextHint,
104
+ };
105
+ const first = await sendClosing(session, ask, request, plan);
106
+ if (!("turn" in first)) {
107
+ return first.status === "skipped"
108
+ ? record("skipped", `${first.detail}; no closing request was sent`)
109
+ : record("failed", first.detail, first.usageReported);
110
+ }
111
+ if (first.turn.closingRetry !== true)
112
+ return acceptDebriefTurn(session, first.turn, trigger, ask, record, first.usageReported);
113
+ // The first reply's usage is already recorded, so the checks see it. Under a cap, a reply
114
+ // without complete usage stops another paid request, as it does after a participant turn.
115
+ const again = session.capDeclared && !first.usageReported
116
+ ? {
117
+ skip: "the closing request did not report complete usage, so the remaining model budget is unknown",
118
+ }
119
+ : planDebrief(session, ask, trigger);
120
+ const second = "skip" in again
121
+ ? { status: "skipped", detail: again.skip }
122
+ : await sendClosing(session, ask, request, again);
123
+ if ("turn" in second) {
124
+ const usageReported = first.usageReported && second.usageReported;
125
+ return acceptDebriefTurn(session, second.turn, trigger, ask, record, usageReported, "; asked twice: the first reply was not the JSON the request asked for");
126
+ }
127
+ // The first reply stands, with why no second one replaced it.
128
+ const skipped = second.status === "skipped";
129
+ return acceptDebriefTurn(session, first.turn, trigger, ask, record, first.usageReported && (skipped || second.usageReported), `; ${skipped ? "not asked again" : "asked once more"}: ${second.detail}`);
130
+ }
131
+ /** Send one closing request within its time bound, and record the usage its reply reports. */
132
+ async function sendClosing(session, ask, request, { send, boundMs }) {
90
133
  const { signal, provider } = session;
91
134
  const scope = requestScope(signal);
92
135
  const timer = setTimeout(() => scope.end(), boundMs);
93
136
  timer.unref?.();
94
137
  session.trace.bump("debriefCalls");
95
138
  try {
96
- const request = {
97
- instructions: session.settings.instructions,
98
- observation: trigger.observation,
99
- ...(provider.requestPolicy !== "fail_closed" || context.previousExecution === undefined
100
- ? {}
101
- : { previousExecution: context.previousExecution }),
102
- ...(context.previousResponseId === undefined
103
- ? {}
104
- : { previousResponseId: context.previousResponseId }),
105
- ...(context.acknowledgedSafetyChecks === undefined
106
- ? {}
107
- : { acknowledgedSafetyChecks: context.acknowledgedSafetyChecks }),
108
- contextHint: ask.contextHint,
109
- };
110
139
  const turn = provider.requestPolicy === "fail_closed"
111
140
  ? await singleDispatch(session, "debrief", (dispatchSignal) => send(request, dispatchSignal), boundMs)
112
141
  : await raceSessionDeadline(send(request, scope.signal), boundMs, signal);
113
- return acceptDebriefTurn(session, turn, trigger, ask, record);
142
+ return { turn, usageReported: recordClosingUsage(session, turn) };
114
143
  }
115
144
  catch (error) {
116
145
  // A failed optional report cannot rewrite the already observed structured completion.
117
146
  if (isComputerUseAdmissionLimitError(error)) {
118
- return record("skipped", "the adapter reported a local admission limit before provider dispatch; no closing request was sent");
147
+ return {
148
+ status: "skipped",
149
+ detail: "the adapter reported a local admission limit before provider dispatch",
150
+ };
119
151
  }
120
152
  const detail = signal?.aborted
121
153
  ? "cancelled"
@@ -126,14 +158,19 @@ async function debriefExchange(session, context, trigger, ask, { send, boundMs }
126
158
  : String(error);
127
159
  const debriefReceipt = provider.requestPolicy === "fail_closed" ? session.usage.requests.at(-1) : undefined;
128
160
  const usageReported = debriefReceipt?.usageComplete === true && isCompleteTurnUsage(debriefReceipt.usage);
129
- return record("failed", `${detail}; closing request usage is ${usageReported ? "reported" : "unreported"}`, usageReported);
161
+ return {
162
+ status: "failed",
163
+ detail: `${detail}; closing request usage is ${usageReported ? "reported" : "unreported"}`,
164
+ usageReported,
165
+ };
130
166
  }
131
167
  finally {
132
168
  clearTimeout(timer);
133
169
  scope.end();
134
170
  }
135
171
  }
136
- function acceptDebriefTurn(session, turn, trigger, ask, record) {
172
+ /** Record a closing reply's usage, and say whether it was complete. */
173
+ function recordClosingUsage(session, turn) {
137
174
  const { maxUsd, overRunBudget, estimateTurnCostUsd } = session.settings;
138
175
  session.usage.record(turn, "debrief");
139
176
  session.lastResponseId = turn.responseId ?? session.lastResponseId;
@@ -144,13 +181,16 @@ function acceptDebriefTurn(session, turn, trigger, ask, record) {
144
181
  (maxUsd !== undefined && finalEstimate != null && finalEstimate > maxUsd)) {
145
182
  session.trace.record("notice", () => notice("warn", "model budget reached during closing report", "The closing request crossed an estimated budget; no further requests or actions followed. Task completion is unchanged."));
146
183
  }
147
- const usageReported = isCompleteTurnUsage(turn.usage) && turn.providerRequest?.usageComplete !== false;
184
+ return isCompleteTurnUsage(turn.usage) && turn.providerRequest?.usageComplete !== false;
185
+ }
186
+ /** Accept a closing reply as the debrief's result; `note` adds why no later reply replaced it. */
187
+ function acceptDebriefTurn(session, turn, trigger, ask, record, usageReported, note = "") {
148
188
  if (turn.actions.length > 0 || turn.pendingSafetyChecks.length > 0) {
149
- return record("failed", "the closing response requested actions or safety checks; none were executed and its report was not accepted", usageReported);
189
+ return record("failed", `the closing response requested actions or safety checks; none were executed and its report was not accepted${note}`, usageReported);
150
190
  }
151
191
  // First-party providers parse their replies already; these parses cover third-party ports.
152
192
  const { status, detail, ...added } = ask.read(session, turn, trigger);
153
- return { ...record(status, detail, usageReported), ...added };
193
+ return { ...record(status, `${detail}${note}`, usageReported), ...added };
154
194
  }
155
195
  /**
156
196
  * A closing report: its summary and friction as one participant message, then its impressions.
@@ -205,6 +245,6 @@ function readImpressions(session, turn) {
205
245
  session.impressions = recordImpressions(session, impressions.data);
206
246
  return {
207
247
  status: "completed",
208
- detail: "one read-only impressions request; no additional desktop actions; the participant's own ending is unchanged",
248
+ detail: "a read-only impressions request; no additional desktop actions; the participant's own ending is unchanged",
209
249
  };
210
250
  }
@@ -203,6 +203,12 @@ export interface CuaTurn {
203
203
  closingReport?: ParticipantClosingReport;
204
204
  /** Present only for an accepted impressions-only closing reply. */
205
205
  impressions?: ParticipantImpression[];
206
+ /**
207
+ * Set on a closing reply that was not the JSON its request asked for, when the provider's next
208
+ * closing request would ask for it more strictly. The loop may then call the same closing method
209
+ * once more, after the checks the first request passed and with a time bound of its own.
210
+ */
211
+ closingRetry?: true;
206
212
  }
207
213
  /** The model side of the loop. Self-describes its identity and capabilities. */
208
214
  export interface CuaProvider {
@@ -246,7 +252,10 @@ export interface CuaProvider {
246
252
  /** Latest known usage of the continuing request. Used only for runtime spend guards. */
247
253
  readonly pendingRequestUsage?: CuaTurn["usage"];
248
254
  nextTurn(req: CuaTurnRequest, signal: AbortSignal, spend?: CuaSpendGate): Promise<CuaTurn>;
249
- /** Optional read-only closing report. Implementations must disable tools and make no retries. */
255
+ /**
256
+ * Optional read-only closing report. Implementations must disable tools and send one request per
257
+ * call, with no retries of their own; a second request is the loop's call (`closingRetry`).
258
+ */
250
259
  debrief?: ((req: CuaTurnRequest, signal: AbortSignal) => Promise<CuaTurn>) | undefined;
251
260
  /**
252
261
  * Optional read-only request for impressions only, after the participant ended the session
@@ -319,12 +328,12 @@ export interface CuaLoopOptions {
319
328
  maxWaitMs?: number;
320
329
  /**
321
330
  * How long a `wait` that names no duration lasts, in ms, when its turn only waits or takes
322
- * screenshots: the study's actor.idleWaitMs. It is also how often the loop looks at the screen
323
- * while such a turn is watched, at most every 30000 ms when the executor is speechEnabled. After
324
- * an action in the same turn such a wait lasts 500 ms. A whole number from 1000 to 600000 and no
325
- * longer than an explicit maxWaitMs; any other value throws a RangeError before the session
326
- * starts. Default 5000. The session never applies more than its maxWaitMs, and the trace records
327
- * 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`.
328
337
  */
329
338
  idleWaitMs?: number;
330
339
  /**
@@ -50,10 +50,13 @@ export class IdleWatcher {
50
50
  if (plannedMs === 0)
51
51
  return undefined;
52
52
  const capMs = Math.max(plannedMs, session.waits.watchMaxMs);
53
- // Never shorter than the turn's own planned length, so a long named wait is not cut short.
53
+ // Never shorter than idleWaitMs, so a participant naming a few milliseconds on a static screen
54
+ // does not send requests back to back, and never shorter than the turn's own planned length, so
55
+ // a long named wait is not cut short.
56
+ const shortestMs = Math.max(plannedMs, session.waits.idleWaitMs);
54
57
  const limitMs = this.grownFromMs === undefined
55
- ? plannedMs
56
- : Math.min(capMs, Math.max(plannedMs, 2 * this.grownFromMs));
58
+ ? shortestMs
59
+ : Math.min(capMs, Math.max(shortestMs, 2 * this.grownFromMs));
57
60
  const ending = await watchScreen(session, this.observer, turnNumber, reference, limitMs);
58
61
  // A small change that held still (a status label, or a counter the rule did not hold back)
59
62
  // wakes the participant and keeps the doubling, so a repeating small change cannot keep every
@@ -64,7 +67,7 @@ export class IdleWatcher {
64
67
  wake: ending.wake,
65
68
  ...(ending.observation === undefined ? {} : { observation: ending.observation }),
66
69
  ...(ending.stop === undefined ? {} : { stop: ending.stop }),
67
- hint: hintFor(ending, grows && limitMs < capMs ? capMs : undefined),
70
+ hint: hintFor(ending),
68
71
  waits: {
69
72
  ...splitWatch(lengths, limitMs, ending.ms),
70
73
  sessionEnded: ending.stop !== undefined,
@@ -215,21 +218,18 @@ function splitWatch(lengths, limitMs, ms) {
215
218
  };
216
219
  }
217
220
  /**
218
- * The next request's note. A clock ending says nothing: the clock text that follows says it. A
221
+ * The next request's note. A clock ending says only how long the wait lasted. A
219
222
  * small change is named by its place, so a participant is not told the screen changed when what
220
- * changed was a counter or a timestamp. `growsToMs` adds the growth sentence when the next watch
221
- * doubles.
223
+ * changed was a counter or a timestamp. The note says nothing of the next wait's length: a
224
+ * participant told it names that length and keeps waiting.
222
225
  */
223
- function hintFor(ending, growsToMs) {
226
+ function hintFor(ending) {
224
227
  const lasted = `Your wait lasted ${duration(ending.ms)}`;
225
- const grows = growsToMs === undefined
226
- ? ""
227
- : ` A wait right after it can last twice as long, at most ${duration(growsToMs)}.`;
228
228
  const { seen } = ending;
229
229
  switch (ending.wake) {
230
230
  case "changed":
231
231
  return seen?.verdict === "settled" && seen.small
232
- ? `${lasted} and ended when a small part of the screen changed and then stayed the same for over a minute, ${place(seen.area)}.${grows}`
232
+ ? `${lasted} and ended when a small part of the screen changed and then stayed the same for over a minute, ${place(seen.area)}.`
233
233
  : `${lasted} and ended when the screen changed.`;
234
234
  case "speech":
235
235
  return `${lasted} and ended when you heard speech.`;
@@ -239,13 +239,15 @@ function hintFor(ending, growsToMs) {
239
239
  // never says the screen stayed the same.
240
240
  case "limit":
241
241
  if (seen?.verdict === "changing" && seen.lookSoon)
242
- return `${lasted}; a large part of the screen was changing at its last check.${grows}`;
242
+ return `${lasted}; a large part of the screen was changing at its last check.`;
243
243
  if (seen?.verdict !== "settled" && seen?.held !== undefined)
244
- return `${lasted}; a small part of the screen changed and stayed the same, ${place(seen.held)}. A small change does not end a wait.${grows}`;
245
- return `${lasted}; no change on the screen held still long enough to end it.${grows}`;
244
+ return `${lasted}; a small part of the screen changed and stayed the same, ${place(seen.held)}. A small change does not end a wait.`;
245
+ return `${lasted}; no change on the screen held still long enough to end it.`;
246
246
  case "stalled":
247
247
  return `${lasted}; the screen could not be checked during it.`;
248
+ // The clock text that follows says why the wait ended.
248
249
  case "clock":
250
+ return `${lasted}.`;
249
251
  case "stopped":
250
252
  return undefined;
251
253
  }
@@ -4,16 +4,17 @@ import { asArray, asRecord, inputMessage, openAiActionToCua } from "./openai-wir
4
4
  // provider still threads on previous_response_id feed it too, so a switch partway through a
5
5
  // session keeps everything before the switch.
6
6
  //
7
- // The Responses API pairs items: every computer_call needs its computer_call_output, and a
8
- // reasoning item travels with the item it preceded. The conversation is therefore cut only at
9
- // exchange boundaries: one reply's output items together with the items that answered them.
10
- // When the estimate passes the budget, the conversation is cut down to half the budget in one
11
- // step: the opening screenshot goes first, then the oldest exchanges become lines of a progress
12
- // note that keeps their reasoning summaries, messages and actions, and the text that answered
13
- // them, such as a note that an action was not run. The note is an assistant message, so text the
14
- // model wrote (which can quote a web page) keeps the trust it had when the model wrote it. The newest MIN_KEPT_EXCHANGES exchanges are never collapsed, so a
15
- // request passes the budget when they alone exceed it; the trace's per-request estimate shows when
16
- // that happens.
7
+ // The Responses API pairs items: every computer_call needs its computer_call_output, every
8
+ // function_call (wait_minutes) its function_call_output, and a reasoning item travels with the
9
+ // item it preceded. The conversation is therefore cut only at exchange boundaries: one reply's
10
+ // output items together with the items that answered them. When the estimate passes the budget, the
11
+ // conversation is cut down to half the budget in one step: the opening screenshot goes first, then
12
+ // the oldest exchanges become lines of a progress note that keeps their reasoning summaries,
13
+ // messages, actions and function calls, and the text that answered them, such as a note that an
14
+ // action was not run or how a wait ended. The note is an assistant message, so text the model wrote
15
+ // (which can quote a web page) keeps the trust it had when the model wrote it. The newest
16
+ // MIN_KEPT_EXCHANGES exchanges are never collapsed, so a request passes the budget when they alone
17
+ // exceed it; the trace's per-request estimate shows when that happens.
17
18
  //
18
19
  // A cut rewrites the start of the prompt, so the provider's prompt cache misses on the request
19
20
  // after it. Between cuts nothing already sent changes: each request starts with the whole of the
@@ -156,12 +157,15 @@ function describeExchange(exchange) {
156
157
  const said = [];
157
158
  const did = [];
158
159
  const told = [];
159
- // The answers are the call outputs, whose content is a screenshot, and hint messages.
160
+ // The answers are the computer_call outputs, whose content is a screenshot, the function call
161
+ // outputs, which say how a wait ended as text beside any screenshot, and hint messages.
160
162
  for (const raw of exchange.answers) {
161
163
  const item = asRecord(raw);
162
164
  if (item.type === "computer_call_output")
163
165
  continue;
164
- for (const entry of asArray(item.content)) {
166
+ if (typeof item.output === "string" && item.output.length > 0)
167
+ told.push(item.output);
168
+ for (const entry of [...asArray(item.content), ...asArray(item.output)]) {
165
169
  const text = asRecord(entry).text;
166
170
  if (typeof text === "string" && text.length > 0)
167
171
  told.push(text);
@@ -189,6 +193,11 @@ function describeExchange(exchange) {
189
193
  did.push(describeAction(mapped));
190
194
  }
191
195
  }
196
+ // A function call is noted as it was made, such as wait_minutes {"minutes":3}.
197
+ if (item.type === "function_call" && typeof item.name === "string")
198
+ did.push(typeof item.arguments === "string"
199
+ ? `${item.name} ${clip(item.arguments, 120)}`
200
+ : item.name);
192
201
  }
193
202
  const lead = `Turn ${exchange.turn}: `;
194
203
  const before = [
@@ -224,14 +233,17 @@ function clip(text, limit) {
224
233
  const flat = text.replace(/\s+/g, " ").trim();
225
234
  return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
226
235
  }
227
- /** The data URLs of the images an input item carries. */
236
+ /**
237
+ * The data URLs of the images an input item carries: a computer_call output's screenshot, and the
238
+ * image parts of a message's content or a function call output's output.
239
+ */
228
240
  function imagesOf(item) {
229
241
  const record = asRecord(item);
230
242
  const output = asRecord(record.output);
231
243
  const urls = [];
232
244
  if (typeof output.image_url === "string")
233
245
  urls.push(output.image_url);
234
- for (const part of asArray(record.content)) {
246
+ for (const part of [...asArray(record.content), ...asArray(record.output)]) {
235
247
  const url = asRecord(part).image_url;
236
248
  if (typeof url === "string")
237
249
  urls.push(url);
@@ -40,6 +40,10 @@ export declare class OpenAiConversation {
40
40
  private lastResponseId;
41
41
  /** computer_call ids the next request owes an output for. */
42
42
  private pendingCallIds;
43
+ /** The function calls the next request owes an output for. */
44
+ private pendingFunctionCalls;
45
+ /** Whether the latest reply acted, so its waits went to the desktop and were not watched. */
46
+ private pendingActed;
43
47
  /** Accepted replies so far; the next request is number `replies + 1`. */
44
48
  private replies;
45
49
  /** The whole conversation, which explicit-context requests carry within its token budget. */
@@ -48,12 +52,22 @@ export declare class OpenAiConversation {
48
52
  /** Undefined once the account or model rejected reasoning summaries. */
49
53
  private reasoningSummary;
50
54
  private readonly evidence;
55
+ /** The items of a closing request whose reply was not the stated JSON, for its strict retry. */
56
+ private openClosing;
51
57
  constructor(settings: OpenAiConversationSettings);
52
58
  /**
53
- * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
54
- * reply's computer calls are owed. A closing request asks for strict JSON and no tools.
59
+ * The next participant turn's request, carrying `req`'s screen: the opening request, or the
60
+ * outputs the latest reply's computer and function calls are owed.
55
61
  */
56
- request(req: CuaTurnRequest, closing?: ClosingRequest): ConversationRequest;
62
+ request(req: CuaTurnRequest): ConversationRequest;
63
+ /**
64
+ * The read-only closing request carrying `req`'s screen, sent once with `send`. It goes out
65
+ * without text.format, which is part of the cached prefix, so it reads the session from the
66
+ * cache. A completed reply that is not JSON the schema accepts comes back marked closingRetry and
67
+ * leaves the conversation where it was, so the next closing request is the same one with the
68
+ * strict schema set. Returns the reply's turn with the account it gave.
69
+ */
70
+ close(req: CuaTurnRequest, closing: ClosingRequest, send: (body: Record<string, unknown>) => Promise<unknown>): Promise<CuaTurn>;
57
71
  /**
58
72
  * The organization rejected server-side state: carry the conversation on the client from the
59
73
  * request being sent, and record which answer made the switch. False when the conversation is
@@ -69,9 +83,20 @@ export declare class OpenAiConversation {
69
83
  record(): ActorConversation;
70
84
  /**
71
85
  * The items a request adds to the conversation: the opening message with the first screen, or
72
- * an output for each pending computer_call with the latest screenshot and any hint.
86
+ * an output for each pending call with the latest screenshot, and any hint. Each computer_call
87
+ * output carries the screenshot. Function call outputs follow in reply order: with no
88
+ * computer_call the first carries the screenshot, and every other one carries text only, so the
89
+ * screenshot is sent once. A turn's hint goes into the first function call output, so it reads
90
+ * as how the wait ended; a closing request's hint asks for the closing account and stays a user
91
+ * message. A call to a tool that does not exist gets an output that says so (callNote).
73
92
  */
74
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;
75
100
  private context;
76
101
  /**
77
102
  * A participant request: the opening request, or a continuation carrying `sent`. A threaded
@@ -79,12 +104,17 @@ export declare class OpenAiConversation {
79
104
  * ahead of `sent`.
80
105
  */
81
106
  private turnBody;
82
- /** A read-only closing request: no tools, its own output limit, a strict schema. */
107
+ /**
108
+ * A read-only closing request: the next turn's body with tools off and its own output limit,
109
+ * and the strict schema only when `schema` is set.
110
+ */
83
111
  private closingBody;
84
112
  /**
85
- * Move the conversation on to a reply to a request that added `sent`. A participant reply cut
86
- * off by the output limit is set aside, so the next request is the same one again
87
- * (outputLimitRetry); its actions never run, so no call output is owed for them.
113
+ * A participant turn's reply to a request that added `sent`. A reply cut off by the output
114
+ * limit is set aside, so the next request is the same one again (outputLimitRetry); its actions
115
+ * never run, so no call output is owed for them.
88
116
  */
89
117
  private accept;
118
+ /** Move the conversation on to a reply to a request that added `sent`. */
119
+ private moveOn;
90
120
  }