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.
Files changed (86) hide show
  1. package/README.md +32 -0
  2. package/dist/harness.d.ts +1 -0
  3. package/dist/harness.js +1 -0
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.js +4 -0
  6. package/dist/internal/harness/event-stream.d.ts +7 -0
  7. package/dist/internal/harness/event-stream.js +42 -0
  8. package/dist/internal/harness/index.d.ts +7 -0
  9. package/dist/internal/harness/index.js +7 -0
  10. package/dist/internal/harness/message.d.ts +26 -0
  11. package/dist/internal/harness/message.js +192 -0
  12. package/dist/internal/harness/report.d.ts +51 -0
  13. package/dist/internal/harness/report.js +115 -0
  14. package/dist/internal/harness/shadow.d.ts +28 -0
  15. package/dist/internal/harness/shadow.js +118 -0
  16. package/dist/internal/harness/socket-bridge.d.ts +65 -0
  17. package/dist/internal/harness/socket-bridge.js +227 -0
  18. package/dist/internal/harness/task-supervisor.d.ts +20 -0
  19. package/dist/internal/harness/task-supervisor.js +180 -0
  20. package/dist/internal/harness/tool-runtime.d.ts +20 -0
  21. package/dist/internal/harness/tool-runtime.js +93 -0
  22. package/dist/internal/harness/tools.d.ts +32 -0
  23. package/dist/internal/harness/tools.js +30 -0
  24. package/dist/internal/harness/voice-session.d.ts +33 -0
  25. package/dist/internal/harness/voice-session.js +268 -0
  26. package/dist/internal/harness/xo.d.ts +25 -0
  27. package/dist/internal/harness/xo.js +24 -0
  28. package/dist/internal/protocol/async-queue.d.ts +8 -0
  29. package/dist/internal/protocol/async-queue.js +34 -0
  30. package/dist/internal/protocol/audio.d.ts +12 -0
  31. package/dist/internal/protocol/audio.js +24 -0
  32. package/dist/internal/protocol/events.d.ts +350 -0
  33. package/dist/internal/protocol/events.js +167 -0
  34. package/dist/internal/protocol/index.d.ts +6 -0
  35. package/dist/internal/protocol/index.js +6 -0
  36. package/dist/internal/protocol/parts.d.ts +219 -0
  37. package/dist/internal/protocol/parts.js +105 -0
  38. package/dist/internal/protocol/provider.d.ts +118 -0
  39. package/dist/internal/protocol/provider.js +77 -0
  40. package/dist/internal/protocol/tools.d.ts +62 -0
  41. package/dist/internal/protocol/tools.js +47 -0
  42. package/dist/internal/provider/contract.d.ts +24 -0
  43. package/dist/internal/provider/contract.js +1 -0
  44. package/dist/internal/provider/grok-voice.d.ts +45 -0
  45. package/dist/internal/provider/grok-voice.js +77 -0
  46. package/dist/internal/provider/index.d.ts +6 -0
  47. package/dist/internal/provider/index.js +9 -0
  48. package/dist/internal/provider/node-socket.d.ts +3 -0
  49. package/dist/internal/provider/node-socket.js +74 -0
  50. package/dist/internal/provider/openai-realtime.d.ts +43 -0
  51. package/dist/internal/provider/openai-realtime.js +76 -0
  52. package/dist/internal/provider/realtime-session.d.ts +30 -0
  53. package/dist/internal/provider/realtime-session.js +461 -0
  54. package/dist/internal/provider/realtime-socket.d.ts +42 -0
  55. package/dist/internal/provider/realtime-socket.js +30 -0
  56. package/dist/internal/provider/workers-socket.d.ts +11 -0
  57. package/dist/internal/provider/workers-socket.js +108 -0
  58. package/dist/internal/provider/workers.d.ts +6 -0
  59. package/dist/internal/provider/workers.js +11 -0
  60. package/dist/internal/provider-fake/index.d.ts +46 -0
  61. package/dist/internal/provider-fake/index.js +112 -0
  62. package/dist/internal/provider-fake/replay-voice-provider.d.ts +47 -0
  63. package/dist/internal/provider-fake/replay-voice-provider.js +133 -0
  64. package/dist/internal/provider-fake/scripted-voice-provider.d.ts +61 -0
  65. package/dist/internal/provider-fake/scripted-voice-provider.js +185 -0
  66. package/dist/internal/storage/append-tail.d.ts +12 -0
  67. package/dist/internal/storage/append-tail.js +19 -0
  68. package/dist/internal/storage/event-store.d.ts +19 -0
  69. package/dist/internal/storage/event-store.js +1 -0
  70. package/dist/internal/storage/index.d.ts +4 -0
  71. package/dist/internal/storage/index.js +4 -0
  72. package/dist/internal/storage/jsonl-event-store.d.ts +13 -0
  73. package/dist/internal/storage/jsonl-event-store.js +105 -0
  74. package/dist/internal/storage/memory-event-store.d.ts +8 -0
  75. package/dist/internal/storage/memory-event-store.js +22 -0
  76. package/dist/protocol.d.ts +1 -0
  77. package/dist/protocol.js +1 -0
  78. package/dist/provider-workers.d.ts +1 -0
  79. package/dist/provider-workers.js +1 -0
  80. package/dist/provider.d.ts +1 -0
  81. package/dist/provider.js +1 -0
  82. package/dist/storage.d.ts +1 -0
  83. package/dist/storage.js +1 -0
  84. package/dist/testing.d.ts +1 -0
  85. package/dist/testing.js +1 -0
  86. package/package.json +76 -0
@@ -0,0 +1,33 @@
1
+ import { type AgentInput, type AudioChunk, type HarnessEvent, type OutputModality, type PlayoutProgress } from "../protocol/index.js";
2
+ import type { VoiceProvider } from "../provider/index.js";
3
+ import type { EventStore } from "../storage/index.js";
4
+ import type { ToolRegistry } from "./tools.js";
5
+ export interface CreateVoiceSessionOptions {
6
+ sessionId: string;
7
+ provider: VoiceProvider;
8
+ store: EventStore;
9
+ tools: ToolRegistry;
10
+ instructions?: string;
11
+ /** What the model may produce this session; ["text"] disables audio output. Default: audio. */
12
+ outputModalities?: readonly OutputModality[];
13
+ /** Closes the session with max_duration_reached once elapsed. Guards runaway metered sessions. */
14
+ maxDurationMs?: number;
15
+ }
16
+ export declare class VoiceSession {
17
+ #private;
18
+ readonly id: string;
19
+ private constructor();
20
+ static create(options: CreateVoiceSessionOptions): Promise<VoiceSession>;
21
+ events(afterSequence?: number): AsyncIterable<HarnessEvent>;
22
+ history(): Promise<readonly HarnessEvent[]>;
23
+ sendAudio(chunk: AudioChunk): Promise<void>;
24
+ /**
25
+ * Injects a user turn. Accepts a plain string or multimodal `AgentInput` (text /
26
+ * image / file parts). The parts are recorded on the `message.input` event, so the
27
+ * log keeps full fidelity for projection; realtime providers only consume text, so
28
+ * the collapsed text is what goes over the wire (media-native providers wire later).
29
+ */
30
+ sendText(input: AgentInput): Promise<void>;
31
+ reportPlayout(progress: PlayoutProgress): Promise<void>;
32
+ close(reason?: string): Promise<void>;
33
+ }
@@ -0,0 +1,268 @@
1
+ import { AgentInputSchema, AudioChunkSchema, agentInputToText, MessageInputTextSchema, PlayoutProgressSchema, ProviderEventSchema, } from "../protocol/index.js";
2
+ import { ReplayEventStream } from "./event-stream.js";
3
+ import { TaskSupervisor } from "./task-supervisor.js";
4
+ import { ToolRuntime } from "./tool-runtime.js";
5
+ export class VoiceSession {
6
+ id;
7
+ #providerSession;
8
+ #store;
9
+ #events = new ReplayEventStream();
10
+ #controller = new AbortController();
11
+ #startedAt;
12
+ #toolExecutions = new Set();
13
+ #pendingRecords = new Set();
14
+ #tasks;
15
+ #toolRuntime;
16
+ #providerPump;
17
+ #closePromise;
18
+ #maxDurationTimer;
19
+ constructor(options, providerSession, startedAt, startedEvent) {
20
+ this.id = options.sessionId;
21
+ this.#providerSession = providerSession;
22
+ this.#store = options.store;
23
+ this.#startedAt = startedAt;
24
+ this.#events.publish(startedEvent);
25
+ this.#tasks = new TaskSupervisor({
26
+ record: (event) => this.#record(event),
27
+ sendContext: (event) => this.#providerSession.sendContext(event),
28
+ flush: () => this.#store.flush(this.id),
29
+ sessionSignal: this.#controller.signal,
30
+ onError: () => {
31
+ void this.close("storage_error");
32
+ },
33
+ });
34
+ this.#toolRuntime = new ToolRuntime({
35
+ tools: options.tools,
36
+ tasks: this.#tasks,
37
+ record: (event) => this.#record(event),
38
+ resultSink: {
39
+ deliver: (result) => this.#providerSession.sendToolResult(result),
40
+ },
41
+ signal: this.#controller.signal,
42
+ flush: () => this.#store.flush(this.id),
43
+ });
44
+ }
45
+ static async create(options) {
46
+ const startedAt = performance.now();
47
+ const startedEvent = await options.store.append(createUnsequencedEvent(options.sessionId, startedAt, {
48
+ type: "session.started",
49
+ formatVersion: 1,
50
+ providerId: options.provider.id,
51
+ capabilities: options.provider.capabilities,
52
+ ...(options.instructions === undefined ? {} : { instructions: options.instructions }),
53
+ ...(options.outputModalities === undefined
54
+ ? {}
55
+ : { outputModalities: [...options.outputModalities] }),
56
+ }));
57
+ await options.store.flush(options.sessionId);
58
+ const providerController = new AbortController();
59
+ let providerSession;
60
+ try {
61
+ providerSession = await options.provider.createSession({
62
+ sessionId: options.sessionId,
63
+ signal: providerController.signal,
64
+ tools: options.tools.definitions(),
65
+ ...(options.instructions === undefined ? {} : { instructions: options.instructions }),
66
+ ...(options.outputModalities === undefined ? {} : { outputModalities: options.outputModalities }),
67
+ });
68
+ }
69
+ catch (error) {
70
+ providerController.abort(error);
71
+ await recordFailedCreation(options.store, options.sessionId, startedAt);
72
+ throw error;
73
+ }
74
+ const session = new VoiceSession(options, providerSession, startedAt, startedEvent);
75
+ session.#controller.signal.addEventListener("abort", () => providerController.abort(session.#controller.signal.reason), {
76
+ once: true,
77
+ });
78
+ session.#providerPump = session.#pumpProviderEvents();
79
+ if (options.maxDurationMs !== undefined) {
80
+ session.#maxDurationTimer = setTimeout(() => {
81
+ void session.close("max_duration_reached");
82
+ }, options.maxDurationMs);
83
+ }
84
+ return session;
85
+ }
86
+ events(afterSequence = 0) {
87
+ return this.#events.subscribe(afterSequence);
88
+ }
89
+ history() {
90
+ return this.#store.list(this.id);
91
+ }
92
+ async sendAudio(chunk) {
93
+ this.#assertOpen();
94
+ const validated = AudioChunkSchema.parse(chunk);
95
+ // The store reserves this event's log position synchronously, so the durable append
96
+ // and the provider forward can run in parallel without reordering the log. Media
97
+ // latency must not wait on persistence; the call still settles only once both are done.
98
+ const recorded = this.#record({ type: "audio.input", chunk: validated });
99
+ recorded.catch(() => undefined);
100
+ await this.#providerSession.sendAudio(validated);
101
+ await recorded;
102
+ }
103
+ /**
104
+ * Injects a user turn. Accepts a plain string or multimodal `AgentInput` (text /
105
+ * image / file parts). The parts are recorded on the `message.input` event, so the
106
+ * log keeps full fidelity for projection; realtime providers only consume text, so
107
+ * the collapsed text is what goes over the wire (media-native providers wire later).
108
+ */
109
+ async sendText(input) {
110
+ this.#assertOpen();
111
+ const parsed = AgentInputSchema.parse(input);
112
+ const collapsed = agentInputToText(parsed);
113
+ if (collapsed === "") {
114
+ throw new Error("Realtime providers require at least one text part in AgentInput");
115
+ }
116
+ const text = MessageInputTextSchema.parse(collapsed);
117
+ await this.#record({
118
+ type: "message.input",
119
+ text,
120
+ ...(typeof parsed === "string" ? {} : { parts: parsed }),
121
+ });
122
+ await this.#providerSession.sendText(text);
123
+ }
124
+ async reportPlayout(progress) {
125
+ this.#assertOpen();
126
+ const validated = PlayoutProgressSchema.parse(progress);
127
+ await this.#record({ type: "playout.progress", ...validated });
128
+ await this.#providerSession.reportPlayout(validated);
129
+ }
130
+ close(reason = "client_closed") {
131
+ this.#closePromise ??= this.#performClose(reason);
132
+ return this.#closePromise;
133
+ }
134
+ async #performClose(reason) {
135
+ if (this.#maxDurationTimer)
136
+ clearTimeout(this.#maxDurationTimer);
137
+ this.#controller.abort(reason);
138
+ await Promise.allSettled([...this.#toolExecutions]);
139
+ await this.#tasks.cancelAll(reason);
140
+ try {
141
+ await this.#providerSession.close(reason);
142
+ await this.#providerPump;
143
+ }
144
+ finally {
145
+ await Promise.allSettled([...this.#pendingRecords]);
146
+ try {
147
+ await this.#record({ type: "session.ended", reason });
148
+ await this.#store.flush(this.id);
149
+ }
150
+ finally {
151
+ this.#events.close();
152
+ }
153
+ }
154
+ }
155
+ async #pumpProviderEvents() {
156
+ try {
157
+ for await (const candidate of this.#providerSession.events) {
158
+ const event = ProviderEventSchema.parse(candidate);
159
+ switch (event.type) {
160
+ case "ready":
161
+ await this.#record({ type: "provider.ready" });
162
+ break;
163
+ case "audio.output":
164
+ // Pipelined: the append is enqueued (log position reserved) without stalling
165
+ // the pump, so a burst of audio deltas cannot delay a trailing tool call or
166
+ // error behind per-chunk persistence.
167
+ this.#trackRecord(this.#record({ type: "audio.output", chunk: event.chunk }));
168
+ break;
169
+ case "transcript":
170
+ await this.#record({
171
+ type: "transcript",
172
+ role: event.role,
173
+ text: event.text,
174
+ ...(event.streamId === undefined ? {} : { streamId: event.streamId }),
175
+ });
176
+ break;
177
+ case "transcript.delta":
178
+ this.#trackRecord(this.#record({
179
+ type: "transcript.delta",
180
+ role: event.role,
181
+ delta: event.delta,
182
+ ...(event.streamId === undefined ? {} : { streamId: event.streamId }),
183
+ }));
184
+ break;
185
+ case "tool.call":
186
+ await this.#record({ type: "tool.requested", call: event.call });
187
+ this.#trackToolExecution(this.#toolRuntime.execute(event.call));
188
+ break;
189
+ case "usage": {
190
+ const { type: _type, ...usage } = event;
191
+ await this.#record({ type: "usage", ...usage });
192
+ break;
193
+ }
194
+ case "error":
195
+ await this.#record({
196
+ type: "provider.error",
197
+ message: event.message,
198
+ recoverable: event.recoverable,
199
+ });
200
+ if (!event.recoverable)
201
+ void this.close("provider_error");
202
+ break;
203
+ case "closed":
204
+ await this.#record({ type: "provider.closed", reason: event.reason });
205
+ void this.close("provider_closed");
206
+ break;
207
+ }
208
+ }
209
+ }
210
+ catch (error) {
211
+ if (!this.#controller.signal.aborted) {
212
+ await this.#record({
213
+ type: "provider.error",
214
+ message: errorMessage(error),
215
+ recoverable: false,
216
+ });
217
+ }
218
+ }
219
+ }
220
+ #trackToolExecution(execution) {
221
+ this.#toolExecutions.add(execution);
222
+ void execution.then(() => this.#toolExecutions.delete(execution), () => {
223
+ this.#toolExecutions.delete(execution);
224
+ void this.close("storage_error");
225
+ });
226
+ }
227
+ #trackRecord(record) {
228
+ this.#pendingRecords.add(record);
229
+ void record.then(() => this.#pendingRecords.delete(record), () => {
230
+ this.#pendingRecords.delete(record);
231
+ void this.close("storage_error");
232
+ });
233
+ }
234
+ async #record(event) {
235
+ const stored = await this.#store.append(createUnsequencedEvent(this.id, this.#startedAt, event));
236
+ this.#events.publish(stored);
237
+ return stored;
238
+ }
239
+ #assertOpen() {
240
+ if (this.#closePromise || this.#controller.signal.aborted) {
241
+ throw new Error("Voice session is closed");
242
+ }
243
+ }
244
+ }
245
+ function createUnsequencedEvent(sessionId, startedAt, event) {
246
+ return {
247
+ ...event,
248
+ id: crypto.randomUUID(),
249
+ sessionId,
250
+ recordedAtMs: Date.now(),
251
+ sessionTimeMs: performance.now() - startedAt,
252
+ };
253
+ }
254
+ async function recordFailedCreation(store, sessionId, startedAt) {
255
+ try {
256
+ await store.append(createUnsequencedEvent(sessionId, startedAt, {
257
+ type: "session.ended",
258
+ reason: "provider_create_failed",
259
+ }));
260
+ await store.flush(sessionId);
261
+ }
262
+ catch {
263
+ // Preserve the provider creation error; the original started event remains inspectable.
264
+ }
265
+ }
266
+ function errorMessage(error) {
267
+ return error instanceof Error ? error.message : String(error);
268
+ }
@@ -0,0 +1,25 @@
1
+ import type { OutputModality } from "../protocol/index.js";
2
+ import type { VoiceProvider } from "../provider/index.js";
3
+ import type { EventStore } from "../storage/index.js";
4
+ import { ToolRegistry, type VoiceTool } from "./tools.js";
5
+ import { VoiceSession } from "./voice-session.js";
6
+ export interface XOOptions {
7
+ store: EventStore;
8
+ tools?: readonly VoiceTool[];
9
+ }
10
+ export interface StartSessionOptions {
11
+ provider: VoiceProvider;
12
+ sessionId?: string;
13
+ instructions?: string;
14
+ /** What the model may produce this session; ["text"] disables audio output. Default: audio. */
15
+ outputModalities?: readonly OutputModality[];
16
+ /** Closes the session with max_duration_reached once elapsed. Guards runaway metered sessions. */
17
+ maxDurationMs?: number;
18
+ }
19
+ export declare class XO {
20
+ readonly store: EventStore;
21
+ readonly tools: ToolRegistry;
22
+ constructor(options: XOOptions);
23
+ registerTool<const Tool extends VoiceTool>(tool: Tool): void;
24
+ startSession(options: StartSessionOptions): Promise<VoiceSession>;
25
+ }
@@ -0,0 +1,24 @@
1
+ import { ToolRegistry } from "./tools.js";
2
+ import { VoiceSession } from "./voice-session.js";
3
+ export class XO {
4
+ store;
5
+ tools;
6
+ constructor(options) {
7
+ this.store = options.store;
8
+ this.tools = new ToolRegistry(options.tools);
9
+ }
10
+ registerTool(tool) {
11
+ this.tools.register(tool);
12
+ }
13
+ startSession(options) {
14
+ return VoiceSession.create({
15
+ sessionId: options.sessionId ?? crypto.randomUUID(),
16
+ provider: options.provider,
17
+ store: this.store,
18
+ tools: this.tools,
19
+ ...(options.instructions === undefined ? {} : { instructions: options.instructions }),
20
+ ...(options.outputModalities === undefined ? {} : { outputModalities: options.outputModalities }),
21
+ ...(options.maxDurationMs === undefined ? {} : { maxDurationMs: options.maxDurationMs }),
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,8 @@
1
+ /** Unbounded push/pull bridge between a callback producer and an async-iterating consumer. */
2
+ export declare class AsyncQueue<Value> implements AsyncIterableIterator<Value> {
3
+ #private;
4
+ push(value: Value): void;
5
+ close(): void;
6
+ next(): Promise<IteratorResult<Value>>;
7
+ [Symbol.asyncIterator](): AsyncIterableIterator<Value>;
8
+ }
@@ -0,0 +1,34 @@
1
+ /** Unbounded push/pull bridge between a callback producer and an async-iterating consumer. */
2
+ export class AsyncQueue {
3
+ #values = [];
4
+ #resolvers = [];
5
+ #closed = false;
6
+ push(value) {
7
+ if (this.#closed)
8
+ throw new Error("Cannot push to a closed queue");
9
+ const resolve = this.#resolvers.shift();
10
+ if (resolve)
11
+ resolve({ done: false, value });
12
+ else
13
+ this.#values.push(value);
14
+ }
15
+ close() {
16
+ if (this.#closed)
17
+ return;
18
+ this.#closed = true;
19
+ for (const resolve of this.#resolvers.splice(0)) {
20
+ resolve({ done: true, value: undefined });
21
+ }
22
+ }
23
+ next() {
24
+ const value = this.#values.shift();
25
+ if (value !== undefined)
26
+ return Promise.resolve({ done: false, value });
27
+ if (this.#closed)
28
+ return Promise.resolve({ done: true, value: undefined });
29
+ return new Promise((resolve) => this.#resolvers.push(resolve));
30
+ }
31
+ [Symbol.asyncIterator]() {
32
+ return this;
33
+ }
34
+ }
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+ export declare const AudioChunkSchema: z.ZodObject<{
3
+ streamId: z.ZodString;
4
+ sequence: z.ZodInt;
5
+ encoding: z.ZodLiteral<"pcm_s16le">;
6
+ sampleRate: z.ZodInt;
7
+ channels: z.ZodInt;
8
+ startSample: z.ZodInt;
9
+ data: z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>;
10
+ }, z.core.$strip>;
11
+ export type AudioChunk = z.infer<typeof AudioChunkSchema>;
12
+ export declare function audioSampleCount(chunk: AudioChunk): number;
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ export const AudioChunkSchema = z
3
+ .object({
4
+ streamId: z.string().min(1),
5
+ sequence: z.int().nonnegative(),
6
+ encoding: z.literal("pcm_s16le"),
7
+ sampleRate: z.int().positive(),
8
+ channels: z.int().positive(),
9
+ startSample: z.int().nonnegative(),
10
+ data: z.instanceof(Uint8Array),
11
+ })
12
+ .superRefine((chunk, context) => {
13
+ const frameWidth = chunk.channels * 2;
14
+ if (chunk.data.byteLength % frameWidth !== 0) {
15
+ context.addIssue({
16
+ code: "custom",
17
+ message: `PCM byte length must be divisible by the ${frameWidth}-byte frame width`,
18
+ path: ["data"],
19
+ });
20
+ }
21
+ });
22
+ export function audioSampleCount(chunk) {
23
+ return chunk.data.byteLength / (chunk.channels * 2);
24
+ }