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