humanish 0.131.1 → 0.132.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 CHANGED
@@ -8,6 +8,18 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.132.0: An OpenAI participant's closing report reads the prompt cache (2026-10-13)
12
+
13
+ humanish 0.132.0 cuts the cost of an OpenAI computer-use participant's closing report. The closing
14
+ report and impressions requests set `text.format`, which is part of the prompt cache's prefix, so
15
+ each one was billed as a fresh write of the whole session: 25% to 42% of a run's model spend in the
16
+ 8 runs measured on 0.131.0. They now send the schema in their input instead and read the session
17
+ from the cache. In two live 12-minute runs the closing request cost $0.026 and $0.025 where it
18
+ would have cost $0.14. A reply that does not match the schema is asked once more with the strict
19
+ schema, after the same budget and time checks.
20
+
21
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.132.0)
22
+
11
23
  ## 0.131.1: Hosted desktops keep their screens on while participants wait (2026-10-13)
12
24
 
13
25
  humanish 0.131.1 keeps a hosted desktop's screen on while a participant waits without input.
@@ -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
@@ -48,12 +48,22 @@ export declare class OpenAiConversation {
48
48
  /** Undefined once the account or model rejected reasoning summaries. */
49
49
  private reasoningSummary;
50
50
  private readonly evidence;
51
+ /** The items of a closing request whose reply was not the stated JSON, for its strict retry. */
52
+ private openClosing;
51
53
  constructor(settings: OpenAiConversationSettings);
52
54
  /**
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.
55
+ * 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.
55
57
  */
56
- request(req: CuaTurnRequest, closing?: ClosingRequest): ConversationRequest;
58
+ request(req: CuaTurnRequest): ConversationRequest;
59
+ /**
60
+ * The read-only closing request carrying `req`'s screen, sent once with `send`. It goes out
61
+ * without text.format, which is part of the cached prefix, so it reads the session from the
62
+ * cache. A completed reply that is not JSON the schema accepts comes back marked closingRetry and
63
+ * leaves the conversation where it was, so the next closing request is the same one with the
64
+ * strict schema set. Returns the reply's turn with the account it gave.
65
+ */
66
+ close(req: CuaTurnRequest, closing: ClosingRequest, send: (body: Record<string, unknown>) => Promise<unknown>): Promise<CuaTurn>;
57
67
  /**
58
68
  * The organization rejected server-side state: carry the conversation on the client from the
59
69
  * request being sent, and record which answer made the switch. False when the conversation is
@@ -79,12 +89,17 @@ export declare class OpenAiConversation {
79
89
  * ahead of `sent`.
80
90
  */
81
91
  private turnBody;
82
- /** A read-only closing request: no tools, its own output limit, a strict schema. */
92
+ /**
93
+ * A read-only closing request: the next turn's body with tools off and its own output limit,
94
+ * and the strict schema only when `schema` is set.
95
+ */
83
96
  private closingBody;
84
97
  /**
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.
98
+ * A participant turn's reply to a request that added `sent`. A reply cut off by the output
99
+ * limit is set aside, so the next request is the same one again (outputLimitRetry); its actions
100
+ * never run, so no call output is owed for them.
88
101
  */
89
102
  private accept;
103
+ /** Move the conversation on to a reply to a request that added `sent`. */
104
+ private moveOn;
90
105
  }
@@ -1,6 +1,6 @@
1
1
  import { closingReportSchema, impressionsReplySchema, strictOutputSchema, } from "../closing-report.js";
2
2
  import { CarriedConversation, estimateTokens } from "./openai-context.js";
3
- import { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
3
+ import { asArray, asRecord, buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, inputMessage, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
4
4
  /**
5
5
  * The closing request's output limit: its own cap, or the declared limit when that is lower. It
6
6
  * counts reasoning too, and leaves room for six impressions after the summary and friction reports.
@@ -33,6 +33,8 @@ export class OpenAiConversation {
33
33
  /** Undefined once the account or model rejected reasoning summaries. */
34
34
  reasoningSummary;
35
35
  evidence;
36
+ /** The items of a closing request whose reply was not the stated JSON, for its strict retry. */
37
+ openClosing;
36
38
  constructor(settings) {
37
39
  this.settings = settings;
38
40
  this.mode = settings.zeroDataRetention ? "explicit_context" : "threaded";
@@ -40,16 +42,44 @@ export class OpenAiConversation {
40
42
  this.evidence = new ConversationRecord(settings.zeroDataRetention, settings.now);
41
43
  }
42
44
  /**
43
- * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
44
- * reply's computer calls are owed. A closing request asks for strict JSON and no tools.
45
+ * 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.
45
47
  */
46
- request(req, closing) {
48
+ request(req) {
47
49
  const sent = this.newItems(req);
48
50
  return {
49
- body: () => closing === undefined ? this.turnBody(req, sent) : this.closingBody(req, sent, closing),
50
- accept: (reply) => this.accept(reply, closing, sent),
51
+ body: () => this.turnBody(req, sent),
52
+ accept: (reply) => this.accept(reply, sent),
51
53
  };
52
54
  }
55
+ /**
56
+ * The read-only closing request carrying `req`'s screen, sent once with `send`. It goes out
57
+ * without text.format, which is part of the cached prefix, so it reads the session from the
58
+ * cache. A completed reply that is not JSON the schema accepts comes back marked closingRetry and
59
+ * leaves the conversation where it was, so the next closing request is the same one with the
60
+ * strict schema set. Returns the reply's turn with the account it gave.
61
+ */
62
+ async close(req, closing, send) {
63
+ const strict = this.openClosing !== undefined;
64
+ // Before any accepted reply the request is the opening one, which carries no hint of its own.
65
+ const ask = this.replies === 0 ? hintItems(req.contextHint) : [];
66
+ const sent = this.openClosing ?? [...this.newItems(req), ...ask, replyForm(closing)];
67
+ this.openClosing = undefined;
68
+ const reply = await send(this.closingBody(req, sent, closing, strict));
69
+ // Recorded before the reply is accepted, as a participant turn is.
70
+ if (closing === "impressions")
71
+ this.evidence.requested(this.mode, this.carried, sent, closing);
72
+ const completed = asRecord(reply).status === "completed";
73
+ const parsed = parseOpenAiResponse(reply);
74
+ const account = completed ? closingAccount(parsed.turn.message, closing, strict) : undefined;
75
+ // A refusal would most likely stand under the strict schema too, so it is not asked again.
76
+ if (completed && account === undefined && !strict && !refused(reply)) {
77
+ this.openClosing = sent;
78
+ return { ...parsed.turn, closingRetry: true };
79
+ }
80
+ this.moveOn(parsed, sent);
81
+ return { ...parsed.turn, ...account };
82
+ }
53
83
  /**
54
84
  * The organization rejected server-side state: carry the conversation on the client from the
55
85
  * request being sent, and record which answer made the switch. False when the conversation is
@@ -111,8 +141,9 @@ export class OpenAiConversation {
111
141
  */
112
142
  turnBody(req, sent) {
113
143
  const ctx = this.context(req.instructions);
144
+ // `sent` is the opening message, and on a closing request also the ask and the reply's form.
114
145
  if (this.replies === 0)
115
- return buildInitialRequest(ctx, req.observation.screenshot);
146
+ return { ...buildInitialRequest(ctx, req.observation.screenshot), input: [...sent] };
116
147
  return buildContinuationRequest({
117
148
  ctx,
118
149
  previousResponseId: this.lastResponseId,
@@ -120,36 +151,76 @@ export class OpenAiConversation {
120
151
  ...(this.mode === "explicit_context" ? { explicitContextItems: this.carried.carried() } : {}),
121
152
  });
122
153
  }
123
- /** A read-only closing request: no tools, its own output limit, a strict schema. */
124
- closingBody(req, sent, closing) {
154
+ /**
155
+ * A read-only closing request: the next turn's body with tools off and its own output limit,
156
+ * and the strict schema only when `schema` is set.
157
+ */
158
+ closingBody(req, sent, closing, schema) {
125
159
  return {
126
160
  ...this.turnBody(req, sent),
127
161
  tool_choice: "none",
128
162
  max_output_tokens: Math.min(this.settings.maxOutputTokens ?? CLOSING_OUTPUT_LIMIT, CLOSING_OUTPUT_LIMIT),
129
- text: { format: CLOSING_FORMATS[closing] },
163
+ ...(schema ? { text: { format: CLOSING_FORMATS[closing] } } : {}),
130
164
  };
131
165
  }
132
166
  /**
133
- * Move the conversation on to a reply to a request that added `sent`. A participant reply cut
134
- * off by the output limit is set aside, so the next request is the same one again
135
- * (outputLimitRetry); its actions never run, so no call output is owed for them.
167
+ * A participant turn's reply to a request that added `sent`. A reply cut off by the output
168
+ * limit is set aside, so the next request is the same one again (outputLimitRetry); its actions
169
+ * never run, so no call output is owed for them.
136
170
  */
137
- accept(reply, closing, sent) {
171
+ accept(reply, sent) {
138
172
  // Recorded before the reply is accepted: accepting can cut the carried history, and the record
139
173
  // counts what this request carried.
140
- if (closing !== "report")
141
- this.evidence.requested(this.mode, this.carried, sent, closing);
174
+ this.evidence.requested(this.mode, this.carried, sent);
142
175
  const parsed = parseOpenAiResponse(reply);
143
- if (closing === undefined && parsed.turn.interruption === "output_limit")
176
+ if (parsed.turn.interruption === "output_limit")
144
177
  return parsed.turn;
178
+ this.moveOn(parsed, sent);
179
+ return parsed.turn;
180
+ }
181
+ /** Move the conversation on to a reply to a request that added `sent`. */
182
+ moveOn(parsed, sent) {
145
183
  if (parsed.turn.responseId !== undefined)
146
184
  this.lastResponseId = parsed.turn.responseId;
147
185
  this.pendingCallIds = parsed.callIds;
148
186
  this.replies += 1;
149
187
  this.carried.accept(this.replies, sent, parsed.outputItems);
150
- return parsed.turn;
151
188
  }
152
189
  }
190
+ /**
191
+ * What form a closing reply takes, said in the request itself because text.format would rewrite
192
+ * the cached prefix: the schema the strict retry sets.
193
+ */
194
+ function replyForm(closing) {
195
+ const text = `Reply with only a JSON object that matches this JSON Schema, with no other text: ${JSON.stringify(CLOSING_FORMATS[closing].schema)}`;
196
+ return inputMessage("user", [{ type: "input_text", text }]);
197
+ }
198
+ /**
199
+ * The account a closing reply's text gives: JSON the request's schema accepts, or none. A reply
200
+ * sent without text.format must give every field the stated schema requires. With text.format the
201
+ * provider holds the reply to that schema, and the check is the one any provider's report gets.
202
+ */
203
+ function closingAccount(message, closing, strict) {
204
+ // Without text.format a model may set the object in a code fence or after a message of its own.
205
+ const text = message ?? "";
206
+ let reply;
207
+ try {
208
+ reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ if (closing === "report") {
214
+ const report = (strict ? closingReportSchema : closingReportSchema.required()).safeParse(reply);
215
+ return report.success ? { closingReport: report.data } : undefined;
216
+ }
217
+ const impressions = impressionsReplySchema.safeParse(reply);
218
+ return impressions.success ? { impressions: impressions.data.impressions } : undefined;
219
+ }
220
+ /** Whether a reply's messages include a refusal, which carries no output_text. */
221
+ function refused(reply) {
222
+ return asArray(asRecord(reply).output).some((item) => asArray(asRecord(item).content).some((part) => asRecord(part).type === "refusal"));
223
+ }
153
224
  /** The trace's account of how a session carried its conversation (ActorConversation). */
154
225
  class ConversationRecord {
155
226
  now;
@@ -1,8 +1,7 @@
1
- import { closingReportSchema, impressionsReplySchema } from "../closing-report.js";
2
1
  import { ComputerUseAdmissionLimitError, isComputerUseAdmissionLimitError, } from "./admission-limit.js";
3
2
  import { ComputerUsePromptRefusedError } from "./provider-error.js";
4
3
  import { OpenAiConversation, } from "./openai-conversation.js";
5
- import { asRecord, optionalString } from "./openai-wire.js";
4
+ import { optionalString } from "./openai-wire.js";
6
5
  import { redactText } from "../../evidence/redaction.js";
7
6
  import { isMaxOutputTokens } from "../output-token-limit.js";
8
7
  import { prepareContainedOutputFile, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../../run/contained-output.js";
@@ -455,38 +454,15 @@ export function createOpenAiResponsesProvider(options) {
455
454
  }
456
455
  }
457
456
  };
458
- const requestTurn = async (req, signal, closing, spend) => {
457
+ const requestTurn = async (req, signal, spend) => {
459
458
  await capture.prepareNext();
460
- const request = conversation.request(req, closing);
461
- // A closing request makes exactly one request: no HTTP or policy-latch retries.
462
- const raw = closing === undefined
463
- ? await postTurn(request, signal, spend)
464
- : await post(request.body(), signal, 0, false);
465
- const turn = request.accept(raw);
466
- if (closing !== undefined) {
467
- // Refusals, incomplete output, malformed JSON, and invalid shapes remain no-report results.
468
- // Never promote raw JSON or a fallback paragraph into a structured finding.
469
- try {
470
- const reply = JSON.parse(turn.message ?? "");
471
- if (asRecord(raw).status === "completed") {
472
- if (closing === "report") {
473
- const report = closingReportSchema.safeParse(reply);
474
- if (report.success)
475
- return { ...turn, closingReport: report.data };
476
- }
477
- else {
478
- const impressions = impressionsReplySchema.safeParse(reply);
479
- if (impressions.success)
480
- return { ...turn, impressions: impressions.data.impressions };
481
- }
482
- }
483
- }
484
- catch {
485
- /* A failed optional closing account keeps its usage and no report. */
486
- }
487
- }
488
- return turn;
459
+ const request = conversation.request(req);
460
+ return request.accept(await postTurn(request, signal, spend));
489
461
  };
462
+ // Each closing request is posted once: no HTTP or policy-latch retries. A reply with no valid
463
+ // account keeps its usage and gives no report: refusals, incomplete output, malformed JSON and
464
+ // invalid shapes are never promoted into a structured finding.
465
+ const requestClosing = (req, signal, closing) => conversation.close(req, closing, (body) => post(body, signal, 0, false));
490
466
  return {
491
467
  id: "openai-responses-cu",
492
468
  version: model,
@@ -505,7 +481,7 @@ export function createOpenAiResponsesProvider(options) {
505
481
  get interactionUsageIncomplete() {
506
482
  return interactionUsageIncomplete;
507
483
  },
508
- nextTurn: (req, signal, spend) => requestTurn(req, signal, undefined, spend),
484
+ nextTurn: (req, signal, spend) => requestTurn(req, signal, spend),
509
485
  get conversation() {
510
486
  return conversation.record();
511
487
  },
@@ -513,7 +489,7 @@ export function createOpenAiResponsesProvider(options) {
513
489
  // explicit_context one carries its newest screens whole and its oldest turns summarized past
514
490
  // the budget, which the trace's conversation record counts. A participant that worked a long
515
491
  // session writes its closing account from what it carries, as a person writes one from memory.
516
- debrief: (req, signal) => requestTurn(req, signal, "report"),
517
- requestImpressions: (req, signal) => requestTurn(req, signal, "impressions"),
492
+ debrief: (req, signal) => requestClosing(req, signal, "report"),
493
+ requestImpressions: (req, signal) => requestClosing(req, signal, "impressions"),
518
494
  };
519
495
  }
@@ -387,9 +387,14 @@ request), without provider support, or without remaining time and known budget.
387
387
  unreported usage remains an unknown cost line. `counts.debriefCalls` is separate
388
388
  from interaction turns, and reported usage contributes to aggregate cost.
389
389
 
390
- The OpenAI implementation makes one request with tools disabled and structured
391
- output; no HTTP or policy retries. Its output limit is 3072 tokens, or the declared
392
- `maxOutputTokens` when lower. Stateless/ZDR mode does not offer retrospective
390
+ The OpenAI implementation sends each closing request with tools disabled and no HTTP or
391
+ policy retries. The request sends no `text.format`, so it reads the session from the prompt
392
+ cache, and states the JSON Schema in its input. The reply's JSON object is read from its text,
393
+ also from inside a code fence or after a message of its own. A completed reply with no JSON that
394
+ schema accepts, apart from a refusal, comes back with `closingRetry` set. The loop may then call
395
+ the same method once more, after the checks the first request passed and with a time bound of
396
+ its own, and that request sets the strict schema. `counts.debriefCalls` counts each request. Its
397
+ output limit is 3072 tokens, or the declared `maxOutputTokens` when lower. Stateless/ZDR mode does not offer retrospective
393
398
  reporting: its client-carried conversation summarizes its oldest turns past a token
394
399
  budget, so it may not hold the whole session a retrospective report needs. It does offer
395
400
  the impressions-only request, which reads the carried conversation: its newest
@@ -1199,7 +1199,14 @@ Core-owned fields:
1199
1199
  `estimatedInputTokens`. The impressions-only request has `kind: impressions`;
1200
1200
  the optional `kind` field is absent on participant turns and in older bundles.
1201
1201
  Impressions carry the context the next turn would carry, with the same budget
1202
- and summarization of older turns. Billed input per request is in `tokenUsage.turns`. A
1202
+ and summarization of older turns. The closing report and impressions requests
1203
+ send no `text.format`, so they can read the session from the prompt cache; the
1204
+ request states the JSON Schema in its input instead. A completed reply with no
1205
+ JSON the schema accepts, apart from a refusal, can be sent once more with the
1206
+ strict schema, only after the budget and time checks the first passed and with
1207
+ a time bound of its own. An impressions request sent twice has two entries. The
1208
+ closing report has no entry here. Each closing request that reported usage has
1209
+ its own row in `tokenUsage.turns` after the participant's requests. Billed input per request is in `tokenUsage.turns`. A
1203
1210
  participant that ran in `explicit_context` gets a run warning. Absent on other
1204
1211
  providers and on earlier bundles
1205
1212
  - optional `affordanceUse` (`humanish.affordance-use.v1`): which kind of route this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.131.1",
3
+ "version": "0.132.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",