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 +12 -0
- package/dist/actors/computer-use/loop/debrief.js +63 -23
- package/dist/actors/computer-use/loop/types.d.ts +10 -1
- package/dist/actors/computer-use/openai-conversation.d.ts +22 -7
- package/dist/actors/computer-use/openai-conversation.js +89 -18
- package/dist/actors/computer-use/openai-provider.js +11 -35
- package/docs/architecture/actor-contract.md +8 -3
- package/docs/contracts/schemas.md +8 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
|
54
|
-
* reply's computer calls are owed.
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
44
|
-
* reply's computer calls are owed.
|
|
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
|
|
48
|
+
request(req) {
|
|
47
49
|
const sent = this.newItems(req);
|
|
48
50
|
return {
|
|
49
|
-
body: () =>
|
|
50
|
-
accept: (reply) => this.accept(reply,
|
|
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
|
-
/**
|
|
124
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
|
391
|
-
|
|
392
|
-
|
|
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.
|
|
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
|