xo-harness 0.2.0 → 0.3.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/README.md +92 -5
- package/dist/browser/worklets/capture-processor.js +34 -0
- package/dist/browser/worklets/playback-processor.js +188 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +1 -0
- package/dist/internal/browser/browser-voice-client.d.ts +33 -0
- package/dist/internal/browser/browser-voice-client.js +316 -0
- package/dist/internal/browser/index.d.ts +2 -0
- package/dist/internal/browser/index.js +1 -0
- package/dist/internal/harness/conversation-context.d.ts +45 -0
- package/dist/internal/harness/conversation-context.js +79 -0
- package/dist/internal/harness/conversation-projection.d.ts +55 -0
- package/dist/internal/harness/conversation-projection.js +145 -0
- package/dist/internal/harness/event-stream.d.ts +23 -2
- package/dist/internal/harness/event-stream.js +148 -18
- package/dist/internal/harness/index.d.ts +7 -1
- package/dist/internal/harness/index.js +7 -1
- package/dist/internal/harness/message.d.ts +24 -11
- package/dist/internal/harness/message.js +271 -77
- package/dist/internal/harness/report-diff.d.ts +3 -0
- package/dist/internal/harness/report-diff.js +10 -0
- package/dist/internal/harness/report.d.ts +17 -14
- package/dist/internal/harness/report.js +41 -24
- package/dist/internal/harness/runtime-limits.d.ts +18 -0
- package/dist/internal/harness/runtime-limits.js +19 -0
- package/dist/internal/harness/session-persistence.d.ts +16 -0
- package/dist/internal/harness/session-persistence.js +106 -0
- package/dist/internal/harness/shadow.d.ts +5 -5
- package/dist/internal/harness/shadow.js +214 -52
- package/dist/internal/harness/socket-bridge.d.ts +45 -4
- package/dist/internal/harness/socket-bridge.js +215 -46
- package/dist/internal/harness/task-supervisor.d.ts +6 -2
- package/dist/internal/harness/task-supervisor.js +97 -15
- package/dist/internal/harness/tool-calls.d.ts +40 -0
- package/dist/internal/harness/tool-calls.js +132 -0
- package/dist/internal/harness/tool-policy.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.js +37 -40
- package/dist/internal/harness/tools.d.ts +21 -0
- package/dist/internal/harness/tools.js +7 -1
- package/dist/internal/harness/usage-tracker.d.ts +39 -0
- package/dist/internal/harness/usage-tracker.js +104 -0
- package/dist/internal/harness/voice-session.d.ts +39 -8
- package/dist/internal/harness/voice-session.js +353 -58
- package/dist/internal/harness/xo.d.ts +7 -12
- package/dist/internal/harness/xo.js +25 -13
- package/dist/internal/protocol/async-queue.d.ts +22 -1
- package/dist/internal/protocol/async-queue.js +86 -12
- package/dist/internal/protocol/audio.d.ts +16 -2
- package/dist/internal/protocol/audio.js +23 -5
- package/dist/internal/protocol/backend-output.d.ts +70 -0
- package/dist/internal/protocol/backend-output.js +39 -0
- package/dist/internal/protocol/event-json.d.ts +3 -0
- package/dist/internal/protocol/event-json.js +15 -0
- package/dist/internal/protocol/events.d.ts +342 -4
- package/dist/internal/protocol/events.js +53 -25
- package/dist/internal/protocol/index.d.ts +5 -0
- package/dist/internal/protocol/index.js +5 -0
- package/dist/internal/protocol/output-source.d.ts +18 -0
- package/dist/internal/protocol/output-source.js +15 -0
- package/dist/internal/protocol/paced-audio.d.ts +27 -0
- package/dist/internal/protocol/paced-audio.js +117 -0
- package/dist/internal/protocol/parts.d.ts +154 -0
- package/dist/internal/protocol/parts.js +32 -6
- package/dist/internal/protocol/provider.d.ts +308 -1
- package/dist/internal/protocol/provider.js +102 -3
- package/dist/internal/protocol/records.d.ts +773 -0
- package/dist/internal/protocol/records.js +46 -0
- package/dist/internal/protocol/tools.d.ts +40 -0
- package/dist/internal/protocol/tools.js +12 -0
- package/dist/internal/protocol/transcript.d.ts +19 -0
- package/dist/internal/protocol/transcript.js +53 -0
- package/dist/internal/provider/contract.d.ts +25 -2
- package/dist/internal/provider/event-queue.d.ts +17 -0
- package/dist/internal/provider/event-queue.js +78 -0
- package/dist/internal/provider/grok-voice.d.ts +12 -9
- package/dist/internal/provider/grok-voice.js +26 -15
- package/dist/internal/provider/index.d.ts +1 -0
- package/dist/internal/provider/index.js +1 -0
- package/dist/internal/provider/live-session.d.ts +29 -0
- package/dist/internal/provider/live-session.js +840 -0
- package/dist/internal/provider/node-socket.js +6 -0
- package/dist/internal/provider/openai-live.d.ts +62 -0
- package/dist/internal/provider/openai-live.js +160 -0
- package/dist/internal/provider/openai-realtime.d.ts +9 -5
- package/dist/internal/provider/openai-realtime.js +24 -10
- package/dist/internal/provider/realtime-session.d.ts +12 -1
- package/dist/internal/provider/realtime-session.js +373 -70
- package/dist/internal/provider/realtime-socket.d.ts +3 -1
- package/dist/internal/provider/tool-status-context.d.ts +10 -0
- package/dist/internal/provider/tool-status-context.js +30 -0
- package/dist/internal/provider/workers-socket.js +63 -15
- package/dist/internal/provider/workers.d.ts +1 -0
- package/dist/internal/provider/workers.js +1 -0
- package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
- package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
- package/dist/internal/storage/event-store.d.ts +22 -4
- package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
- package/dist/internal/storage/jsonl-event-store.js +21 -16
- package/dist/internal/storage/memory-event-store.d.ts +4 -3
- package/dist/internal/storage/memory-event-store.js +8 -2
- package/dist/internal/storage/memory.d.ts +1 -1
- package/dist/internal/testkit/events.d.ts +14 -0
- package/dist/internal/testkit/events.js +33 -0
- package/dist/internal/testkit/runtime.d.ts +3 -0
- package/dist/internal/testkit/runtime.js +3 -0
- package/dist/internal/testkit/trajectory.d.ts +23 -0
- package/dist/internal/testkit/trajectory.js +58 -0
- package/dist/internal/tools-openai/index.d.ts +2 -0
- package/dist/internal/tools-openai/index.js +79 -72
- package/dist/internal/tools-openai/responses.d.ts +5 -1
- package/dist/internal/tools-openai/responses.js +53 -5
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +7 -1
|
@@ -1,27 +1,41 @@
|
|
|
1
|
-
import { AssistantTurnRequestSchema,
|
|
1
|
+
import { AssistantTurnRequestSchema, AudioChunkSchema, audioSampleCount, PlayoutProgressSchema, PROVIDER_DIAGNOSTIC_MAX_CONTENT_TYPES, PROVIDER_DIAGNOSTIC_MAX_OUTPUTS, PROVIDER_DIAGNOSTIC_STRING_MAX_LENGTH, ProviderContextEventSchema, ProviderToolResultSchema, ToolCallSchema, } from "../protocol/index.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { ProviderEventQueue } from "./event-queue.js";
|
|
4
|
+
import { toolStatusContext } from "./tool-status-context.js";
|
|
3
5
|
const FALLBACK_STREAM_ID = "assistant-output";
|
|
4
6
|
const CLOSE_REASON_BYTE_LIMIT = 120;
|
|
5
7
|
const EMITTED_USER_TRANSCRIPT_CACHE_LIMIT = 128;
|
|
8
|
+
const TOOL_RESULT_CORRELATION_LIMIT = 4096;
|
|
6
9
|
const VAD_RESPONSE_RECOVERY_MS = 1_500;
|
|
7
10
|
const ServerEnvelopeSchema = z.looseObject({ type: z.string().min(1) });
|
|
8
|
-
const
|
|
11
|
+
const OutputCoordinatesSchema = z.object({
|
|
9
12
|
item_id: z.string().min(1).optional(),
|
|
10
13
|
response_id: z.string().min(1).optional(),
|
|
14
|
+
output_index: z.int().nonnegative().optional(),
|
|
15
|
+
content_index: z.int().nonnegative().optional(),
|
|
16
|
+
});
|
|
17
|
+
const OutputAudioDeltaSchema = z.looseObject({
|
|
18
|
+
...OutputCoordinatesSchema.shape,
|
|
11
19
|
delta: z.string().min(1).optional(),
|
|
12
20
|
data: z.string().min(1).optional(),
|
|
13
21
|
});
|
|
14
22
|
const FunctionCallItemSchema = z.looseObject({
|
|
15
23
|
type: z.literal("function_call"),
|
|
24
|
+
id: z.string().min(1).optional(),
|
|
16
25
|
call_id: z.string().min(1),
|
|
17
26
|
name: z.string().min(1),
|
|
18
27
|
arguments: z.string(),
|
|
19
28
|
});
|
|
20
29
|
const FunctionCallArgumentsDoneSchema = z.looseObject({
|
|
30
|
+
...OutputCoordinatesSchema.shape,
|
|
21
31
|
call_id: z.string().min(1),
|
|
22
32
|
name: z.string().min(1),
|
|
23
33
|
arguments: z.string(),
|
|
24
34
|
});
|
|
35
|
+
const OutputItemDoneSchema = z.looseObject({
|
|
36
|
+
...OutputCoordinatesSchema.shape,
|
|
37
|
+
item: FunctionCallItemSchema,
|
|
38
|
+
});
|
|
25
39
|
const UsageDetailsSchema = z.looseObject({
|
|
26
40
|
text_tokens: z.number().int().nonnegative().optional(),
|
|
27
41
|
audio_tokens: z.number().int().nonnegative().optional(),
|
|
@@ -55,19 +69,18 @@ const ResponseDiagnosticSchema = z.looseObject({
|
|
|
55
69
|
status_details: ResponseStatusDetailsSchema.nullish(),
|
|
56
70
|
output: z.array(ResponseOutputDiagnosticSchema).max(PROVIDER_DIAGNOSTIC_MAX_OUTPUTS).optional(),
|
|
57
71
|
});
|
|
58
|
-
|
|
72
|
+
const ResponseUsageSchema = z.looseObject({
|
|
73
|
+
total_tokens: z.number().int().nonnegative(),
|
|
74
|
+
input_tokens: z.number().int().nonnegative(),
|
|
75
|
+
output_tokens: z.number().int().nonnegative(),
|
|
76
|
+
input_token_details: UsageDetailsSchema.optional(),
|
|
77
|
+
output_token_details: UsageDetailsSchema.optional(),
|
|
78
|
+
});
|
|
79
|
+
/** Optional usage and diagnostics must never decide whether a function completed. */
|
|
59
80
|
const ResponseDoneSchema = z.looseObject({
|
|
60
81
|
response: z.looseObject({
|
|
61
82
|
output: z.array(z.looseObject({ type: z.string().optional() })).optional(),
|
|
62
|
-
usage: z
|
|
63
|
-
.looseObject({
|
|
64
|
-
total_tokens: z.number().int().nonnegative(),
|
|
65
|
-
input_tokens: z.number().int().nonnegative(),
|
|
66
|
-
output_tokens: z.number().int().nonnegative(),
|
|
67
|
-
input_token_details: UsageDetailsSchema.optional(),
|
|
68
|
-
output_token_details: UsageDetailsSchema.optional(),
|
|
69
|
-
})
|
|
70
|
-
.optional(),
|
|
83
|
+
usage: z.unknown().optional(),
|
|
71
84
|
}),
|
|
72
85
|
});
|
|
73
86
|
const ResponseEnvelopeSchema = z.looseObject({
|
|
@@ -79,18 +92,23 @@ const ResponseIdEnvelopeSchema = z.looseObject({
|
|
|
79
92
|
});
|
|
80
93
|
const TranscriptEventSchema = z.looseObject({
|
|
81
94
|
transcript: z.string(),
|
|
82
|
-
|
|
95
|
+
...OutputCoordinatesSchema.shape,
|
|
96
|
+
});
|
|
97
|
+
const InputTranscriptionFailedSchema = z.looseObject({
|
|
98
|
+
item_id: z.string().min(1),
|
|
99
|
+
content_index: z.int().nonnegative(),
|
|
100
|
+
error: z.looseObject({ message: z.string().nullish(), code: z.string().nullish() }),
|
|
83
101
|
});
|
|
84
102
|
const OutputTextDoneSchema = z.looseObject({
|
|
85
103
|
text: z.string(),
|
|
86
|
-
|
|
104
|
+
...OutputCoordinatesSchema.shape,
|
|
87
105
|
});
|
|
88
106
|
const TranscriptDeltaWireSchema = z.looseObject({
|
|
89
107
|
delta: z.string(),
|
|
90
|
-
|
|
108
|
+
...OutputCoordinatesSchema.shape,
|
|
91
109
|
});
|
|
92
110
|
const ServerErrorSchema = z.looseObject({
|
|
93
|
-
error: z.looseObject({ message: z.string().optional() }).optional(),
|
|
111
|
+
error: z.looseObject({ message: z.string().optional(), event_id: z.string().min(1).nullish() }).optional(),
|
|
94
112
|
});
|
|
95
113
|
const SessionUpdatedSchema = z.looseObject({
|
|
96
114
|
session: z
|
|
@@ -116,12 +134,20 @@ const SpeechBoundarySchema = z.looseObject({
|
|
|
116
134
|
export class RealtimeVoiceSession {
|
|
117
135
|
#socket;
|
|
118
136
|
#wire;
|
|
119
|
-
#
|
|
137
|
+
#signal;
|
|
138
|
+
#onAbort = () => {
|
|
139
|
+
void this.close(String(this.#signal.reason ?? "aborted"));
|
|
140
|
+
};
|
|
141
|
+
#queue;
|
|
120
142
|
#outputStreams = new Map();
|
|
121
143
|
#playedThrough = new Map();
|
|
122
144
|
#interruptedStreams = new Set();
|
|
145
|
+
#interruptedResponseIds = new Set();
|
|
123
146
|
#deliveredToolCalls = new Set();
|
|
124
|
-
#
|
|
147
|
+
#rejectedToolCalls = new Set();
|
|
148
|
+
#backgroundCalls = new Map();
|
|
149
|
+
#toolResultEvents = new Map();
|
|
150
|
+
#completedResponses = new Map();
|
|
125
151
|
#usageResponseIds = new Set();
|
|
126
152
|
#pendingUserTranscripts = new Map();
|
|
127
153
|
#emittedUserTranscripts = new Map();
|
|
@@ -131,6 +157,8 @@ export class RealtimeVoiceSession {
|
|
|
131
157
|
#responseNumber = 0;
|
|
132
158
|
#responseActive = false;
|
|
133
159
|
#activeResponseId;
|
|
160
|
+
#pendingResponseEventId;
|
|
161
|
+
#clientEventSequence = 0;
|
|
134
162
|
#responseWanted = false;
|
|
135
163
|
#inputSpeechActive = false;
|
|
136
164
|
#awaitingVadResponse = false;
|
|
@@ -139,26 +167,49 @@ export class RealtimeVoiceSession {
|
|
|
139
167
|
#wantedResponseInstructions;
|
|
140
168
|
#ready = false;
|
|
141
169
|
#closed = false;
|
|
170
|
+
#settling = false;
|
|
142
171
|
#closeReason;
|
|
143
172
|
constructor(socket, wire, options) {
|
|
144
173
|
this.#socket = socket;
|
|
145
174
|
this.#wire = wire;
|
|
175
|
+
this.#signal = options.signal;
|
|
146
176
|
this.#outputSampleRate = wire.outputSampleRate;
|
|
147
177
|
this.#settled = new Promise((resolve) => {
|
|
148
178
|
this.#resolveSettled = resolve;
|
|
149
179
|
});
|
|
180
|
+
this.#queue = new ProviderEventQueue(wire.eventQueueLimits, () => {
|
|
181
|
+
this.#settle("provider_event_queue_failed");
|
|
182
|
+
if (socket.state !== "closed") {
|
|
183
|
+
if (socket.terminate)
|
|
184
|
+
socket.terminate();
|
|
185
|
+
else
|
|
186
|
+
socket.close(1011, "provider_event_queue_failed");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
150
189
|
socket.onMessage = (text) => this.#handleMessage(text);
|
|
151
190
|
socket.onError = (message) => this.#emitError(message, false);
|
|
152
191
|
socket.onClose = (code) => this.#settle(this.#closeReason ?? `socket_closed_${code}`);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
192
|
+
this.#signal.addEventListener("abort", this.#onAbort, { once: true });
|
|
193
|
+
if (this.#signal.aborted) {
|
|
194
|
+
this.#onAbort();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
156
197
|
this.#send({ type: "session.update", session: wire.sessionUpdate });
|
|
157
198
|
}
|
|
199
|
+
get failureSignal() {
|
|
200
|
+
return this.#queue.failureSignal;
|
|
201
|
+
}
|
|
202
|
+
eventQueueStats() {
|
|
203
|
+
return this.#queue.stats();
|
|
204
|
+
}
|
|
158
205
|
get events() {
|
|
159
206
|
return this.#queue;
|
|
160
207
|
}
|
|
161
208
|
async sendAudio(chunk) {
|
|
209
|
+
this.failureSignal.throwIfAborted();
|
|
210
|
+
if (this.#wire.autoCommitAudio === false) {
|
|
211
|
+
throw new Error(`${this.#wire.label} cannot accept audio with turnDetection: null; XO does not expose manual input-audio commit. Enable turn detection, optionally with create_response: false for host-triggered replies.`);
|
|
212
|
+
}
|
|
162
213
|
const validated = AudioChunkSchema.parse(chunk);
|
|
163
214
|
if (validated.channels !== 1) {
|
|
164
215
|
throw new Error(`${this.#wire.label} expects mono PCM input`);
|
|
@@ -169,6 +220,7 @@ export class RealtimeVoiceSession {
|
|
|
169
220
|
this.#send({ type: "input_audio_buffer.append", audio: toBase64(validated.data) });
|
|
170
221
|
}
|
|
171
222
|
async sendText(text) {
|
|
223
|
+
this.failureSignal.throwIfAborted();
|
|
172
224
|
this.#send({
|
|
173
225
|
type: "conversation.item.create",
|
|
174
226
|
item: { type: "message", role: "user", content: [{ type: "input_text", text }] },
|
|
@@ -176,38 +228,110 @@ export class RealtimeVoiceSession {
|
|
|
176
228
|
this.#requestResponse();
|
|
177
229
|
}
|
|
178
230
|
async requestAssistantTurn(request = {}) {
|
|
231
|
+
this.failureSignal.throwIfAborted();
|
|
179
232
|
const validated = AssistantTurnRequestSchema.parse(request);
|
|
180
233
|
this.#requestResponse(validated.instructions);
|
|
181
234
|
}
|
|
182
235
|
async reportPlayout(progress) {
|
|
236
|
+
this.failureSignal.throwIfAborted();
|
|
183
237
|
const validated = PlayoutProgressSchema.parse(progress);
|
|
184
|
-
this.#playedThrough.set(validated.streamId, validated.playedThroughSample);
|
|
238
|
+
this.#playedThrough.set(validated.streamId, Math.max(this.#playedThrough.get(validated.streamId) ?? 0, validated.playedThroughSample));
|
|
185
239
|
}
|
|
186
240
|
async sendToolResult(result) {
|
|
241
|
+
this.failureSignal.throwIfAborted();
|
|
187
242
|
const validated = ProviderToolResultSchema.parse(result);
|
|
188
|
-
this.#
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
243
|
+
if (this.#wire.nativeAsyncTools && validated.outcome.type === "accepted_task") {
|
|
244
|
+
const { taskId } = validated.outcome;
|
|
245
|
+
const existing = this.#backgroundCalls.get(validated.callId);
|
|
246
|
+
if (existing) {
|
|
247
|
+
if (existing.taskId !== taskId)
|
|
248
|
+
throw new Error("A background tool call cannot be assigned to a different task");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if ([...this.#backgroundCalls.values()].some((call) => call.taskId === taskId))
|
|
252
|
+
throw new Error("A background task cannot belong to a different tool call");
|
|
253
|
+
// A task handle is acceptance, not the result of the original function call.
|
|
254
|
+
// Keeping that call open lets native async-tool reasoning recognize pending work.
|
|
255
|
+
this.#sendContextItem(JSON.stringify({
|
|
256
|
+
type: "accepted_task",
|
|
257
|
+
callId: validated.callId,
|
|
258
|
+
taskId,
|
|
259
|
+
note: "The original function call remains pending. Its final output will arrive automatically; continue independent work while waiting.",
|
|
260
|
+
}));
|
|
261
|
+
this.#backgroundCalls.set(validated.callId, { taskId, settled: false });
|
|
262
|
+
this.#requestResponse();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.#sendFunctionResult(validated.callId, toolOutcomePayload(validated.outcome));
|
|
196
266
|
this.#requestResponse();
|
|
197
267
|
}
|
|
268
|
+
#sendFunctionResult(callId, output) {
|
|
269
|
+
const eventId = this.#wire.correlateClientErrors
|
|
270
|
+
? `xo-realtime-tool-result-${++this.#clientEventSequence}`
|
|
271
|
+
: undefined;
|
|
272
|
+
if (eventId !== undefined)
|
|
273
|
+
this.#toolResultEvents.set(eventId, callId);
|
|
274
|
+
try {
|
|
275
|
+
this.#send({
|
|
276
|
+
type: "conversation.item.create",
|
|
277
|
+
...(eventId === undefined ? {} : { event_id: eventId }),
|
|
278
|
+
item: {
|
|
279
|
+
type: "function_call_output",
|
|
280
|
+
call_id: callId,
|
|
281
|
+
output,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (eventId !== undefined)
|
|
287
|
+
this.#toolResultEvents.delete(eventId);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
// Retain recent submissions for asynchronous rejections without an unbounded
|
|
291
|
+
// per-session command log. An error never retries the action or its output.
|
|
292
|
+
if (this.#toolResultEvents.size > TOOL_RESULT_CORRELATION_LIMIT) {
|
|
293
|
+
const oldest = this.#toolResultEvents.keys().next().value;
|
|
294
|
+
if (oldest !== undefined)
|
|
295
|
+
this.#toolResultEvents.delete(oldest);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
198
298
|
async sendContext(event) {
|
|
299
|
+
this.failureSignal.throwIfAborted();
|
|
199
300
|
const validated = ProviderContextEventSchema.parse(event);
|
|
301
|
+
if (validated.type !== "tool.status") {
|
|
302
|
+
const background = this.#backgroundCalls.get(validated.callId);
|
|
303
|
+
if (!background && [...this.#backgroundCalls.values()].some((call) => call.taskId === validated.taskId))
|
|
304
|
+
throw new Error("Task context does not match the accepted background tool call");
|
|
305
|
+
if (background) {
|
|
306
|
+
if (background.taskId !== validated.taskId)
|
|
307
|
+
throw new Error("Task context does not match the accepted background tool call");
|
|
308
|
+
if (background.settled)
|
|
309
|
+
return;
|
|
310
|
+
if (validated.type !== "task.progress") {
|
|
311
|
+
this.#sendFunctionResult(validated.callId, taskResultPayload(validated));
|
|
312
|
+
background.settled = true;
|
|
313
|
+
if (this.#wire.announceTaskSettlement)
|
|
314
|
+
this.#requestResponse();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.#sendContextItem(contextText(validated));
|
|
320
|
+
if (validated.type !== "tool.status" &&
|
|
321
|
+
validated.type !== "task.progress" &&
|
|
322
|
+
this.#wire.announceTaskSettlement) {
|
|
323
|
+
this.#requestResponse();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
#sendContextItem(text) {
|
|
200
327
|
this.#send({
|
|
201
328
|
type: "conversation.item.create",
|
|
202
329
|
item: {
|
|
203
330
|
type: "message",
|
|
204
331
|
role: "system",
|
|
205
|
-
content: [{ type: "input_text", text
|
|
332
|
+
content: [{ type: "input_text", text }],
|
|
206
333
|
},
|
|
207
334
|
});
|
|
208
|
-
if (validated.type !== "task.progress" && this.#wire.announceTaskSettlement) {
|
|
209
|
-
this.#requestResponse();
|
|
210
|
-
}
|
|
211
335
|
}
|
|
212
336
|
async close(reason = "client_closed") {
|
|
213
337
|
this.#closeReason ??= reason;
|
|
@@ -222,6 +346,8 @@ export class RealtimeVoiceSession {
|
|
|
222
346
|
await this.#settled;
|
|
223
347
|
}
|
|
224
348
|
#handleMessage(text) {
|
|
349
|
+
if (this.#closed)
|
|
350
|
+
return;
|
|
225
351
|
let parsed;
|
|
226
352
|
try {
|
|
227
353
|
parsed = JSON.parse(text);
|
|
@@ -241,8 +367,19 @@ export class RealtimeVoiceSession {
|
|
|
241
367
|
break;
|
|
242
368
|
case "response.created":
|
|
243
369
|
this.#handleResponseCreated(parsed);
|
|
370
|
+
this.#pendingResponseEventId = undefined;
|
|
244
371
|
this.#responseActive = true;
|
|
245
372
|
this.#activeResponseId = responseIdFromEnvelope(parsed);
|
|
373
|
+
// A response.create already in flight can arrive after the server's VAD start.
|
|
374
|
+
// Automatic interruption only cancelled responses that existed at that boundary.
|
|
375
|
+
if (this.#inputSpeechActive && this.#awaitingVadResponse && this.#wire.interruptOnSpeech !== false) {
|
|
376
|
+
if (this.#activeResponseId !== undefined)
|
|
377
|
+
this.#interruptedResponseIds.add(this.#activeResponseId);
|
|
378
|
+
this.#trySend({
|
|
379
|
+
type: "response.cancel",
|
|
380
|
+
...(this.#activeResponseId === undefined ? {} : { response_id: this.#activeResponseId }),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
246
383
|
if (!this.#inputSpeechActive)
|
|
247
384
|
this.#clearAwaitingVadResponse();
|
|
248
385
|
this.#reportResponseState();
|
|
@@ -266,9 +403,19 @@ export class RealtimeVoiceSession {
|
|
|
266
403
|
case "conversation.item.input_audio_transcription.completed":
|
|
267
404
|
this.#handleTranscript(parsed, "user");
|
|
268
405
|
break;
|
|
406
|
+
case "conversation.item.input_audio_transcription.failed": {
|
|
407
|
+
const failed = InputTranscriptionFailedSchema.safeParse(parsed);
|
|
408
|
+
this.#emitError(failed.success
|
|
409
|
+
? `Input audio transcription failed for ${failed.data.item_id} content ${failed.data.content_index}: ${failed.data.error.message ?? failed.data.error.code ?? "Unknown transcription error"}`
|
|
410
|
+
: "Received a malformed input audio transcription failure", true);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
269
413
|
case "response.function_call_arguments.done":
|
|
270
414
|
this.#handleFunctionCallArgumentsDone(parsed);
|
|
271
415
|
break;
|
|
416
|
+
case "response.output_item.done":
|
|
417
|
+
this.#handleOutputItemDone(parsed);
|
|
418
|
+
break;
|
|
272
419
|
case "response.done":
|
|
273
420
|
this.#handleResponseDone(parsed);
|
|
274
421
|
break;
|
|
@@ -319,6 +466,8 @@ export class RealtimeVoiceSession {
|
|
|
319
466
|
this.#emitError("Received a malformed output audio delta", true);
|
|
320
467
|
return;
|
|
321
468
|
}
|
|
469
|
+
if (delta.data.response_id !== undefined && this.#interruptedResponseIds.has(delta.data.response_id))
|
|
470
|
+
return;
|
|
322
471
|
const payload = delta.data.delta ?? delta.data.data;
|
|
323
472
|
if (payload === undefined) {
|
|
324
473
|
this.#emitError("Received an output audio delta without an audio payload", true);
|
|
@@ -336,9 +485,11 @@ export class RealtimeVoiceSession {
|
|
|
336
485
|
this.#emitError(`Received a PCM16 delta with invalid byte length ${bytes.byteLength}`, true);
|
|
337
486
|
return;
|
|
338
487
|
}
|
|
339
|
-
const
|
|
488
|
+
const baseStreamId = delta.data.item_id ??
|
|
340
489
|
delta.data.response_id ??
|
|
341
490
|
(this.#responseNumber === 0 ? FALLBACK_STREAM_ID : `${FALLBACK_STREAM_ID}-${this.#responseNumber}`);
|
|
491
|
+
const streamId = contentStreamId(baseStreamId, delta.data.content_index);
|
|
492
|
+
const source = outputSource(delta.data);
|
|
342
493
|
if (this.#interruptedStreams.has(streamId))
|
|
343
494
|
return;
|
|
344
495
|
const stream = this.#outputStreams.get(streamId) ?? {
|
|
@@ -346,6 +497,8 @@ export class RealtimeVoiceSession {
|
|
|
346
497
|
startSample: 0,
|
|
347
498
|
complete: false,
|
|
348
499
|
itemId: delta.data.item_id,
|
|
500
|
+
responseId: delta.data.response_id ?? this.#activeResponseId,
|
|
501
|
+
contentIndex: delta.data.content_index ?? 0,
|
|
349
502
|
};
|
|
350
503
|
const chunk = AudioChunkSchema.parse({
|
|
351
504
|
streamId,
|
|
@@ -359,20 +512,22 @@ export class RealtimeVoiceSession {
|
|
|
359
512
|
stream.sequence += 1;
|
|
360
513
|
stream.startSample += audioSampleCount(chunk);
|
|
361
514
|
this.#outputStreams.set(streamId, stream);
|
|
362
|
-
this.#emit({ type: "audio.output", chunk });
|
|
515
|
+
this.#emit({ type: "audio.output", chunk, ...(source === undefined ? {} : { source }) });
|
|
363
516
|
}
|
|
364
517
|
#handleTranscript(parsed, role) {
|
|
365
518
|
const transcript = TranscriptEventSchema.safeParse(parsed);
|
|
366
519
|
if (!transcript.success)
|
|
367
520
|
return;
|
|
521
|
+
const source = outputSource(transcript.data);
|
|
522
|
+
const streamId = transcriptStreamId(transcript.data);
|
|
368
523
|
if (role === "user" && transcript.data.item_id !== undefined) {
|
|
369
|
-
this.#queueUserTranscript(transcript.data.item_id, transcript.data.transcript);
|
|
524
|
+
this.#queueUserTranscript(streamId ?? transcript.data.item_id, transcript.data.transcript, source);
|
|
370
525
|
return;
|
|
371
526
|
}
|
|
372
|
-
this.#emitTranscript(role, transcript.data.transcript,
|
|
527
|
+
this.#emitTranscript(role, transcript.data.transcript, streamId, source);
|
|
373
528
|
}
|
|
374
529
|
/** xAI can send several "completed" revisions for one input item; emit only the settled text. */
|
|
375
|
-
#queueUserTranscript(streamId, rawText) {
|
|
530
|
+
#queueUserTranscript(streamId, rawText, source) {
|
|
376
531
|
const text = rawText.trim();
|
|
377
532
|
if (text === "")
|
|
378
533
|
return;
|
|
@@ -380,7 +535,11 @@ export class RealtimeVoiceSession {
|
|
|
380
535
|
if (pending)
|
|
381
536
|
clearTimeout(pending.timer);
|
|
382
537
|
const timer = setTimeout(() => this.#flushUserTranscript(streamId), this.#wire.inputTranscriptSettleMs ?? 0);
|
|
383
|
-
this.#pendingUserTranscripts.set(streamId, {
|
|
538
|
+
this.#pendingUserTranscripts.set(streamId, {
|
|
539
|
+
text,
|
|
540
|
+
source: source === undefined ? pending?.source : { ...pending?.source, ...source },
|
|
541
|
+
timer,
|
|
542
|
+
});
|
|
384
543
|
if (!this.#wire.inputTranscriptSettleMs)
|
|
385
544
|
this.#flushUserTranscript(streamId);
|
|
386
545
|
}
|
|
@@ -399,7 +558,7 @@ export class RealtimeVoiceSession {
|
|
|
399
558
|
if (oldestStreamId !== undefined)
|
|
400
559
|
this.#emittedUserTranscripts.delete(oldestStreamId);
|
|
401
560
|
}
|
|
402
|
-
this.#emitTranscript("user", pending.text, streamId);
|
|
561
|
+
this.#emitTranscript("user", pending.text, streamId, pending.source);
|
|
403
562
|
}
|
|
404
563
|
#flushUserTranscripts() {
|
|
405
564
|
for (const streamId of [...this.#pendingUserTranscripts.keys()])
|
|
@@ -409,46 +568,86 @@ export class RealtimeVoiceSession {
|
|
|
409
568
|
const delta = TranscriptDeltaWireSchema.safeParse(parsed);
|
|
410
569
|
if (!delta.success || delta.data.delta === "")
|
|
411
570
|
return;
|
|
571
|
+
const source = outputSource(delta.data);
|
|
572
|
+
const streamId = transcriptStreamId(delta.data);
|
|
412
573
|
this.#emit({
|
|
413
574
|
type: "transcript.delta",
|
|
414
575
|
role,
|
|
415
576
|
delta: delta.data.delta,
|
|
416
|
-
...(
|
|
577
|
+
...(streamId === undefined ? {} : { streamId }),
|
|
578
|
+
...(source === undefined ? {} : { source }),
|
|
417
579
|
});
|
|
418
580
|
}
|
|
419
581
|
#handleOutputText(parsed) {
|
|
420
582
|
const output = OutputTextDoneSchema.safeParse(parsed);
|
|
421
583
|
if (!output.success)
|
|
422
584
|
return;
|
|
423
|
-
this.#emitTranscript("assistant", output.data.text, output.data.
|
|
585
|
+
this.#emitTranscript("assistant", output.data.text, transcriptStreamId(output.data), outputSource(output.data));
|
|
424
586
|
}
|
|
425
|
-
#emitTranscript(role, rawText, streamId) {
|
|
587
|
+
#emitTranscript(role, rawText, streamId, source) {
|
|
426
588
|
const text = rawText.trim();
|
|
427
589
|
if (text === "")
|
|
428
590
|
return;
|
|
429
|
-
this.#emit({
|
|
591
|
+
this.#emit({
|
|
592
|
+
type: "transcript",
|
|
593
|
+
role,
|
|
594
|
+
text,
|
|
595
|
+
...(streamId === undefined ? {} : { streamId }),
|
|
596
|
+
...(source === undefined ? {} : { source }),
|
|
597
|
+
});
|
|
430
598
|
}
|
|
431
599
|
#handleFunctionCallArgumentsDone(parsed) {
|
|
600
|
+
// OpenAI also emits this for cancelled/incomplete calls. Its authoritative
|
|
601
|
+
// item/response completion carries full arguments, so no speculative buffer is needed.
|
|
602
|
+
if (this.#wire.toolCallCompletion !== "arguments")
|
|
603
|
+
return;
|
|
432
604
|
const done = FunctionCallArgumentsDoneSchema.safeParse(parsed);
|
|
433
605
|
if (!done.success)
|
|
434
606
|
return;
|
|
435
|
-
this.#emitFunctionCall(done.data.call_id, done.data.name, done.data.arguments);
|
|
607
|
+
this.#emitFunctionCall(done.data.call_id, done.data.name, done.data.arguments, outputSource(done.data));
|
|
608
|
+
}
|
|
609
|
+
#handleOutputItemDone(parsed) {
|
|
610
|
+
const done = OutputItemDoneSchema.safeParse(parsed);
|
|
611
|
+
if (!done.success)
|
|
612
|
+
return;
|
|
613
|
+
const item = done.data.item;
|
|
614
|
+
if (item.status !== undefined && item.status !== "completed") {
|
|
615
|
+
this.#rejectToolCall(item.call_id);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (item.status !== "completed" && this.#wire.toolCallCompletion === "confirmed")
|
|
619
|
+
return;
|
|
620
|
+
this.#emitFunctionCall(item.call_id, item.name, item.arguments, outputSource({
|
|
621
|
+
...done.data,
|
|
622
|
+
...(item.id === undefined ? {} : { item_id: item.id }),
|
|
623
|
+
}));
|
|
436
624
|
}
|
|
437
625
|
#handleResponseDone(parsed) {
|
|
438
626
|
this.#emitResponseEnvelopeDiagnostic(parsed, "completed");
|
|
439
627
|
const responseId = responseIdFromEnvelope(parsed);
|
|
440
|
-
const duplicate = responseId !== undefined && this.#
|
|
441
|
-
if (responseId !== undefined)
|
|
442
|
-
this.#completedResponseIds.add(responseId);
|
|
628
|
+
const duplicate = responseId !== undefined && this.#completedResponses.has(responseId);
|
|
443
629
|
const done = ResponseDoneSchema.safeParse(parsed);
|
|
630
|
+
const status = done.success ? done.data.response.status : undefined;
|
|
631
|
+
const completed = done.success &&
|
|
632
|
+
(status === "completed" || (status === undefined && this.#wire.toolCallCompletion === "arguments"));
|
|
633
|
+
if (responseId !== undefined && !duplicate)
|
|
634
|
+
this.#completedResponses.set(responseId, completed);
|
|
444
635
|
if (done.success) {
|
|
445
636
|
const usage = done.data.response.usage;
|
|
446
|
-
if (usage && (responseId === undefined || !this.#usageResponseIds.has(responseId))) {
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
this.#
|
|
637
|
+
if (usage !== undefined && (responseId === undefined || !this.#usageResponseIds.has(responseId))) {
|
|
638
|
+
const parsedUsage = ResponseUsageSchema.safeParse(usage);
|
|
639
|
+
if (parsedUsage.success) {
|
|
640
|
+
this.#emitUsage(parsedUsage.data);
|
|
641
|
+
if (responseId !== undefined)
|
|
642
|
+
this.#usageResponseIds.add(responseId);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
this.#emitError("Received incomplete or invalid response usage; token accounting remains unconfirmed", true);
|
|
646
|
+
}
|
|
450
647
|
}
|
|
451
|
-
for (const item of done.data.response.output ?? []) {
|
|
648
|
+
for (const [outputIndex, item] of (done.data.response.output ?? []).entries()) {
|
|
649
|
+
if (duplicate)
|
|
650
|
+
break;
|
|
452
651
|
if (item.type !== "function_call")
|
|
453
652
|
continue;
|
|
454
653
|
const call = FunctionCallItemSchema.safeParse(item);
|
|
@@ -456,7 +655,15 @@ export class RealtimeVoiceSession {
|
|
|
456
655
|
this.#emitError("Received a function call item without call_id, name, or arguments", true);
|
|
457
656
|
continue;
|
|
458
657
|
}
|
|
459
|
-
|
|
658
|
+
if (!completed || (call.data.status !== undefined && call.data.status !== "completed")) {
|
|
659
|
+
this.#rejectToolCall(call.data.call_id);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
this.#emitFunctionCall(call.data.call_id, call.data.name, call.data.arguments, outputSource({
|
|
663
|
+
output_index: outputIndex,
|
|
664
|
+
...(responseId === undefined ? {} : { response_id: responseId }),
|
|
665
|
+
...(call.data.id === undefined ? {} : { item_id: call.data.id }),
|
|
666
|
+
}));
|
|
460
667
|
}
|
|
461
668
|
}
|
|
462
669
|
// A duplicate or stale terminal update can refine diagnostics and carry omitted
|
|
@@ -469,6 +676,7 @@ export class RealtimeVoiceSession {
|
|
|
469
676
|
return;
|
|
470
677
|
this.#responseActive = false;
|
|
471
678
|
this.#activeResponseId = undefined;
|
|
679
|
+
this.#pendingResponseEventId = undefined;
|
|
472
680
|
// Generation often ends well before the client finishes playing its queued PCM.
|
|
473
681
|
for (const stream of this.#outputStreams.values())
|
|
474
682
|
stream.complete = true;
|
|
@@ -496,25 +704,44 @@ export class RealtimeVoiceSession {
|
|
|
496
704
|
: { cachedAudioInputTokens: input.cached_tokens_details.audio_tokens }),
|
|
497
705
|
});
|
|
498
706
|
}
|
|
499
|
-
#
|
|
500
|
-
if (this.#deliveredToolCalls.has(callId))
|
|
707
|
+
#rejectToolCall(callId) {
|
|
708
|
+
if (!this.#deliveredToolCalls.has(callId))
|
|
709
|
+
this.#rejectedToolCalls.add(callId);
|
|
710
|
+
}
|
|
711
|
+
#emitFunctionCall(callId, name, rawArguments, source) {
|
|
712
|
+
if (this.#closed || this.#deliveredToolCalls.has(callId) || this.#rejectedToolCalls.has(callId))
|
|
713
|
+
return;
|
|
714
|
+
if (source?.responseId !== undefined &&
|
|
715
|
+
(this.#completedResponses.get(source.responseId) === false ||
|
|
716
|
+
this.#interruptedResponseIds.has(source.responseId)))
|
|
501
717
|
return;
|
|
502
718
|
let parsedArguments;
|
|
503
719
|
try {
|
|
504
720
|
parsedArguments = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
|
|
721
|
+
if (parsedArguments === null || typeof parsedArguments !== "object" || Array.isArray(parsedArguments))
|
|
722
|
+
throw new Error("Function arguments must be a valid JSON object");
|
|
505
723
|
}
|
|
506
724
|
catch {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
this.#emitError(`Tool call ${name}
|
|
725
|
+
// This is confirmed native intent, even though it cannot be admitted to the
|
|
726
|
+
// harness. Settle it once so the model can recover instead of waiting forever.
|
|
727
|
+
this.#rejectToolCall(callId);
|
|
728
|
+
const error = "Function arguments must be a valid JSON object";
|
|
729
|
+
this.#emitError(`Tool call ${name} (${callId}) was not executed: ${error}`, true);
|
|
730
|
+
// Finish the incoming response's bookkeeping before scheduling its recovery.
|
|
731
|
+
void Promise.resolve()
|
|
732
|
+
.then(() => this.sendToolResult({ callId, outcome: { type: "failed", error } }))
|
|
733
|
+
.catch((error) => this.#emitError(`Could not return invalid tool arguments: ${String(error)}`, true));
|
|
512
734
|
return;
|
|
513
735
|
}
|
|
514
736
|
this.#deliveredToolCalls.add(callId);
|
|
515
737
|
this.#emit({
|
|
516
738
|
type: "tool.call",
|
|
517
|
-
call: ToolCallSchema.parse({
|
|
739
|
+
call: ToolCallSchema.parse({
|
|
740
|
+
callId,
|
|
741
|
+
name,
|
|
742
|
+
arguments: parsedArguments,
|
|
743
|
+
...(source === undefined ? {} : { source }),
|
|
744
|
+
}),
|
|
518
745
|
});
|
|
519
746
|
}
|
|
520
747
|
#handleSpeechStarted(parsed) {
|
|
@@ -525,6 +752,8 @@ export class RealtimeVoiceSession {
|
|
|
525
752
|
this.#reportResponseState();
|
|
526
753
|
if (this.#wire.interruptOnSpeech === false)
|
|
527
754
|
return;
|
|
755
|
+
if (this.#activeResponseId !== undefined)
|
|
756
|
+
this.#interruptedResponseIds.add(this.#activeResponseId);
|
|
528
757
|
// A response can contain a preamble and an answer, both ahead of playback.
|
|
529
758
|
// Discard queued items first so stopping the audible item cannot start the next.
|
|
530
759
|
for (const [streamId, stream] of [...this.#outputStreams].reverse()) {
|
|
@@ -534,14 +763,24 @@ export class RealtimeVoiceSession {
|
|
|
534
763
|
if (stream.complete && playedThroughSample >= stream.startSample)
|
|
535
764
|
continue;
|
|
536
765
|
this.#interruptedStreams.add(streamId);
|
|
766
|
+
if (stream.responseId !== undefined)
|
|
767
|
+
this.#interruptedResponseIds.add(stream.responseId);
|
|
537
768
|
this.#emit({ type: "audio.interrupted", streamId });
|
|
538
|
-
//
|
|
539
|
-
|
|
769
|
+
// Legacy clients never acknowledge playback. Preserve their conversation
|
|
770
|
+
// context instead of mistaking unknown playback for zero heard samples.
|
|
771
|
+
// Once a client reports playback, other queued streams can start at zero.
|
|
772
|
+
if (stream.itemId === undefined || this.#playedThrough.size === 0)
|
|
540
773
|
continue;
|
|
774
|
+
if (stream.contentIndex !== 0) {
|
|
775
|
+
// The public truncate contract currently permits only content_index 0.
|
|
776
|
+
// Discard locally without truncating a different content part on the server.
|
|
777
|
+
this.#emitError(`Interrupted audio content ${stream.contentIndex} locally; native truncation only supports content index 0`, true);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
541
780
|
this.#trySend({
|
|
542
781
|
type: "conversation.item.truncate",
|
|
543
782
|
item_id: stream.itemId,
|
|
544
|
-
content_index:
|
|
783
|
+
content_index: stream.contentIndex,
|
|
545
784
|
audio_end_ms: Math.floor((Math.min(playedThroughSample, stream.startSample) / this.#outputSampleRate) * 1000),
|
|
546
785
|
});
|
|
547
786
|
}
|
|
@@ -595,8 +834,22 @@ export class RealtimeVoiceSession {
|
|
|
595
834
|
const error = ServerErrorSchema.safeParse(parsed);
|
|
596
835
|
const message = error.success ? error.data.error?.message : undefined;
|
|
597
836
|
this.#emitError(message ?? `${this.#wire.label} reported an error`, true);
|
|
598
|
-
|
|
599
|
-
|
|
837
|
+
const eventId = error.success ? error.data.error?.event_id : undefined;
|
|
838
|
+
const callId = eventId == null ? undefined : this.#toolResultEvents.get(eventId);
|
|
839
|
+
if (callId !== undefined && eventId != null) {
|
|
840
|
+
this.#toolResultEvents.delete(eventId);
|
|
841
|
+
this.#emit({
|
|
842
|
+
type: "tool.delivery_failed",
|
|
843
|
+
callId,
|
|
844
|
+
error: message ?? `${this.#wire.label} rejected the function result`,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
if (eventId != null && eventId === this.#pendingResponseEventId) {
|
|
848
|
+
// A rejected create has no response.done to release the scheduler. Only its
|
|
849
|
+
// correlated error may clear this latch; accepted responses still own it.
|
|
850
|
+
this.#pendingResponseEventId = undefined;
|
|
851
|
+
this.#responseActive = false;
|
|
852
|
+
// Do not retry the rejected request; only resume a separately queued turn.
|
|
600
853
|
this.#resumeQueuedResponse();
|
|
601
854
|
}
|
|
602
855
|
}
|
|
@@ -619,14 +872,21 @@ export class RealtimeVoiceSession {
|
|
|
619
872
|
}
|
|
620
873
|
this.#responseWanted = false;
|
|
621
874
|
this.#responseActive = true;
|
|
875
|
+
this.#pendingResponseEventId = this.#wire.correlateClientErrors
|
|
876
|
+
? `xo-realtime-response-${++this.#clientEventSequence}`
|
|
877
|
+
: undefined;
|
|
622
878
|
this.#reportResponseState();
|
|
623
879
|
this.#trySend({
|
|
624
880
|
type: "response.create",
|
|
881
|
+
...(this.#pendingResponseEventId === undefined ? {} : { event_id: this.#pendingResponseEventId }),
|
|
625
882
|
...(instructions === undefined ? {} : { response: { instructions } }),
|
|
626
883
|
});
|
|
627
884
|
}
|
|
628
885
|
#armVadResponseRecovery() {
|
|
629
886
|
this.#clearVadResponseRecoveryTimer();
|
|
887
|
+
// The preceding speech diagnostic can synchronously fail the ingress queue.
|
|
888
|
+
if (this.#closed)
|
|
889
|
+
return;
|
|
630
890
|
this.#vadResponseRecoveryTimer = setTimeout(() => {
|
|
631
891
|
this.#vadResponseRecoveryTimer = undefined;
|
|
632
892
|
if (this.#closed || !this.#awaitingVadResponse)
|
|
@@ -663,6 +923,7 @@ export class RealtimeVoiceSession {
|
|
|
663
923
|
this.#emit({ type: "response.state", state });
|
|
664
924
|
}
|
|
665
925
|
#send(payload) {
|
|
926
|
+
this.failureSignal.throwIfAborted();
|
|
666
927
|
if (this.#socket.state !== "open") {
|
|
667
928
|
throw new Error(`${this.#wire.label} session socket is not open`);
|
|
668
929
|
}
|
|
@@ -670,7 +931,7 @@ export class RealtimeVoiceSession {
|
|
|
670
931
|
}
|
|
671
932
|
/** For sends triggered by incoming events: a closing socket is settlement, not an error. */
|
|
672
933
|
#trySend(payload) {
|
|
673
|
-
if (this.#socket.state !== "open")
|
|
934
|
+
if (this.#closed || this.failureSignal.aborted || this.#socket.state !== "open")
|
|
674
935
|
return;
|
|
675
936
|
this.#send(payload);
|
|
676
937
|
}
|
|
@@ -683,9 +944,12 @@ export class RealtimeVoiceSession {
|
|
|
683
944
|
this.#emit({ type: "error", message, recoverable });
|
|
684
945
|
}
|
|
685
946
|
#settle(reason) {
|
|
686
|
-
if (this.#closed)
|
|
947
|
+
if (this.#closed || this.#settling)
|
|
687
948
|
return;
|
|
949
|
+
this.#settling = true;
|
|
950
|
+
this.#signal.removeEventListener("abort", this.#onAbort);
|
|
688
951
|
this.#clearVadResponseRecoveryTimer();
|
|
952
|
+
this.#toolResultEvents.clear();
|
|
689
953
|
this.#flushUserTranscripts();
|
|
690
954
|
this.#closed = true;
|
|
691
955
|
this.#queue.push({ type: "closed", reason });
|
|
@@ -693,6 +957,33 @@ export class RealtimeVoiceSession {
|
|
|
693
957
|
this.#resolveSettled();
|
|
694
958
|
}
|
|
695
959
|
}
|
|
960
|
+
/** Native coordinates are evidence only; never fill them from the active response or a counter. */
|
|
961
|
+
function outputSource(coordinates) {
|
|
962
|
+
const { response_id, item_id, output_index, content_index } = coordinates;
|
|
963
|
+
if (response_id === undefined &&
|
|
964
|
+
item_id === undefined &&
|
|
965
|
+
output_index === undefined &&
|
|
966
|
+
content_index === undefined)
|
|
967
|
+
return undefined;
|
|
968
|
+
return {
|
|
969
|
+
scope: "voice",
|
|
970
|
+
...(response_id === undefined ? {} : { responseId: response_id }),
|
|
971
|
+
...(item_id === undefined ? {} : { itemId: item_id }),
|
|
972
|
+
...(output_index === undefined ? {} : { outputIndex: output_index }),
|
|
973
|
+
...(content_index === undefined ? {} : { contentIndex: content_index }),
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
function transcriptStreamId(coordinates) {
|
|
977
|
+
const base = coordinates.item_id ?? coordinates.response_id;
|
|
978
|
+
return base === undefined ? undefined : contentStreamId(base, coordinates.content_index);
|
|
979
|
+
}
|
|
980
|
+
/** Preserve legacy zero-index IDs; escape the namespace to keep native IDs collision-free. */
|
|
981
|
+
function contentStreamId(base, contentIndex = 0) {
|
|
982
|
+
const prefix = "xo-content:";
|
|
983
|
+
return contentIndex === 0 && !base.startsWith(prefix)
|
|
984
|
+
? base
|
|
985
|
+
: `${prefix}${JSON.stringify([base, contentIndex])}`;
|
|
986
|
+
}
|
|
696
987
|
export function toRealtimeFunctionTool(definition) {
|
|
697
988
|
return {
|
|
698
989
|
type: "function",
|
|
@@ -717,6 +1008,8 @@ function toolOutcomePayload(outcome) {
|
|
|
717
1008
|
}
|
|
718
1009
|
function contextText(event) {
|
|
719
1010
|
switch (event.type) {
|
|
1011
|
+
case "tool.status":
|
|
1012
|
+
return toolStatusContext(event);
|
|
720
1013
|
case "task.progress":
|
|
721
1014
|
return `Background task ${event.taskId} progress: ${JSON.stringify(event.update)}`;
|
|
722
1015
|
case "task.completed":
|
|
@@ -727,6 +1020,16 @@ function contextText(event) {
|
|
|
727
1020
|
return `Background task ${event.taskId} was cancelled: ${event.reason}`;
|
|
728
1021
|
}
|
|
729
1022
|
}
|
|
1023
|
+
function taskResultPayload(event) {
|
|
1024
|
+
switch (event.type) {
|
|
1025
|
+
case "task.completed":
|
|
1026
|
+
return JSON.stringify(event.result);
|
|
1027
|
+
case "task.failed":
|
|
1028
|
+
return JSON.stringify({ status: "failed", error: event.error });
|
|
1029
|
+
case "task.cancelled":
|
|
1030
|
+
return JSON.stringify({ status: "cancelled", reason: event.reason });
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
730
1033
|
function toBase64(data) {
|
|
731
1034
|
let binary = "";
|
|
732
1035
|
// Bound each argument list; microphone buffers need not fit the engine's call stack.
|