xo-harness 0.2.1 → 0.3.1
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 +109 -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 +216 -49
- package/dist/internal/harness/task-supervisor.d.ts +6 -2
- package/dist/internal/harness/task-supervisor.js +97 -17
- 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 +38 -43
- 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 +354 -61
- 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 +21 -2
- package/dist/internal/protocol/audio.js +56 -5
- package/dist/internal/protocol/backend-output.d.ts +70 -0
- package/dist/internal/protocol/backend-output.js +39 -0
- package/dist/internal/protocol/error-message.d.ts +6 -0
- package/dist/internal/protocol/error-message.js +8 -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/internal.d.ts +5 -0
- package/dist/internal/protocol/internal.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 +838 -0
- package/dist/internal/provider/node-socket.js +7 -3
- package/dist/internal/provider/openai-live.d.ts +62 -0
- package/dist/internal/provider/openai-live.js +161 -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 +369 -68
- 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,9 +1,10 @@
|
|
|
1
|
-
import { MessageInputTextSchema, OutputModalitySchema, } from "../protocol/index.js";
|
|
1
|
+
import { MessageInputTextSchema, OutputModalitySchema, PlayoutRangeSchema, } from "../protocol/index.js";
|
|
2
|
+
import { errorMessage } from "../protocol/internal.js";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
/**
|
|
4
5
|
* The client wire protocol every XO transport speaks:
|
|
5
6
|
* - text frames are JSON control messages (start / playout / stop up; started / event /
|
|
6
|
-
* error / closed down),
|
|
7
|
+
* interrupt / error / closed down),
|
|
7
8
|
* - binary frames are PCM16 mono audio (microphone up; length-prefixed model audio down:
|
|
8
9
|
* u32le header length, JSON header, PCM bytes).
|
|
9
10
|
*
|
|
@@ -33,6 +34,7 @@ export const VoiceClientMessageSchema = z.discriminatedUnion("type", [
|
|
|
33
34
|
streamId: z.string().min(1),
|
|
34
35
|
playedThroughSample: z.number().int().nonnegative(),
|
|
35
36
|
}),
|
|
37
|
+
PlayoutRangeSchema.safeExtend({ type: z.literal("playout.range") }),
|
|
36
38
|
/** Graceful end; the server still delivers the trailing events and a closed frame. */
|
|
37
39
|
z.object({ type: z.literal("stop"), reason: z.string().min(1).optional() }),
|
|
38
40
|
]);
|
|
@@ -40,15 +42,35 @@ export const VOICE_BRIDGE_INPUT_SAMPLE_RATE = 24_000;
|
|
|
40
42
|
export class VoiceSocketBridge {
|
|
41
43
|
#socket;
|
|
42
44
|
#startSession;
|
|
45
|
+
#onStarted;
|
|
46
|
+
#shouldSendEvent;
|
|
47
|
+
#onError;
|
|
48
|
+
#startupCloseTimeoutMs;
|
|
43
49
|
#inputSampleRate;
|
|
50
|
+
#workController = new AbortController();
|
|
51
|
+
#startupController;
|
|
52
|
+
#startup;
|
|
53
|
+
#eventPump;
|
|
54
|
+
#teardownPromise;
|
|
55
|
+
#closeReason;
|
|
56
|
+
#socketFailed = false;
|
|
44
57
|
#session;
|
|
45
58
|
#sequence = 0;
|
|
46
59
|
#startSample = 0;
|
|
47
60
|
#closing = false;
|
|
48
61
|
#sessionEnded = false;
|
|
62
|
+
#starting = false;
|
|
63
|
+
#ready = false;
|
|
49
64
|
constructor(options) {
|
|
65
|
+
const timeout = options.startupCloseTimeoutMs ?? 5_000;
|
|
66
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 2_147_483_647)
|
|
67
|
+
throw new Error("startupCloseTimeoutMs must be a positive integer no greater than 2147483647 ms");
|
|
50
68
|
this.#socket = options.socket;
|
|
51
69
|
this.#startSession = options.startSession;
|
|
70
|
+
this.#onStarted = options.onStarted;
|
|
71
|
+
this.#shouldSendEvent = options.shouldSendEvent;
|
|
72
|
+
this.#onError = options.onError;
|
|
73
|
+
this.#startupCloseTimeoutMs = timeout;
|
|
52
74
|
this.#inputSampleRate = options.inputSampleRate ?? VOICE_BRIDGE_INPUT_SAMPLE_RATE;
|
|
53
75
|
}
|
|
54
76
|
/** Feed every text frame from the client socket here. */
|
|
@@ -57,7 +79,7 @@ export class VoiceSocketBridge {
|
|
|
57
79
|
await this.#handleControl(VoiceClientMessageSchema.parse(JSON.parse(text)));
|
|
58
80
|
}
|
|
59
81
|
catch (error) {
|
|
60
|
-
this.#
|
|
82
|
+
this.#reportError(error);
|
|
61
83
|
}
|
|
62
84
|
}
|
|
63
85
|
/** Feed every binary frame from the client socket here. */
|
|
@@ -66,36 +88,82 @@ export class VoiceSocketBridge {
|
|
|
66
88
|
await this.#pushAudio(data);
|
|
67
89
|
}
|
|
68
90
|
catch (error) {
|
|
69
|
-
this.#
|
|
91
|
+
this.#reportError(error);
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Closes an attached session gracefully, or cancels pending creation and waits up to
|
|
96
|
+
* startupCloseTimeoutMs. Repeated calls share one result. Unconfirmed startup is still
|
|
97
|
+
* cleaned up if it returns later; this result never claims provider finalization.
|
|
98
|
+
*/
|
|
99
|
+
handleClose(reason = "socket_closed") {
|
|
100
|
+
return this.#teardown(reason);
|
|
75
101
|
}
|
|
76
102
|
async #handleControl(message) {
|
|
77
103
|
switch (message.type) {
|
|
78
104
|
case "start": {
|
|
79
|
-
if (this.#session)
|
|
105
|
+
if (this.#session || this.#starting)
|
|
80
106
|
throw new Error("A session is already active on this connection");
|
|
107
|
+
if (this.#closing || this.#sessionEnded)
|
|
108
|
+
throw new Error("This voice connection has ended");
|
|
109
|
+
this.#starting = true;
|
|
81
110
|
const { type: _type, ...start } = message;
|
|
82
|
-
const
|
|
83
|
-
this.#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
this.#startupController = controller;
|
|
113
|
+
// Assign the tracked promise before invoking host code, which can close reentrantly.
|
|
114
|
+
const creation = Promise.resolve()
|
|
115
|
+
.then(async () => {
|
|
116
|
+
controller.signal.throwIfAborted();
|
|
117
|
+
const session = await this.#startSession(start, { startupSignal: controller.signal });
|
|
118
|
+
// Ownership transfers at factory return, before readiness. Ordinary close must
|
|
119
|
+
// never abort this persistent owner signal after the session has been attached.
|
|
120
|
+
this.#startupController = undefined;
|
|
121
|
+
if (this.#closing) {
|
|
122
|
+
await session
|
|
123
|
+
.close(this.#closeReason ?? "client_disconnected_during_startup")
|
|
124
|
+
.catch((error) => this.#reportError(error));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.#session = session;
|
|
128
|
+
this.#eventPump = this.#pumpEvents(session, message.provider);
|
|
129
|
+
}
|
|
130
|
+
return session;
|
|
131
|
+
})
|
|
132
|
+
.finally(() => {
|
|
133
|
+
this.#startupController = undefined;
|
|
134
|
+
});
|
|
135
|
+
this.#startup = creation.then(() => undefined, () => undefined);
|
|
136
|
+
try {
|
|
137
|
+
const session = await creation;
|
|
138
|
+
if (this.#closing)
|
|
139
|
+
break;
|
|
140
|
+
await session.waitUntilReady();
|
|
141
|
+
if (this.#closing || this.#sessionEnded)
|
|
142
|
+
break;
|
|
143
|
+
if (!this.#announceStarted(session, message.provider))
|
|
144
|
+
break;
|
|
145
|
+
if (!this.#closing && !this.#sessionEnded)
|
|
146
|
+
void this.#runOnStarted(session);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (!this.#closing) {
|
|
150
|
+
void this.#teardown("startup_failed");
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
this.#starting = false;
|
|
156
|
+
}
|
|
89
157
|
break;
|
|
90
158
|
}
|
|
91
159
|
case "text": {
|
|
92
|
-
if (this.#sessionEnded)
|
|
93
|
-
|
|
94
|
-
await this.#session
|
|
160
|
+
if (!this.#ready || !this.#session || this.#sessionEnded || this.#closing)
|
|
161
|
+
throw new Error("Typed input requires an active, started voice session");
|
|
162
|
+
await this.#session.sendText(message.text);
|
|
95
163
|
break;
|
|
96
164
|
}
|
|
97
165
|
case "playout": {
|
|
98
|
-
if (this.#sessionEnded)
|
|
166
|
+
if (this.#sessionEnded || this.#closing)
|
|
99
167
|
break;
|
|
100
168
|
await this.#session?.reportPlayout({
|
|
101
169
|
streamId: message.streamId,
|
|
@@ -103,6 +171,13 @@ export class VoiceSocketBridge {
|
|
|
103
171
|
});
|
|
104
172
|
break;
|
|
105
173
|
}
|
|
174
|
+
case "playout.range": {
|
|
175
|
+
if (this.#sessionEnded || this.#closing)
|
|
176
|
+
break;
|
|
177
|
+
const { type: _type, ...range } = message;
|
|
178
|
+
await this.#session?.reportPlayoutRange(range);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
106
181
|
case "stop": {
|
|
107
182
|
await this.#teardown(message.reason ?? "client_stopped");
|
|
108
183
|
break;
|
|
@@ -112,11 +187,13 @@ export class VoiceSocketBridge {
|
|
|
112
187
|
async #pushAudio(data) {
|
|
113
188
|
const session = this.#session;
|
|
114
189
|
// In-flight microphone frames after settlement are expected, not an error.
|
|
115
|
-
if (!session || this.#closing || this.#sessionEnded)
|
|
190
|
+
if (!session || !this.#ready || this.#closing || this.#sessionEnded)
|
|
116
191
|
return;
|
|
117
|
-
const byteLength = data.byteLength
|
|
192
|
+
const byteLength = data.byteLength;
|
|
118
193
|
if (byteLength === 0)
|
|
119
194
|
return;
|
|
195
|
+
if (byteLength % 2 !== 0)
|
|
196
|
+
throw new Error("Microphone PCM16 frames must contain complete 2-byte samples");
|
|
120
197
|
const chunk = {
|
|
121
198
|
streamId: "microphone",
|
|
122
199
|
sequence: this.#sequence,
|
|
@@ -124,65 +201,163 @@ export class VoiceSocketBridge {
|
|
|
124
201
|
sampleRate: this.#inputSampleRate,
|
|
125
202
|
channels: 1,
|
|
126
203
|
startSample: this.#startSample,
|
|
127
|
-
data:
|
|
204
|
+
data: Uint8Array.from(data),
|
|
128
205
|
};
|
|
129
206
|
this.#sequence += 1;
|
|
130
207
|
this.#startSample += byteLength / 2;
|
|
131
208
|
await session.sendAudio(chunk);
|
|
132
209
|
}
|
|
133
|
-
async #pumpEvents(session) {
|
|
210
|
+
async #pumpEvents(session, provider) {
|
|
134
211
|
try {
|
|
135
212
|
for await (const event of session.events()) {
|
|
213
|
+
let visible = true;
|
|
214
|
+
try {
|
|
215
|
+
if (this.#shouldSendEvent) {
|
|
216
|
+
visible = this.#shouldSendEvent(event);
|
|
217
|
+
if (typeof visible !== "boolean") {
|
|
218
|
+
// Observe an accidentally async callback without accepting its eventual decision.
|
|
219
|
+
void Promise.resolve(visible).catch(() => undefined);
|
|
220
|
+
throw new TypeError("shouldSendEvent must return a boolean");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (cause) {
|
|
225
|
+
throw new Error("Client event filter failed", { cause });
|
|
226
|
+
}
|
|
227
|
+
// Keep consuming startup events without buffering, while presenting readiness
|
|
228
|
+
// only after the control frame that enables the client's media/input path.
|
|
229
|
+
if (event.type === "provider.ready")
|
|
230
|
+
this.#announceStarted(session, provider);
|
|
136
231
|
if (event.type === "audio.output") {
|
|
137
232
|
this.#sendBinary(encodeVoiceAudioFrame(event.chunk));
|
|
138
233
|
}
|
|
139
|
-
|
|
234
|
+
// Playback must still stop when the application's journal visibility policy
|
|
235
|
+
// hides this event. The control frame carries no private source metadata.
|
|
236
|
+
if (event.type === "audio.interrupted" && !visible)
|
|
237
|
+
this.#sendJson({ type: "interrupt", streamId: event.streamId });
|
|
238
|
+
if (visible)
|
|
239
|
+
this.#sendJson({ type: "event", event: summarizeVoiceEvent(event) });
|
|
140
240
|
if (event.type === "session.ended") {
|
|
141
|
-
this.#
|
|
142
|
-
this.#sendJson({ type: "closed", reason: event.reason });
|
|
241
|
+
this.#finishClient(event.reason);
|
|
143
242
|
}
|
|
144
243
|
}
|
|
145
244
|
}
|
|
146
245
|
catch (error) {
|
|
147
|
-
this.#
|
|
246
|
+
this.#reportError(error);
|
|
247
|
+
void this.#teardown("session_stream_failed");
|
|
148
248
|
}
|
|
149
249
|
finally {
|
|
150
250
|
// However the pump exits, the connection must settle: no more media forwarding,
|
|
151
251
|
// and the client always receives a closed frame exactly once.
|
|
152
|
-
|
|
153
|
-
this.#sessionEnded = true;
|
|
154
|
-
this.#sendJson({ type: "closed", reason: "session_stream_ended" });
|
|
155
|
-
}
|
|
252
|
+
this.#finishClient("session_stream_ended");
|
|
156
253
|
}
|
|
157
254
|
}
|
|
158
|
-
|
|
159
|
-
if (this.#closing)
|
|
160
|
-
return;
|
|
255
|
+
#announceStarted(session, provider) {
|
|
256
|
+
if (this.#closing || this.#sessionEnded)
|
|
257
|
+
return false;
|
|
258
|
+
if (this.#ready)
|
|
259
|
+
return true;
|
|
260
|
+
this.#ready = true;
|
|
261
|
+
return this.#sendJson({
|
|
262
|
+
type: "started",
|
|
263
|
+
sessionId: session.id,
|
|
264
|
+
provider,
|
|
265
|
+
inputCadence: session.capabilities.inputCadence ?? "on-demand",
|
|
266
|
+
outputLifecycle: session.capabilities.outputLifecycle ?? "response",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
#teardown(reason) {
|
|
270
|
+
if (this.#teardownPromise)
|
|
271
|
+
return this.#teardownPromise;
|
|
272
|
+
const settled = Promise.withResolvers();
|
|
273
|
+
this.#teardownPromise = settled.promise;
|
|
161
274
|
this.#closing = true;
|
|
275
|
+
this.#closeReason = reason;
|
|
276
|
+
this.#ready = false;
|
|
277
|
+
this.#workController.abort(reason);
|
|
278
|
+
this.#startupController?.abort(reason);
|
|
279
|
+
void this.#performTeardown(reason).then(settled.resolve, settled.reject);
|
|
280
|
+
return settled.promise;
|
|
281
|
+
}
|
|
282
|
+
async #performTeardown(reason) {
|
|
162
283
|
const session = this.#session;
|
|
163
284
|
this.#session = undefined;
|
|
285
|
+
if (session) {
|
|
286
|
+
await session.close(reason).catch((error) => this.#reportError(error));
|
|
287
|
+
await this.#eventPump;
|
|
288
|
+
return { startup: "settled" };
|
|
289
|
+
}
|
|
290
|
+
let deadline;
|
|
164
291
|
try {
|
|
165
|
-
|
|
166
|
-
await
|
|
292
|
+
const result = this.#startup
|
|
293
|
+
? await Promise.race([
|
|
294
|
+
this.#startup.then(() => ({ startup: "settled" })),
|
|
295
|
+
new Promise((resolve) => {
|
|
296
|
+
deadline = setTimeout(() => resolve({ startup: "unconfirmed" }), this.#startupCloseTimeoutMs);
|
|
297
|
+
}),
|
|
298
|
+
])
|
|
299
|
+
: { startup: "settled" };
|
|
300
|
+
this.#finishClient(reason);
|
|
301
|
+
return result;
|
|
167
302
|
}
|
|
168
303
|
finally {
|
|
169
|
-
|
|
304
|
+
clearTimeout(deadline);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async #runOnStarted(session) {
|
|
308
|
+
try {
|
|
309
|
+
await this.#onStarted?.(session, { signal: this.#workController.signal });
|
|
170
310
|
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (!this.#workController.signal.aborted)
|
|
313
|
+
this.#reportError(error);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
#finishClient(reason) {
|
|
317
|
+
if (this.#sessionEnded)
|
|
318
|
+
return;
|
|
319
|
+
this.#sessionEnded = true;
|
|
320
|
+
this.#ready = false;
|
|
321
|
+
this.#workController.abort(reason);
|
|
322
|
+
this.#sendJson({ type: "closed", reason });
|
|
323
|
+
}
|
|
324
|
+
#reportError(error) {
|
|
325
|
+
this.#notifyError(error);
|
|
326
|
+
this.#sendJson({ type: "error", message: errorMessage(error) });
|
|
327
|
+
}
|
|
328
|
+
#notifyError(error) {
|
|
329
|
+
try {
|
|
330
|
+
this.#onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// An observation callback must not interrupt cleanup or recursively report itself.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
#socketFailure(error) {
|
|
337
|
+
this.#socketFailed = true;
|
|
338
|
+
void this.#teardown("socket_send_failed");
|
|
339
|
+
this.#notifyError(error);
|
|
171
340
|
}
|
|
172
341
|
#sendJson(payload) {
|
|
342
|
+
if (this.#socketFailed)
|
|
343
|
+
return false;
|
|
173
344
|
try {
|
|
174
345
|
this.#socket.sendText(JSON.stringify(payload));
|
|
346
|
+
return true;
|
|
175
347
|
}
|
|
176
|
-
catch {
|
|
177
|
-
|
|
348
|
+
catch (error) {
|
|
349
|
+
this.#socketFailure(error);
|
|
350
|
+
return false;
|
|
178
351
|
}
|
|
179
352
|
}
|
|
180
353
|
#sendBinary(frame) {
|
|
354
|
+
if (this.#socketFailed)
|
|
355
|
+
return;
|
|
181
356
|
try {
|
|
182
357
|
this.#socket.sendBinary(frame);
|
|
183
358
|
}
|
|
184
|
-
catch {
|
|
185
|
-
|
|
359
|
+
catch (error) {
|
|
360
|
+
this.#socketFailure(error);
|
|
186
361
|
}
|
|
187
362
|
}
|
|
188
363
|
}
|
|
@@ -217,11 +392,3 @@ export function summarizeVoiceEvent(event) {
|
|
|
217
392
|
}
|
|
218
393
|
return { ...event };
|
|
219
394
|
}
|
|
220
|
-
function copyBytes(data, byteLength) {
|
|
221
|
-
const copy = new Uint8Array(byteLength);
|
|
222
|
-
copy.set(data.subarray(0, byteLength));
|
|
223
|
-
return copy;
|
|
224
|
-
}
|
|
225
|
-
function errorMessage(error) {
|
|
226
|
-
return error instanceof Error ? error.message : String(error);
|
|
227
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HarnessEvent, NewHarnessEvent, ProviderContextEvent } from "../protocol/index.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type BackgroundTaskLauncher, type TaskCancellationRequestResult } from "./tools.js";
|
|
3
3
|
type RecordEvent = (event: NewHarnessEvent) => Promise<HarnessEvent>;
|
|
4
4
|
type SendContext = (event: ProviderContextEvent) => Promise<void>;
|
|
5
5
|
type Flush = () => Promise<void>;
|
|
@@ -14,7 +14,11 @@ export declare class TaskSupervisor {
|
|
|
14
14
|
});
|
|
15
15
|
forCall(callId: string): BackgroundTaskLauncher;
|
|
16
16
|
activate(taskId: string, callId: string): Promise<boolean>;
|
|
17
|
-
|
|
17
|
+
/** Records and signals one cancellation request without waiting for a running task to settle. */
|
|
18
|
+
requestCancel(taskId: string, reason?: string): Promise<TaskCancellationRequestResult>;
|
|
19
|
+
cancelPending(taskId: string, callId: string, reason: string, deliverContext?: boolean): Promise<boolean>;
|
|
20
|
+
/** A finished tool only transfers ownership of the task ID in its accepted outcome. */
|
|
21
|
+
cancelUnaccepted(callId: string, acceptedTaskId?: string): Promise<void>;
|
|
18
22
|
cancelAll(reason: string): Promise<void>;
|
|
19
23
|
}
|
|
20
24
|
export {};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { JsonValueSchema } from "../protocol/index.js";
|
|
2
|
+
import { errorMessage } from "../protocol/internal.js";
|
|
3
|
+
import { BackgroundTaskOutcomeSchema, } from "./tools.js";
|
|
2
4
|
export class TaskSupervisor {
|
|
3
5
|
#tasks = new Map();
|
|
4
|
-
#terminalTasks = new
|
|
6
|
+
#terminalTasks = new Map();
|
|
5
7
|
#record;
|
|
6
8
|
#sendContext;
|
|
7
9
|
#flush;
|
|
@@ -16,7 +18,8 @@ export class TaskSupervisor {
|
|
|
16
18
|
}
|
|
17
19
|
forCall(callId) {
|
|
18
20
|
return {
|
|
19
|
-
start: (runner, options) => this.#register(callId, runner, options),
|
|
21
|
+
start: (runner, options) => this.#register(callId, runner, false, options),
|
|
22
|
+
startOutcome: (runner, options) => this.#register(callId, runner, true, options),
|
|
20
23
|
};
|
|
21
24
|
}
|
|
22
25
|
async activate(taskId, callId) {
|
|
@@ -25,14 +28,64 @@ export class TaskSupervisor {
|
|
|
25
28
|
await this.#settle({ type: "task.failed", taskId, callId, error: "activation_failed" }, true);
|
|
26
29
|
return false;
|
|
27
30
|
}
|
|
28
|
-
if (task.
|
|
31
|
+
if (task.cancellationRequest)
|
|
32
|
+
await task.cancellationRequest;
|
|
33
|
+
if (this.#tasks.get(taskId) !== task || task.state === "running")
|
|
34
|
+
return false;
|
|
35
|
+
if (task.controller.signal.aborted) {
|
|
36
|
+
await this.cancelPending(taskId, callId, cancellationReason(task.controller.signal), true);
|
|
29
37
|
return false;
|
|
38
|
+
}
|
|
30
39
|
task.state = "running";
|
|
31
40
|
task.promise = this.#run(taskId, task);
|
|
32
41
|
void task.promise.catch(this.#onError);
|
|
33
42
|
return true;
|
|
34
43
|
}
|
|
35
|
-
|
|
44
|
+
/** Records and signals one cancellation request without waiting for a running task to settle. */
|
|
45
|
+
async requestCancel(taskId, reason = "cancelled") {
|
|
46
|
+
if (!reason.trim())
|
|
47
|
+
throw new Error("Task cancellation reason must not be empty");
|
|
48
|
+
const task = this.#tasks.get(taskId);
|
|
49
|
+
if (!task) {
|
|
50
|
+
for (const terminalId of this.#terminalTasks.values()) {
|
|
51
|
+
if (terminalId === taskId)
|
|
52
|
+
return { status: "settled" };
|
|
53
|
+
}
|
|
54
|
+
return { status: "not_found" };
|
|
55
|
+
}
|
|
56
|
+
if (this.#terminalTasks.has(taskKey(taskId, task.callId)))
|
|
57
|
+
return { status: "settled" };
|
|
58
|
+
if (task.cancellationRequest) {
|
|
59
|
+
await task.cancellationRequest;
|
|
60
|
+
return { status: "already_requested" };
|
|
61
|
+
}
|
|
62
|
+
if (task.controller.signal.aborted)
|
|
63
|
+
return { status: "already_requested" };
|
|
64
|
+
const request = this.#requestCancellation(taskId, task, reason);
|
|
65
|
+
task.cancellationRequest = request;
|
|
66
|
+
try {
|
|
67
|
+
await request;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
task.cancellationRequest = undefined;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return { status: "requested" };
|
|
74
|
+
}
|
|
75
|
+
async #requestCancellation(taskId, task, reason) {
|
|
76
|
+
if (task.recordedCancellationReason === undefined) {
|
|
77
|
+
await this.#record({ type: "task.cancellation_requested", taskId, callId: task.callId, reason });
|
|
78
|
+
task.recordedCancellationReason = reason;
|
|
79
|
+
}
|
|
80
|
+
// A failed flush leaves the successful append in place. Retry durability before
|
|
81
|
+
// signalling, preserving the original request rather than appending a second one.
|
|
82
|
+
await this.#flush();
|
|
83
|
+
task.controller.abort(task.recordedCancellationReason);
|
|
84
|
+
// Leave acceptance ordering intact. Activation or teardown records the pending task's terminal.
|
|
85
|
+
if (task.state === "pending")
|
|
86
|
+
this.#releasePending(task);
|
|
87
|
+
}
|
|
88
|
+
async cancelPending(taskId, callId, reason, deliverContext = false) {
|
|
36
89
|
const task = this.#tasks.get(taskId);
|
|
37
90
|
if (!task || task.callId !== callId) {
|
|
38
91
|
await this.#settle({ type: "task.failed", taskId, callId, error: "activation_failed" }, false);
|
|
@@ -44,9 +97,16 @@ export class TaskSupervisor {
|
|
|
44
97
|
task.detachSessionAbort();
|
|
45
98
|
task.controller.abort(reason);
|
|
46
99
|
this.#releasePending(task);
|
|
47
|
-
await this.#settle({ type: "task.cancelled", taskId, callId, reason },
|
|
100
|
+
await this.#settle({ type: "task.cancelled", taskId, callId, reason }, deliverContext);
|
|
48
101
|
return true;
|
|
49
102
|
}
|
|
103
|
+
/** A finished tool only transfers ownership of the task ID in its accepted outcome. */
|
|
104
|
+
async cancelUnaccepted(callId, acceptedTaskId) {
|
|
105
|
+
for (const [taskId, task] of this.#tasks) {
|
|
106
|
+
if (task.callId === callId && task.state === "pending" && taskId !== acceptedTaskId)
|
|
107
|
+
await this.cancelPending(taskId, callId, "tool_did_not_accept_task");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
50
110
|
async cancelAll(reason) {
|
|
51
111
|
const pendingCancellations = [];
|
|
52
112
|
const running = [];
|
|
@@ -67,14 +127,16 @@ export class TaskSupervisor {
|
|
|
67
127
|
await Promise.allSettled([...pendingCancellations, ...running]);
|
|
68
128
|
}
|
|
69
129
|
#releasePending(task) {
|
|
130
|
+
const release = task.onPendingCancel;
|
|
131
|
+
task.onPendingCancel = undefined;
|
|
70
132
|
try {
|
|
71
|
-
|
|
133
|
+
release?.();
|
|
72
134
|
}
|
|
73
135
|
catch (error) {
|
|
74
136
|
this.#onError(error);
|
|
75
137
|
}
|
|
76
138
|
}
|
|
77
|
-
async #register(callId, runner, options) {
|
|
139
|
+
async #register(callId, runner, explicitOutcome, options) {
|
|
78
140
|
if (this.#sessionSignal.aborted)
|
|
79
141
|
throw new Error("Session is closing");
|
|
80
142
|
const taskId = crypto.randomUUID();
|
|
@@ -85,6 +147,9 @@ export class TaskSupervisor {
|
|
|
85
147
|
callId,
|
|
86
148
|
controller,
|
|
87
149
|
runner,
|
|
150
|
+
explicitOutcome,
|
|
151
|
+
cancellationRequest: undefined,
|
|
152
|
+
recordedCancellationReason: undefined,
|
|
88
153
|
onPendingCancel: options?.onPendingCancel,
|
|
89
154
|
state: "pending",
|
|
90
155
|
detachSessionAbort: () => this.#sessionSignal.removeEventListener("abort", abortFromSession),
|
|
@@ -109,7 +174,7 @@ export class TaskSupervisor {
|
|
|
109
174
|
let terminal;
|
|
110
175
|
let infrastructureFailure;
|
|
111
176
|
try {
|
|
112
|
-
const result =
|
|
177
|
+
const result = await task.runner({
|
|
113
178
|
signal: task.controller.signal,
|
|
114
179
|
report: async (update) => {
|
|
115
180
|
try {
|
|
@@ -123,30 +188,45 @@ export class TaskSupervisor {
|
|
|
123
188
|
throw error;
|
|
124
189
|
}
|
|
125
190
|
},
|
|
126
|
-
})
|
|
191
|
+
});
|
|
127
192
|
if (infrastructureFailure)
|
|
128
193
|
throw infrastructureFailure.error;
|
|
129
|
-
if (task.
|
|
194
|
+
if (task.explicitOutcome) {
|
|
195
|
+
const outcome = BackgroundTaskOutcomeSchema.parse(result);
|
|
196
|
+
const identity = { taskId, callId: task.callId };
|
|
197
|
+
terminal =
|
|
198
|
+
outcome.type === "completed"
|
|
199
|
+
? { type: "task.completed", ...identity, result: outcome.result }
|
|
200
|
+
: outcome.type === "failed"
|
|
201
|
+
? { type: "task.failed", ...identity, error: outcome.error }
|
|
202
|
+
: { type: "task.cancelled", ...identity, reason: outcome.reason };
|
|
203
|
+
}
|
|
204
|
+
else if (task.controller.signal.aborted) {
|
|
130
205
|
terminal = {
|
|
131
206
|
type: "task.cancelled",
|
|
132
207
|
taskId,
|
|
133
208
|
callId: task.callId,
|
|
134
|
-
reason:
|
|
209
|
+
reason: cancellationReason(task.controller.signal),
|
|
135
210
|
};
|
|
136
211
|
}
|
|
137
212
|
else {
|
|
138
|
-
terminal = {
|
|
213
|
+
terminal = {
|
|
214
|
+
type: "task.completed",
|
|
215
|
+
taskId,
|
|
216
|
+
callId: task.callId,
|
|
217
|
+
result: JsonValueSchema.parse(result),
|
|
218
|
+
};
|
|
139
219
|
}
|
|
140
220
|
}
|
|
141
221
|
catch (error) {
|
|
142
222
|
if (infrastructureFailure)
|
|
143
223
|
throw infrastructureFailure.error;
|
|
144
|
-
if (task.controller.signal.aborted) {
|
|
224
|
+
if (!task.explicitOutcome && task.controller.signal.aborted) {
|
|
145
225
|
terminal = {
|
|
146
226
|
type: "task.cancelled",
|
|
147
227
|
taskId,
|
|
148
228
|
callId: task.callId,
|
|
149
|
-
reason:
|
|
229
|
+
reason: cancellationReason(task.controller.signal),
|
|
150
230
|
};
|
|
151
231
|
}
|
|
152
232
|
else {
|
|
@@ -164,7 +244,7 @@ export class TaskSupervisor {
|
|
|
164
244
|
const key = taskKey(event.taskId, event.callId);
|
|
165
245
|
if (this.#terminalTasks.has(key))
|
|
166
246
|
return false;
|
|
167
|
-
this.#terminalTasks.
|
|
247
|
+
this.#terminalTasks.set(key, event.taskId);
|
|
168
248
|
try {
|
|
169
249
|
await this.#record(event);
|
|
170
250
|
}
|
|
@@ -196,6 +276,6 @@ export class TaskSupervisor {
|
|
|
196
276
|
function taskKey(taskId, callId) {
|
|
197
277
|
return JSON.stringify([taskId, callId]);
|
|
198
278
|
}
|
|
199
|
-
function
|
|
200
|
-
return
|
|
279
|
+
function cancellationReason(signal) {
|
|
280
|
+
return String(signal.reason ?? "cancelled");
|
|
201
281
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HarnessRecord, JsonValue, ProviderOutputSource, ToolPartState } from "../protocol/index.js";
|
|
2
|
+
export interface ToolDeliveryFailure {
|
|
3
|
+
eventId: string;
|
|
4
|
+
phase: "tool" | "progress" | "terminal";
|
|
5
|
+
error: string;
|
|
6
|
+
recordedAtMs: number;
|
|
7
|
+
}
|
|
8
|
+
/** One call's recorded execution, independent of delivery or spoken announcements. */
|
|
9
|
+
export interface ToolCallProjection {
|
|
10
|
+
callId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
/** Native invocation provenance, independent of execution and delivery status. */
|
|
13
|
+
source?: ProviderOutputSource;
|
|
14
|
+
/** The same canonical state used by message tool parts. */
|
|
15
|
+
state: ToolPartState;
|
|
16
|
+
/** An accepted task is still pending even though its initial tool call settled. */
|
|
17
|
+
status: "pending" | "result_ready";
|
|
18
|
+
/** Retained after an accepted_task outcome upgrades to its eventual result. */
|
|
19
|
+
taskId?: string;
|
|
20
|
+
progress?: {
|
|
21
|
+
update: JsonValue;
|
|
22
|
+
recordedAtMs: number;
|
|
23
|
+
};
|
|
24
|
+
deliveryFailures: readonly ToolDeliveryFailure[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Incremental projection of a single session's sequence-ordered event log. This
|
|
28
|
+
* observes execution; ToolRuntime and TaskSupervisor remain its only owners.
|
|
29
|
+
* Applying an event replaces the changed entry, leaving prior snapshots stable.
|
|
30
|
+
*/
|
|
31
|
+
export declare class ToolCallTracker {
|
|
32
|
+
#private;
|
|
33
|
+
get(callId: string): ToolCallProjection | undefined;
|
|
34
|
+
list(): ToolCallProjection[];
|
|
35
|
+
hasPending(): boolean;
|
|
36
|
+
/** Returns the updated call, or undefined when the event has no effect. */
|
|
37
|
+
apply(event: HarnessRecord): ToolCallProjection | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Pure replay of the same lifecycle used by live harness consumers and messages. */
|
|
40
|
+
export declare function projectToolCalls(events: readonly HarnessRecord[]): ToolCallProjection[];
|