xo-harness 0.1.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 +32 -0
- package/dist/harness.d.ts +1 -0
- package/dist/harness.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/internal/harness/event-stream.d.ts +7 -0
- package/dist/internal/harness/event-stream.js +42 -0
- package/dist/internal/harness/index.d.ts +7 -0
- package/dist/internal/harness/index.js +7 -0
- package/dist/internal/harness/message.d.ts +26 -0
- package/dist/internal/harness/message.js +192 -0
- package/dist/internal/harness/report.d.ts +51 -0
- package/dist/internal/harness/report.js +115 -0
- package/dist/internal/harness/shadow.d.ts +28 -0
- package/dist/internal/harness/shadow.js +118 -0
- package/dist/internal/harness/socket-bridge.d.ts +65 -0
- package/dist/internal/harness/socket-bridge.js +227 -0
- package/dist/internal/harness/task-supervisor.d.ts +20 -0
- package/dist/internal/harness/task-supervisor.js +180 -0
- package/dist/internal/harness/tool-runtime.d.ts +20 -0
- package/dist/internal/harness/tool-runtime.js +93 -0
- package/dist/internal/harness/tools.d.ts +32 -0
- package/dist/internal/harness/tools.js +30 -0
- package/dist/internal/harness/voice-session.d.ts +33 -0
- package/dist/internal/harness/voice-session.js +268 -0
- package/dist/internal/harness/xo.d.ts +25 -0
- package/dist/internal/harness/xo.js +24 -0
- package/dist/internal/protocol/async-queue.d.ts +8 -0
- package/dist/internal/protocol/async-queue.js +34 -0
- package/dist/internal/protocol/audio.d.ts +12 -0
- package/dist/internal/protocol/audio.js +24 -0
- package/dist/internal/protocol/events.d.ts +350 -0
- package/dist/internal/protocol/events.js +167 -0
- package/dist/internal/protocol/index.d.ts +6 -0
- package/dist/internal/protocol/index.js +6 -0
- package/dist/internal/protocol/parts.d.ts +219 -0
- package/dist/internal/protocol/parts.js +105 -0
- package/dist/internal/protocol/provider.d.ts +118 -0
- package/dist/internal/protocol/provider.js +77 -0
- package/dist/internal/protocol/tools.d.ts +62 -0
- package/dist/internal/protocol/tools.js +47 -0
- package/dist/internal/provider/contract.d.ts +24 -0
- package/dist/internal/provider/contract.js +1 -0
- package/dist/internal/provider/grok-voice.d.ts +45 -0
- package/dist/internal/provider/grok-voice.js +77 -0
- package/dist/internal/provider/index.d.ts +6 -0
- package/dist/internal/provider/index.js +9 -0
- package/dist/internal/provider/node-socket.d.ts +3 -0
- package/dist/internal/provider/node-socket.js +74 -0
- package/dist/internal/provider/openai-realtime.d.ts +43 -0
- package/dist/internal/provider/openai-realtime.js +76 -0
- package/dist/internal/provider/realtime-session.d.ts +30 -0
- package/dist/internal/provider/realtime-session.js +461 -0
- package/dist/internal/provider/realtime-socket.d.ts +42 -0
- package/dist/internal/provider/realtime-socket.js +30 -0
- package/dist/internal/provider/workers-socket.d.ts +11 -0
- package/dist/internal/provider/workers-socket.js +108 -0
- package/dist/internal/provider/workers.d.ts +6 -0
- package/dist/internal/provider/workers.js +11 -0
- package/dist/internal/provider-fake/index.d.ts +46 -0
- package/dist/internal/provider-fake/index.js +112 -0
- package/dist/internal/provider-fake/replay-voice-provider.d.ts +47 -0
- package/dist/internal/provider-fake/replay-voice-provider.js +133 -0
- package/dist/internal/provider-fake/scripted-voice-provider.d.ts +61 -0
- package/dist/internal/provider-fake/scripted-voice-provider.js +185 -0
- package/dist/internal/storage/append-tail.d.ts +12 -0
- package/dist/internal/storage/append-tail.js +19 -0
- package/dist/internal/storage/event-store.d.ts +19 -0
- package/dist/internal/storage/event-store.js +1 -0
- package/dist/internal/storage/index.d.ts +4 -0
- package/dist/internal/storage/index.js +4 -0
- package/dist/internal/storage/jsonl-event-store.d.ts +13 -0
- package/dist/internal/storage/jsonl-event-store.js +105 -0
- package/dist/internal/storage/memory-event-store.d.ts +8 -0
- package/dist/internal/storage/memory-event-store.js +22 -0
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.js +1 -0
- package/dist/provider-workers.d.ts +1 -0
- package/dist/provider-workers.js +1 -0
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +1 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +1 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type AudioChunk, type HarnessEvent } from "../protocol/index.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { VoiceSession } from "./voice-session.js";
|
|
4
|
+
/**
|
|
5
|
+
* The client wire protocol every XO transport speaks:
|
|
6
|
+
* - text frames are JSON control messages (start / playout / stop up; started / event /
|
|
7
|
+
* error / closed down),
|
|
8
|
+
* - binary frames are PCM16 mono audio (microphone up; length-prefixed model audio down:
|
|
9
|
+
* u32le header length, JSON header, PCM bytes).
|
|
10
|
+
*
|
|
11
|
+
* VoiceSocketBridge owns everything between a client socket and a VoiceSession —
|
|
12
|
+
* validation, microphone chunk bookkeeping, the event pump, teardown — so a server
|
|
13
|
+
* (Node WebSocket, Durable Object, anything that can pass text and bytes) wires it
|
|
14
|
+
* with a few lines instead of reimplementing the protocol.
|
|
15
|
+
*/
|
|
16
|
+
export declare const VoiceClientMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
17
|
+
type: z.ZodLiteral<"start">;
|
|
18
|
+
provider: z.ZodString;
|
|
19
|
+
agent: z.ZodOptional<z.ZodString>;
|
|
20
|
+
instructions: z.ZodOptional<z.ZodString>;
|
|
21
|
+
outputModalities: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
22
|
+
audio: "audio";
|
|
23
|
+
text: "text";
|
|
24
|
+
}>>>;
|
|
25
|
+
voice: z.ZodOptional<z.ZodString>;
|
|
26
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
27
|
+
type: z.ZodLiteral<"text">;
|
|
28
|
+
text: z.ZodString;
|
|
29
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
30
|
+
type: z.ZodLiteral<"playout">;
|
|
31
|
+
streamId: z.ZodString;
|
|
32
|
+
playedThroughSample: z.ZodNumber;
|
|
33
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
34
|
+
type: z.ZodLiteral<"stop">;
|
|
35
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
36
|
+
}, z.core.$strip>], "type">;
|
|
37
|
+
export type VoiceClientMessage = z.infer<typeof VoiceClientMessageSchema>;
|
|
38
|
+
/** The start message minus its discriminant — the zod schema stays the single source of truth. */
|
|
39
|
+
export type VoiceStartRequest = Omit<Extract<VoiceClientMessage, {
|
|
40
|
+
type: "start";
|
|
41
|
+
}>, "type">;
|
|
42
|
+
export interface VoiceBridgeSocket {
|
|
43
|
+
sendText(text: string): void;
|
|
44
|
+
sendBinary(frame: ArrayBuffer): void;
|
|
45
|
+
}
|
|
46
|
+
export interface VoiceSocketBridgeOptions {
|
|
47
|
+
socket: VoiceBridgeSocket;
|
|
48
|
+
/** Owns provider resolution and XO construction; the bridge owns only the wire. */
|
|
49
|
+
startSession(start: VoiceStartRequest): Promise<VoiceSession>;
|
|
50
|
+
inputSampleRate?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare const VOICE_BRIDGE_INPUT_SAMPLE_RATE = 24000;
|
|
53
|
+
export declare class VoiceSocketBridge {
|
|
54
|
+
#private;
|
|
55
|
+
constructor(options: VoiceSocketBridgeOptions);
|
|
56
|
+
/** Feed every text frame from the client socket here. */
|
|
57
|
+
handleText(text: string): Promise<void>;
|
|
58
|
+
/** Feed every binary frame from the client socket here. */
|
|
59
|
+
handleBinary(data: Uint8Array): Promise<void>;
|
|
60
|
+
/** Call when the client socket closes; settles the session. */
|
|
61
|
+
handleClose(reason?: string): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export declare function encodeVoiceAudioFrame(chunk: AudioChunk): ArrayBuffer;
|
|
64
|
+
/** Event payload for clients: audio chunks shrink to metadata, bytes stay server-side. */
|
|
65
|
+
export declare function summarizeVoiceEvent(event: HarnessEvent): Record<string, unknown>;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { MessageInputTextSchema, OutputModalitySchema, } from "../protocol/index.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* The client wire protocol every XO transport speaks:
|
|
5
|
+
* - text frames are JSON control messages (start / playout / stop up; started / event /
|
|
6
|
+
* error / closed down),
|
|
7
|
+
* - binary frames are PCM16 mono audio (microphone up; length-prefixed model audio down:
|
|
8
|
+
* u32le header length, JSON header, PCM bytes).
|
|
9
|
+
*
|
|
10
|
+
* VoiceSocketBridge owns everything between a client socket and a VoiceSession —
|
|
11
|
+
* validation, microphone chunk bookkeeping, the event pump, teardown — so a server
|
|
12
|
+
* (Node WebSocket, Durable Object, anything that can pass text and bytes) wires it
|
|
13
|
+
* with a few lines instead of reimplementing the protocol.
|
|
14
|
+
*/
|
|
15
|
+
export const VoiceClientMessageSchema = z.discriminatedUnion("type", [
|
|
16
|
+
/** Open a session; must be the first message, and only one per connection. */
|
|
17
|
+
z.object({
|
|
18
|
+
type: z.literal("start"),
|
|
19
|
+
provider: z.string().min(1),
|
|
20
|
+
/** Named server-side persona; the host maps it to instructions and tools it composes itself. */
|
|
21
|
+
agent: z.string().min(1).max(64).optional(),
|
|
22
|
+
instructions: z.string().max(10_000).optional(),
|
|
23
|
+
/** Restrict model output, e.g. ["text"] for a text-only session. Default: provider's choice (audio). */
|
|
24
|
+
outputModalities: z.array(OutputModalitySchema).min(1).optional(),
|
|
25
|
+
/** Output voice id; the host validates it against the provider's set (unknown → default). */
|
|
26
|
+
voice: z.string().min(1).max(64).optional(),
|
|
27
|
+
}),
|
|
28
|
+
/** A typed user turn (text mode, or typing alongside voice). */
|
|
29
|
+
z.object({ type: z.literal("text"), text: MessageInputTextSchema }),
|
|
30
|
+
/** Playback acknowledgement; feeds barge-in truncation. Send every ~250 ms while playing. */
|
|
31
|
+
z.object({
|
|
32
|
+
type: z.literal("playout"),
|
|
33
|
+
streamId: z.string().min(1),
|
|
34
|
+
playedThroughSample: z.number().int().nonnegative(),
|
|
35
|
+
}),
|
|
36
|
+
/** Graceful end; the server still delivers the trailing events and a closed frame. */
|
|
37
|
+
z.object({ type: z.literal("stop"), reason: z.string().min(1).optional() }),
|
|
38
|
+
]);
|
|
39
|
+
export const VOICE_BRIDGE_INPUT_SAMPLE_RATE = 24_000;
|
|
40
|
+
export class VoiceSocketBridge {
|
|
41
|
+
#socket;
|
|
42
|
+
#startSession;
|
|
43
|
+
#inputSampleRate;
|
|
44
|
+
#session;
|
|
45
|
+
#sequence = 0;
|
|
46
|
+
#startSample = 0;
|
|
47
|
+
#closing = false;
|
|
48
|
+
#sessionEnded = false;
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.#socket = options.socket;
|
|
51
|
+
this.#startSession = options.startSession;
|
|
52
|
+
this.#inputSampleRate = options.inputSampleRate ?? VOICE_BRIDGE_INPUT_SAMPLE_RATE;
|
|
53
|
+
}
|
|
54
|
+
/** Feed every text frame from the client socket here. */
|
|
55
|
+
async handleText(text) {
|
|
56
|
+
try {
|
|
57
|
+
await this.#handleControl(VoiceClientMessageSchema.parse(JSON.parse(text)));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
this.#sendJson({ type: "error", message: errorMessage(error) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Feed every binary frame from the client socket here. */
|
|
64
|
+
async handleBinary(data) {
|
|
65
|
+
try {
|
|
66
|
+
await this.#pushAudio(data);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
this.#sendJson({ type: "error", message: errorMessage(error) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Call when the client socket closes; settles the session. */
|
|
73
|
+
async handleClose(reason = "socket_closed") {
|
|
74
|
+
await this.#teardown(reason);
|
|
75
|
+
}
|
|
76
|
+
async #handleControl(message) {
|
|
77
|
+
switch (message.type) {
|
|
78
|
+
case "start": {
|
|
79
|
+
if (this.#session)
|
|
80
|
+
throw new Error("A session is already active on this connection");
|
|
81
|
+
const { type: _type, ...start } = message;
|
|
82
|
+
const session = await this.#startSession(start);
|
|
83
|
+
this.#session = session;
|
|
84
|
+
this.#sequence = 0;
|
|
85
|
+
this.#startSample = 0;
|
|
86
|
+
this.#sessionEnded = false;
|
|
87
|
+
this.#sendJson({ type: "started", sessionId: session.id, provider: message.provider });
|
|
88
|
+
void this.#pumpEvents(session);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "text": {
|
|
92
|
+
if (this.#sessionEnded)
|
|
93
|
+
break;
|
|
94
|
+
await this.#session?.sendText(message.text);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "playout": {
|
|
98
|
+
if (this.#sessionEnded)
|
|
99
|
+
break;
|
|
100
|
+
await this.#session?.reportPlayout({
|
|
101
|
+
streamId: message.streamId,
|
|
102
|
+
playedThroughSample: message.playedThroughSample,
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "stop": {
|
|
107
|
+
await this.#teardown(message.reason ?? "client_stopped");
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async #pushAudio(data) {
|
|
113
|
+
const session = this.#session;
|
|
114
|
+
// In-flight microphone frames after settlement are expected, not an error.
|
|
115
|
+
if (!session || this.#closing || this.#sessionEnded)
|
|
116
|
+
return;
|
|
117
|
+
const byteLength = data.byteLength - (data.byteLength % 2);
|
|
118
|
+
if (byteLength === 0)
|
|
119
|
+
return;
|
|
120
|
+
const chunk = {
|
|
121
|
+
streamId: "microphone",
|
|
122
|
+
sequence: this.#sequence,
|
|
123
|
+
encoding: "pcm_s16le",
|
|
124
|
+
sampleRate: this.#inputSampleRate,
|
|
125
|
+
channels: 1,
|
|
126
|
+
startSample: this.#startSample,
|
|
127
|
+
data: copyBytes(data, byteLength),
|
|
128
|
+
};
|
|
129
|
+
this.#sequence += 1;
|
|
130
|
+
this.#startSample += byteLength / 2;
|
|
131
|
+
await session.sendAudio(chunk);
|
|
132
|
+
}
|
|
133
|
+
async #pumpEvents(session) {
|
|
134
|
+
try {
|
|
135
|
+
for await (const event of session.events()) {
|
|
136
|
+
if (event.type === "audio.output") {
|
|
137
|
+
this.#sendBinary(encodeVoiceAudioFrame(event.chunk));
|
|
138
|
+
}
|
|
139
|
+
this.#sendJson({ type: "event", event: summarizeVoiceEvent(event) });
|
|
140
|
+
if (event.type === "session.ended") {
|
|
141
|
+
this.#sessionEnded = true;
|
|
142
|
+
this.#sendJson({ type: "closed", reason: event.reason });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
this.#sendJson({ type: "error", message: errorMessage(error) });
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
// However the pump exits, the connection must settle: no more media forwarding,
|
|
151
|
+
// and the client always receives a closed frame exactly once.
|
|
152
|
+
if (!this.#sessionEnded) {
|
|
153
|
+
this.#sessionEnded = true;
|
|
154
|
+
this.#sendJson({ type: "closed", reason: "session_stream_ended" });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async #teardown(reason) {
|
|
159
|
+
if (this.#closing)
|
|
160
|
+
return;
|
|
161
|
+
this.#closing = true;
|
|
162
|
+
const session = this.#session;
|
|
163
|
+
this.#session = undefined;
|
|
164
|
+
try {
|
|
165
|
+
if (session)
|
|
166
|
+
await session.close(reason).catch(() => undefined);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
this.#closing = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
#sendJson(payload) {
|
|
173
|
+
try {
|
|
174
|
+
this.#socket.sendText(JSON.stringify(payload));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// The client socket is gone; teardown happens via handleClose.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
#sendBinary(frame) {
|
|
181
|
+
try {
|
|
182
|
+
this.#socket.sendBinary(frame);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// The client socket is gone; teardown happens via handleClose.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function encodeVoiceAudioFrame(chunk) {
|
|
190
|
+
const header = new TextEncoder().encode(JSON.stringify({
|
|
191
|
+
streamId: chunk.streamId,
|
|
192
|
+
sequence: chunk.sequence,
|
|
193
|
+
startSample: chunk.startSample,
|
|
194
|
+
sampleRate: chunk.sampleRate,
|
|
195
|
+
channels: chunk.channels,
|
|
196
|
+
}));
|
|
197
|
+
const frame = new Uint8Array(4 + header.byteLength + chunk.data.byteLength);
|
|
198
|
+
new DataView(frame.buffer).setUint32(0, header.byteLength, true);
|
|
199
|
+
frame.set(header, 4);
|
|
200
|
+
frame.set(chunk.data, 4 + header.byteLength);
|
|
201
|
+
return frame.buffer;
|
|
202
|
+
}
|
|
203
|
+
/** Event payload for clients: audio chunks shrink to metadata, bytes stay server-side. */
|
|
204
|
+
export function summarizeVoiceEvent(event) {
|
|
205
|
+
if (event.type === "audio.input" || event.type === "audio.output") {
|
|
206
|
+
const { chunk, ...rest } = event;
|
|
207
|
+
return {
|
|
208
|
+
...rest,
|
|
209
|
+
chunk: {
|
|
210
|
+
streamId: chunk.streamId,
|
|
211
|
+
sequence: chunk.sequence,
|
|
212
|
+
startSample: chunk.startSample,
|
|
213
|
+
sampleRate: chunk.sampleRate,
|
|
214
|
+
byteLength: chunk.data.byteLength,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { ...event };
|
|
219
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HarnessEvent, NewHarnessEvent, ProviderContextEvent } from "../protocol/index.js";
|
|
2
|
+
import type { BackgroundTaskLauncher } from "./tools.js";
|
|
3
|
+
type RecordEvent = (event: NewHarnessEvent) => Promise<HarnessEvent>;
|
|
4
|
+
type SendContext = (event: ProviderContextEvent) => Promise<void>;
|
|
5
|
+
type Flush = () => Promise<void>;
|
|
6
|
+
export declare class TaskSupervisor {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(options: {
|
|
9
|
+
record: RecordEvent;
|
|
10
|
+
sendContext: SendContext;
|
|
11
|
+
flush: Flush;
|
|
12
|
+
sessionSignal: AbortSignal;
|
|
13
|
+
onError: (error: unknown) => void;
|
|
14
|
+
});
|
|
15
|
+
forCall(callId: string): BackgroundTaskLauncher;
|
|
16
|
+
activate(taskId: string, callId: string): Promise<boolean>;
|
|
17
|
+
cancelPending(taskId: string, callId: string, reason: string): Promise<boolean>;
|
|
18
|
+
cancelAll(reason: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { JsonValueSchema } from "../protocol/index.js";
|
|
2
|
+
export class TaskSupervisor {
|
|
3
|
+
#tasks = new Map();
|
|
4
|
+
#terminalTasks = new Set();
|
|
5
|
+
#record;
|
|
6
|
+
#sendContext;
|
|
7
|
+
#flush;
|
|
8
|
+
#sessionSignal;
|
|
9
|
+
#onError;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.#record = options.record;
|
|
12
|
+
this.#sendContext = options.sendContext;
|
|
13
|
+
this.#flush = options.flush;
|
|
14
|
+
this.#sessionSignal = options.sessionSignal;
|
|
15
|
+
this.#onError = options.onError;
|
|
16
|
+
}
|
|
17
|
+
forCall(callId) {
|
|
18
|
+
return {
|
|
19
|
+
start: (runner) => this.#register(callId, runner),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async activate(taskId, callId) {
|
|
23
|
+
const task = this.#tasks.get(taskId);
|
|
24
|
+
if (!task || task.callId !== callId) {
|
|
25
|
+
await this.#settle({ type: "task.failed", taskId, callId, error: "activation_failed" }, true);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (task.state === "running")
|
|
29
|
+
return false;
|
|
30
|
+
task.state = "running";
|
|
31
|
+
task.promise = this.#run(taskId, task);
|
|
32
|
+
void task.promise.catch(this.#onError);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
async cancelPending(taskId, callId, reason) {
|
|
36
|
+
const task = this.#tasks.get(taskId);
|
|
37
|
+
if (!task || task.callId !== callId) {
|
|
38
|
+
await this.#settle({ type: "task.failed", taskId, callId, error: "activation_failed" }, false);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (task.state === "running")
|
|
42
|
+
return false;
|
|
43
|
+
this.#tasks.delete(taskId);
|
|
44
|
+
task.detachSessionAbort();
|
|
45
|
+
task.controller.abort(reason);
|
|
46
|
+
await this.#settle({ type: "task.cancelled", taskId, callId, reason }, false);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
async cancelAll(reason) {
|
|
50
|
+
const pendingCancellations = [];
|
|
51
|
+
const running = [];
|
|
52
|
+
for (const [taskId, task] of this.#tasks) {
|
|
53
|
+
if (task.state === "pending") {
|
|
54
|
+
this.#tasks.delete(taskId);
|
|
55
|
+
task.detachSessionAbort();
|
|
56
|
+
task.controller.abort(reason);
|
|
57
|
+
pendingCancellations.push(this.#settle({ type: "task.cancelled", taskId, callId: task.callId, reason }, true).then(() => undefined));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
task.controller.abort(reason);
|
|
61
|
+
if (task.promise)
|
|
62
|
+
running.push(task.promise);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await Promise.allSettled([...pendingCancellations, ...running]);
|
|
66
|
+
}
|
|
67
|
+
async #register(callId, runner) {
|
|
68
|
+
if (this.#sessionSignal.aborted)
|
|
69
|
+
throw new Error("Session is closing");
|
|
70
|
+
const taskId = crypto.randomUUID();
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const abortFromSession = () => controller.abort(this.#sessionSignal.reason);
|
|
73
|
+
this.#sessionSignal.addEventListener("abort", abortFromSession, { once: true });
|
|
74
|
+
this.#tasks.set(taskId, {
|
|
75
|
+
callId,
|
|
76
|
+
controller,
|
|
77
|
+
runner,
|
|
78
|
+
state: "pending",
|
|
79
|
+
detachSessionAbort: () => this.#sessionSignal.removeEventListener("abort", abortFromSession),
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
await this.#record({ type: "task.accepted", taskId, callId });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.#tasks.delete(taskId);
|
|
86
|
+
this.#sessionSignal.removeEventListener("abort", abortFromSession);
|
|
87
|
+
controller.abort("task_acceptance_failed");
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return taskId;
|
|
91
|
+
}
|
|
92
|
+
async #run(taskId, task) {
|
|
93
|
+
try {
|
|
94
|
+
let terminal;
|
|
95
|
+
let infrastructureFailure;
|
|
96
|
+
try {
|
|
97
|
+
const result = JsonValueSchema.parse(await task.runner({
|
|
98
|
+
signal: task.controller.signal,
|
|
99
|
+
report: async (update) => {
|
|
100
|
+
try {
|
|
101
|
+
const event = { type: "task.progress", taskId, callId: task.callId, update };
|
|
102
|
+
await this.#record(event);
|
|
103
|
+
await this.#flush();
|
|
104
|
+
await this.#deliverContext(event, "progress");
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
infrastructureFailure = { error };
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}));
|
|
112
|
+
if (infrastructureFailure)
|
|
113
|
+
throw infrastructureFailure.error;
|
|
114
|
+
if (task.controller.signal.aborted) {
|
|
115
|
+
terminal = {
|
|
116
|
+
type: "task.cancelled",
|
|
117
|
+
taskId,
|
|
118
|
+
callId: task.callId,
|
|
119
|
+
reason: String(task.controller.signal.reason ?? "cancelled"),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
terminal = { type: "task.completed", taskId, callId: task.callId, result };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (infrastructureFailure)
|
|
128
|
+
throw infrastructureFailure.error;
|
|
129
|
+
if (task.controller.signal.aborted) {
|
|
130
|
+
terminal = {
|
|
131
|
+
type: "task.cancelled",
|
|
132
|
+
taskId,
|
|
133
|
+
callId: task.callId,
|
|
134
|
+
reason: String(task.controller.signal.reason ?? "cancelled"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
terminal = { type: "task.failed", taskId, callId: task.callId, error: errorMessage(error) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await this.#settle(terminal, true);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
task.detachSessionAbort();
|
|
145
|
+
this.#tasks.delete(taskId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async #settle(event, deliverContext) {
|
|
149
|
+
const key = taskKey(event.taskId, event.callId);
|
|
150
|
+
if (this.#terminalTasks.has(key))
|
|
151
|
+
return false;
|
|
152
|
+
await this.#record(event);
|
|
153
|
+
this.#terminalTasks.add(key);
|
|
154
|
+
await this.#flush();
|
|
155
|
+
if (deliverContext)
|
|
156
|
+
await this.#deliverContext(event, "terminal");
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
async #deliverContext(event, phase) {
|
|
160
|
+
try {
|
|
161
|
+
await this.#sendContext(event);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
await this.#record({
|
|
165
|
+
type: "task.delivery_failed",
|
|
166
|
+
taskId: event.taskId,
|
|
167
|
+
callId: event.callId,
|
|
168
|
+
phase,
|
|
169
|
+
error: errorMessage(error),
|
|
170
|
+
});
|
|
171
|
+
await this.#flush();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function taskKey(taskId, callId) {
|
|
176
|
+
return JSON.stringify([taskId, callId]);
|
|
177
|
+
}
|
|
178
|
+
function errorMessage(error) {
|
|
179
|
+
return error instanceof Error ? error.message : String(error);
|
|
180
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type HarnessEvent, type NewHarnessEvent, type ProviderToolResult, type ToolCall } from "../protocol/index.js";
|
|
2
|
+
import type { TaskSupervisor } from "./task-supervisor.js";
|
|
3
|
+
import type { ToolRegistry } from "./tools.js";
|
|
4
|
+
type RecordEvent = (event: NewHarnessEvent) => Promise<HarnessEvent>;
|
|
5
|
+
export interface ToolResultSink {
|
|
6
|
+
deliver(result: ProviderToolResult): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare class ToolRuntime {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(options: {
|
|
11
|
+
tools: ToolRegistry;
|
|
12
|
+
tasks: TaskSupervisor;
|
|
13
|
+
record: RecordEvent;
|
|
14
|
+
resultSink: ToolResultSink;
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
flush: () => Promise<void>;
|
|
17
|
+
});
|
|
18
|
+
execute(call: ToolCall): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ToolOutcomeSchema, } from "../protocol/index.js";
|
|
2
|
+
export class ToolRuntime {
|
|
3
|
+
#tools;
|
|
4
|
+
#tasks;
|
|
5
|
+
#record;
|
|
6
|
+
#resultSink;
|
|
7
|
+
#signal;
|
|
8
|
+
#flush;
|
|
9
|
+
#inFlight = new Set();
|
|
10
|
+
#terminal = new Set();
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.#tools = options.tools;
|
|
13
|
+
this.#tasks = options.tasks;
|
|
14
|
+
this.#record = options.record;
|
|
15
|
+
this.#resultSink = options.resultSink;
|
|
16
|
+
this.#signal = options.signal;
|
|
17
|
+
this.#flush = options.flush;
|
|
18
|
+
}
|
|
19
|
+
async execute(call) {
|
|
20
|
+
if (this.#inFlight.has(call.callId) || this.#terminal.has(call.callId))
|
|
21
|
+
return;
|
|
22
|
+
this.#inFlight.add(call.callId);
|
|
23
|
+
try {
|
|
24
|
+
await this.#record({ type: "tool.started", callId: call.callId, name: call.name });
|
|
25
|
+
await this.#flush();
|
|
26
|
+
const tool = this.#tools.get(call.name);
|
|
27
|
+
if (!tool) {
|
|
28
|
+
await this.#settleFailure(call.callId, `Tool not found: ${call.name}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let outcome;
|
|
32
|
+
try {
|
|
33
|
+
const input = await tool.input.parseAsync(call.arguments);
|
|
34
|
+
outcome = ToolOutcomeSchema.parse(await this.#run(tool, input, call.callId));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await this.#settleFailure(call.callId, errorMessage(error));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await this.#settleCompleted(call.callId, outcome);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
this.#inFlight.delete(call.callId);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async #run(tool, input, callId) {
|
|
47
|
+
return tool.execute(input, {
|
|
48
|
+
signal: this.#signal,
|
|
49
|
+
tasks: this.#tasks.forCall(callId),
|
|
50
|
+
report: async (update) => {
|
|
51
|
+
await this.#record({ type: "tool.progress", callId, update });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async #settleCompleted(callId, outcome) {
|
|
56
|
+
this.#assertNotTerminal(callId);
|
|
57
|
+
await this.#record({ type: "tool.completed", callId, outcome });
|
|
58
|
+
this.#terminal.add(callId);
|
|
59
|
+
await this.#flush();
|
|
60
|
+
await this.#deliver(callId, outcome);
|
|
61
|
+
}
|
|
62
|
+
async #settleFailure(callId, message) {
|
|
63
|
+
this.#assertNotTerminal(callId);
|
|
64
|
+
await this.#record({ type: "tool.failed", callId, error: message });
|
|
65
|
+
this.#terminal.add(callId);
|
|
66
|
+
await this.#flush();
|
|
67
|
+
await this.#deliver(callId, { type: "failed", error: message });
|
|
68
|
+
}
|
|
69
|
+
async #deliver(callId, outcome) {
|
|
70
|
+
try {
|
|
71
|
+
await this.#resultSink.deliver({ callId, outcome });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
await this.#record({ type: "tool.delivery_failed", callId, error: errorMessage(error) });
|
|
75
|
+
await this.#flush();
|
|
76
|
+
if (outcome.type === "accepted_task") {
|
|
77
|
+
await this.#tasks.cancelPending(outcome.taskId, callId, "tool_result_delivery_failed");
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (outcome.type === "accepted_task") {
|
|
82
|
+
await this.#tasks.activate(outcome.taskId, callId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
#assertNotTerminal(callId) {
|
|
86
|
+
if (this.#terminal.has(callId)) {
|
|
87
|
+
throw new Error(`Tool call already settled: ${callId}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function errorMessage(error) {
|
|
92
|
+
return error instanceof Error ? error.message : String(error);
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type JsonValue, type ProviderToolDefinition, type ToolOutcome } from "../protocol/index.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export interface BackgroundTaskContext {
|
|
4
|
+
readonly signal: AbortSignal;
|
|
5
|
+
report(update: JsonValue): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export type BackgroundTaskRunner = (context: BackgroundTaskContext) => Promise<JsonValue>;
|
|
8
|
+
export interface BackgroundTaskLauncher {
|
|
9
|
+
start(run: BackgroundTaskRunner): Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
export interface ToolExecutionContext {
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
readonly tasks: BackgroundTaskLauncher;
|
|
14
|
+
report(update: JsonValue): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface VoiceTool<Schema extends z.ZodType = z.ZodType> {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly description: string;
|
|
19
|
+
readonly input: Schema;
|
|
20
|
+
execute(input: z.output<Schema>, context: ToolExecutionContext): ToolOutcome | Promise<ToolOutcome>;
|
|
21
|
+
}
|
|
22
|
+
export declare function defineTool<const Schema extends z.ZodType>(tool: VoiceTool<Schema>): VoiceTool<Schema>;
|
|
23
|
+
type ErasedVoiceTool = VoiceTool<z.ZodType<unknown>>;
|
|
24
|
+
export declare class ToolRegistry {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(tools?: readonly VoiceTool[]);
|
|
27
|
+
register<const Schema extends z.ZodType>(tool: VoiceTool<Schema>): void;
|
|
28
|
+
get(name: string): ErasedVoiceTool | undefined;
|
|
29
|
+
list(): readonly ErasedVoiceTool[];
|
|
30
|
+
definitions(): readonly ProviderToolDefinition[];
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ProviderToolDefinitionSchema, } from "../protocol/index.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export function defineTool(tool) {
|
|
4
|
+
return tool;
|
|
5
|
+
}
|
|
6
|
+
export class ToolRegistry {
|
|
7
|
+
#tools = new Map();
|
|
8
|
+
constructor(tools = []) {
|
|
9
|
+
for (const tool of tools)
|
|
10
|
+
this.register(tool);
|
|
11
|
+
}
|
|
12
|
+
register(tool) {
|
|
13
|
+
if (this.#tools.has(tool.name))
|
|
14
|
+
throw new Error(`Tool already registered: ${tool.name}`);
|
|
15
|
+
this.#tools.set(tool.name, tool);
|
|
16
|
+
}
|
|
17
|
+
get(name) {
|
|
18
|
+
return this.#tools.get(name);
|
|
19
|
+
}
|
|
20
|
+
list() {
|
|
21
|
+
return [...this.#tools.values()];
|
|
22
|
+
}
|
|
23
|
+
definitions() {
|
|
24
|
+
return this.list().map((tool) => ProviderToolDefinitionSchema.parse({
|
|
25
|
+
name: tool.name,
|
|
26
|
+
description: tool.description,
|
|
27
|
+
inputSchema: z.toJSONSchema(tool.input),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
}
|