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,46 @@
|
|
|
1
|
+
import { type AudioChunk, type OutputModality, type PlayoutProgress, type ProviderContextEvent, type ProviderEvent, type ProviderToolDefinition, type ProviderToolResult, type ToolCall } from "../protocol/index.js";
|
|
2
|
+
import type { CreateProviderSessionOptions, VoiceProvider, VoiceProviderSession } from "../provider/index.js";
|
|
3
|
+
export * from "./replay-voice-provider.js";
|
|
4
|
+
export * from "./scripted-voice-provider.js";
|
|
5
|
+
export interface FakeVoiceProviderOptions {
|
|
6
|
+
autoReady?: boolean;
|
|
7
|
+
echoInput?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare class FakeVoiceProvider implements VoiceProvider {
|
|
10
|
+
#private;
|
|
11
|
+
readonly id = "fake-duplex";
|
|
12
|
+
readonly capabilities: {
|
|
13
|
+
readonly continuousInputDuringOutput: true;
|
|
14
|
+
readonly modelManagedFloor: true;
|
|
15
|
+
readonly supportsToolCalls: true;
|
|
16
|
+
readonly supportsAsyncContext: true;
|
|
17
|
+
readonly supportsPlayoutAcknowledgements: true;
|
|
18
|
+
};
|
|
19
|
+
constructor(options?: FakeVoiceProviderOptions);
|
|
20
|
+
get session(): FakeVoiceProviderSession;
|
|
21
|
+
createSession(options: CreateProviderSessionOptions): Promise<VoiceProviderSession>;
|
|
22
|
+
}
|
|
23
|
+
export declare class FakeVoiceProviderSession implements VoiceProviderSession {
|
|
24
|
+
#private;
|
|
25
|
+
readonly receivedAudio: AudioChunk[];
|
|
26
|
+
readonly receivedTexts: string[];
|
|
27
|
+
readonly playoutProgress: PlayoutProgress[];
|
|
28
|
+
readonly toolResults: ProviderToolResult[];
|
|
29
|
+
readonly contextEvents: ProviderContextEvent[];
|
|
30
|
+
readonly availableTools: readonly ProviderToolDefinition[];
|
|
31
|
+
readonly instructions: string | undefined;
|
|
32
|
+
readonly outputModalities: readonly OutputModality[] | undefined;
|
|
33
|
+
constructor(options: CreateProviderSessionOptions, fakeOptions: Required<FakeVoiceProviderOptions>);
|
|
34
|
+
get events(): AsyncIterable<ProviderEvent>;
|
|
35
|
+
get closed(): boolean;
|
|
36
|
+
sendAudio(chunk: AudioChunk): Promise<void>;
|
|
37
|
+
sendText(text: string): Promise<void>;
|
|
38
|
+
reportPlayout(progress: PlayoutProgress): Promise<void>;
|
|
39
|
+
sendToolResult(result: ProviderToolResult): Promise<void>;
|
|
40
|
+
sendContext(event: ProviderContextEvent): Promise<void>;
|
|
41
|
+
emit(event: ProviderEvent): void;
|
|
42
|
+
emitAudio(chunk: AudioChunk): void;
|
|
43
|
+
emitTranscript(role: "user" | "assistant", text: string, streamId?: string): void;
|
|
44
|
+
requestTool(call: ToolCall): void;
|
|
45
|
+
close(reason?: string): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AsyncQueue, AudioChunkSchema, PlayoutProgressSchema, ProviderContextEventSchema, ProviderEventSchema, ProviderToolResultSchema, } from "../protocol/index.js";
|
|
2
|
+
export * from "./replay-voice-provider.js";
|
|
3
|
+
export * from "./scripted-voice-provider.js";
|
|
4
|
+
export class FakeVoiceProvider {
|
|
5
|
+
id = "fake-duplex";
|
|
6
|
+
capabilities = {
|
|
7
|
+
continuousInputDuringOutput: true,
|
|
8
|
+
modelManagedFloor: true,
|
|
9
|
+
supportsToolCalls: true,
|
|
10
|
+
supportsAsyncContext: true,
|
|
11
|
+
supportsPlayoutAcknowledgements: true,
|
|
12
|
+
};
|
|
13
|
+
#options;
|
|
14
|
+
#session;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.#options = {
|
|
17
|
+
autoReady: options.autoReady ?? true,
|
|
18
|
+
echoInput: options.echoInput ?? false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
get session() {
|
|
22
|
+
if (!this.#session)
|
|
23
|
+
throw new Error("Fake provider session has not been created");
|
|
24
|
+
return this.#session;
|
|
25
|
+
}
|
|
26
|
+
async createSession(options) {
|
|
27
|
+
if (this.#session && !this.#session.closed) {
|
|
28
|
+
throw new Error("FakeVoiceProvider supports one active session per instance");
|
|
29
|
+
}
|
|
30
|
+
this.#session = new FakeVoiceProviderSession(options, this.#options);
|
|
31
|
+
return this.#session;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class FakeVoiceProviderSession {
|
|
35
|
+
receivedAudio = [];
|
|
36
|
+
receivedTexts = [];
|
|
37
|
+
playoutProgress = [];
|
|
38
|
+
toolResults = [];
|
|
39
|
+
contextEvents = [];
|
|
40
|
+
availableTools;
|
|
41
|
+
instructions;
|
|
42
|
+
outputModalities;
|
|
43
|
+
#queue = new AsyncQueue();
|
|
44
|
+
#options;
|
|
45
|
+
#outputSequence = 0;
|
|
46
|
+
#closed = false;
|
|
47
|
+
constructor(options, fakeOptions) {
|
|
48
|
+
this.#options = fakeOptions;
|
|
49
|
+
this.availableTools = options.tools;
|
|
50
|
+
this.instructions = options.instructions;
|
|
51
|
+
this.outputModalities = options.outputModalities;
|
|
52
|
+
options.signal.addEventListener("abort", () => {
|
|
53
|
+
void this.close(String(options.signal.reason ?? "aborted"));
|
|
54
|
+
}, { once: true });
|
|
55
|
+
if (this.#options.autoReady)
|
|
56
|
+
queueMicrotask(() => this.emit({ type: "ready" }));
|
|
57
|
+
}
|
|
58
|
+
get events() {
|
|
59
|
+
return this.#queue;
|
|
60
|
+
}
|
|
61
|
+
get closed() {
|
|
62
|
+
return this.#closed;
|
|
63
|
+
}
|
|
64
|
+
async sendAudio(chunk) {
|
|
65
|
+
const validated = AudioChunkSchema.parse(chunk);
|
|
66
|
+
this.receivedAudio.push(validated);
|
|
67
|
+
if (this.#options.echoInput) {
|
|
68
|
+
this.emit({
|
|
69
|
+
type: "audio.output",
|
|
70
|
+
chunk: {
|
|
71
|
+
...validated,
|
|
72
|
+
streamId: "fake-output",
|
|
73
|
+
sequence: this.#outputSequence++,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async sendText(text) {
|
|
79
|
+
this.receivedTexts.push(text);
|
|
80
|
+
if (this.#options.echoInput) {
|
|
81
|
+
this.emitTranscript("assistant", text);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async reportPlayout(progress) {
|
|
85
|
+
this.playoutProgress.push(PlayoutProgressSchema.parse(progress));
|
|
86
|
+
}
|
|
87
|
+
async sendToolResult(result) {
|
|
88
|
+
this.toolResults.push(ProviderToolResultSchema.parse(result));
|
|
89
|
+
}
|
|
90
|
+
async sendContext(event) {
|
|
91
|
+
this.contextEvents.push(ProviderContextEventSchema.parse(event));
|
|
92
|
+
}
|
|
93
|
+
emit(event) {
|
|
94
|
+
this.#queue.push(ProviderEventSchema.parse(event));
|
|
95
|
+
}
|
|
96
|
+
emitAudio(chunk) {
|
|
97
|
+
this.emit({ type: "audio.output", chunk: AudioChunkSchema.parse(chunk) });
|
|
98
|
+
}
|
|
99
|
+
emitTranscript(role, text, streamId) {
|
|
100
|
+
this.emit({ type: "transcript", role, text, ...(streamId === undefined ? {} : { streamId }) });
|
|
101
|
+
}
|
|
102
|
+
requestTool(call) {
|
|
103
|
+
this.emit({ type: "tool.call", call });
|
|
104
|
+
}
|
|
105
|
+
async close(reason = "fake_closed") {
|
|
106
|
+
if (this.#closed)
|
|
107
|
+
return;
|
|
108
|
+
this.#closed = true;
|
|
109
|
+
this.#queue.push({ type: "closed", reason });
|
|
110
|
+
this.#queue.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type AudioChunk, type HarnessEvent, type PlayoutProgress, type ProviderContextEvent, type ProviderEvent, type ProviderToolResult } from "../protocol/index.js";
|
|
2
|
+
import type { CreateProviderSessionOptions, VoiceProvider, VoiceProviderSession } from "../provider/index.js";
|
|
3
|
+
export interface ReplayVoiceProviderOptions {
|
|
4
|
+
/** Recorded harness events to replay. Takes precedence over load. */
|
|
5
|
+
events?: readonly HarnessEvent[];
|
|
6
|
+
/** Loads the recorded events when a session starts. */
|
|
7
|
+
load?: () => Promise<readonly HarnessEvent[]>;
|
|
8
|
+
/** realtime honors recorded sessionTimeMs spacing; instant replays without waiting. */
|
|
9
|
+
pacing?: "realtime" | "instant";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Replays the provider-visible half of a recorded session: model audio, tool requests,
|
|
13
|
+
* and provider errors, in recorded order and (optionally) recorded pacing. Input audio,
|
|
14
|
+
* playout acknowledgements, tool results, and context are accepted and captured but do
|
|
15
|
+
* not influence playback. This is the shadow-test primitive: re-drive UIs, tools, and
|
|
16
|
+
* stores from an authoritative log without a live model or an API key.
|
|
17
|
+
*/
|
|
18
|
+
export declare class ReplayVoiceProvider implements VoiceProvider {
|
|
19
|
+
#private;
|
|
20
|
+
readonly id = "replay";
|
|
21
|
+
readonly capabilities: {
|
|
22
|
+
readonly continuousInputDuringOutput: true;
|
|
23
|
+
readonly modelManagedFloor: true;
|
|
24
|
+
readonly supportsToolCalls: true;
|
|
25
|
+
readonly supportsAsyncContext: true;
|
|
26
|
+
readonly supportsPlayoutAcknowledgements: true;
|
|
27
|
+
};
|
|
28
|
+
constructor(options: ReplayVoiceProviderOptions);
|
|
29
|
+
createSession(options: CreateProviderSessionOptions): Promise<VoiceProviderSession>;
|
|
30
|
+
}
|
|
31
|
+
export declare class ReplayVoiceProviderSession implements VoiceProviderSession {
|
|
32
|
+
#private;
|
|
33
|
+
readonly receivedAudio: AudioChunk[];
|
|
34
|
+
readonly receivedTexts: string[];
|
|
35
|
+
readonly playoutProgress: PlayoutProgress[];
|
|
36
|
+
readonly toolResults: ProviderToolResult[];
|
|
37
|
+
readonly contextEvents: ProviderContextEvent[];
|
|
38
|
+
constructor(events: readonly HarnessEvent[], pacing: "realtime" | "instant", options: CreateProviderSessionOptions);
|
|
39
|
+
get events(): AsyncIterable<ProviderEvent>;
|
|
40
|
+
get closed(): boolean;
|
|
41
|
+
sendAudio(chunk: AudioChunk): Promise<void>;
|
|
42
|
+
sendText(text: string): Promise<void>;
|
|
43
|
+
reportPlayout(progress: PlayoutProgress): Promise<void>;
|
|
44
|
+
sendToolResult(result: ProviderToolResult): Promise<void>;
|
|
45
|
+
sendContext(event: ProviderContextEvent): Promise<void>;
|
|
46
|
+
close(reason?: string): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { AsyncQueue, AudioChunkSchema, PlayoutProgressSchema, ProviderContextEventSchema, ProviderToolResultSchema, } from "../protocol/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Replays the provider-visible half of a recorded session: model audio, tool requests,
|
|
4
|
+
* and provider errors, in recorded order and (optionally) recorded pacing. Input audio,
|
|
5
|
+
* playout acknowledgements, tool results, and context are accepted and captured but do
|
|
6
|
+
* not influence playback. This is the shadow-test primitive: re-drive UIs, tools, and
|
|
7
|
+
* stores from an authoritative log without a live model or an API key.
|
|
8
|
+
*/
|
|
9
|
+
export class ReplayVoiceProvider {
|
|
10
|
+
id = "replay";
|
|
11
|
+
capabilities = {
|
|
12
|
+
continuousInputDuringOutput: true,
|
|
13
|
+
modelManagedFloor: true,
|
|
14
|
+
supportsToolCalls: true,
|
|
15
|
+
supportsAsyncContext: true,
|
|
16
|
+
supportsPlayoutAcknowledgements: true,
|
|
17
|
+
};
|
|
18
|
+
#options;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
if (!options.events && !options.load) {
|
|
21
|
+
throw new Error("ReplayVoiceProvider needs recorded events or a load callback");
|
|
22
|
+
}
|
|
23
|
+
this.#options = options;
|
|
24
|
+
}
|
|
25
|
+
async createSession(options) {
|
|
26
|
+
const events = this.#options.events ?? (await this.#options.load?.()) ?? [];
|
|
27
|
+
return new ReplayVoiceProviderSession(events, this.#options.pacing ?? "realtime", options);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class ReplayVoiceProviderSession {
|
|
31
|
+
receivedAudio = [];
|
|
32
|
+
receivedTexts = [];
|
|
33
|
+
playoutProgress = [];
|
|
34
|
+
toolResults = [];
|
|
35
|
+
contextEvents = [];
|
|
36
|
+
#queue = new AsyncQueue();
|
|
37
|
+
#closed = false;
|
|
38
|
+
#cancelDelay;
|
|
39
|
+
constructor(events, pacing, options) {
|
|
40
|
+
options.signal.addEventListener("abort", () => {
|
|
41
|
+
void this.close(String(options.signal.reason ?? "aborted"));
|
|
42
|
+
}, { once: true });
|
|
43
|
+
void this.#run(events, pacing);
|
|
44
|
+
}
|
|
45
|
+
get events() {
|
|
46
|
+
return this.#queue;
|
|
47
|
+
}
|
|
48
|
+
get closed() {
|
|
49
|
+
return this.#closed;
|
|
50
|
+
}
|
|
51
|
+
async sendAudio(chunk) {
|
|
52
|
+
this.receivedAudio.push(AudioChunkSchema.parse(chunk));
|
|
53
|
+
}
|
|
54
|
+
async sendText(text) {
|
|
55
|
+
this.receivedTexts.push(text);
|
|
56
|
+
}
|
|
57
|
+
async reportPlayout(progress) {
|
|
58
|
+
this.playoutProgress.push(PlayoutProgressSchema.parse(progress));
|
|
59
|
+
}
|
|
60
|
+
async sendToolResult(result) {
|
|
61
|
+
this.toolResults.push(ProviderToolResultSchema.parse(result));
|
|
62
|
+
}
|
|
63
|
+
async sendContext(event) {
|
|
64
|
+
this.contextEvents.push(ProviderContextEventSchema.parse(event));
|
|
65
|
+
}
|
|
66
|
+
async close(reason = "replay_closed") {
|
|
67
|
+
if (this.#closed)
|
|
68
|
+
return;
|
|
69
|
+
this.#closed = true;
|
|
70
|
+
this.#cancelDelay?.();
|
|
71
|
+
this.#queue.push({ type: "closed", reason });
|
|
72
|
+
this.#queue.close();
|
|
73
|
+
}
|
|
74
|
+
async #run(events, pacing) {
|
|
75
|
+
this.#emit({ type: "ready" });
|
|
76
|
+
const replayable = events.filter(isReplayable);
|
|
77
|
+
const startTimeMs = replayable[0]?.sessionTimeMs ?? 0;
|
|
78
|
+
let elapsedMs = 0;
|
|
79
|
+
for (const event of replayable) {
|
|
80
|
+
if (pacing === "realtime") {
|
|
81
|
+
const waitMs = event.sessionTimeMs - startTimeMs - elapsedMs;
|
|
82
|
+
if (waitMs > 0) {
|
|
83
|
+
await this.#delay(waitMs);
|
|
84
|
+
elapsedMs += waitMs;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (this.#closed)
|
|
88
|
+
return;
|
|
89
|
+
this.#emit(toProviderEvent(event));
|
|
90
|
+
}
|
|
91
|
+
await this.close("replay_complete");
|
|
92
|
+
}
|
|
93
|
+
#emit(event) {
|
|
94
|
+
if (!this.#closed)
|
|
95
|
+
this.#queue.push(event);
|
|
96
|
+
}
|
|
97
|
+
#delay(ms) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
this.#cancelDelay = undefined;
|
|
101
|
+
resolve();
|
|
102
|
+
}, ms);
|
|
103
|
+
this.#cancelDelay = () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
this.#cancelDelay = undefined;
|
|
106
|
+
resolve();
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isReplayable(event) {
|
|
112
|
+
return (event.type === "audio.output" ||
|
|
113
|
+
event.type === "transcript" ||
|
|
114
|
+
event.type === "tool.requested" ||
|
|
115
|
+
event.type === "provider.error");
|
|
116
|
+
}
|
|
117
|
+
function toProviderEvent(event) {
|
|
118
|
+
switch (event.type) {
|
|
119
|
+
case "audio.output":
|
|
120
|
+
return { type: "audio.output", chunk: event.chunk };
|
|
121
|
+
case "transcript":
|
|
122
|
+
return {
|
|
123
|
+
type: "transcript",
|
|
124
|
+
role: event.role,
|
|
125
|
+
text: event.text,
|
|
126
|
+
...(event.streamId === undefined ? {} : { streamId: event.streamId }),
|
|
127
|
+
};
|
|
128
|
+
case "tool.requested":
|
|
129
|
+
return { type: "tool.call", call: event.call };
|
|
130
|
+
case "provider.error":
|
|
131
|
+
return { type: "error", message: event.message, recoverable: event.recoverable };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type AudioChunk, type OutputModality, type PlayoutProgress, type ProviderContextEvent, type ProviderEvent, type ProviderToolDefinition, type ProviderToolResult } from "../protocol/index.js";
|
|
2
|
+
import type { CreateProviderSessionOptions, VoiceProvider, VoiceProviderSession } from "../provider/index.js";
|
|
3
|
+
export type ScriptedVoiceStep = {
|
|
4
|
+
type: "emit";
|
|
5
|
+
event: ProviderEvent;
|
|
6
|
+
} | {
|
|
7
|
+
type: "expect.audio";
|
|
8
|
+
value: AudioChunk;
|
|
9
|
+
} | {
|
|
10
|
+
type: "expect.text";
|
|
11
|
+
value: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: "expect.playout";
|
|
14
|
+
value: PlayoutProgress;
|
|
15
|
+
} | {
|
|
16
|
+
type: "expect.tool_result";
|
|
17
|
+
value: ProviderToolResult;
|
|
18
|
+
} | {
|
|
19
|
+
type: "expect.context";
|
|
20
|
+
value: ProviderContextEvent;
|
|
21
|
+
};
|
|
22
|
+
export interface ScriptedVoiceProviderOptions {
|
|
23
|
+
steps: readonly ScriptedVoiceStep[];
|
|
24
|
+
/** Fails a script that is waiting for harness traffic. Defaults to two seconds. */
|
|
25
|
+
stepTimeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A closed-loop deterministic provider. Unlike recorded replay, scripts can stop after
|
|
29
|
+
* emitting a tool call and verify the exact result before the next model event appears.
|
|
30
|
+
*/
|
|
31
|
+
export declare class ScriptedVoiceProvider implements VoiceProvider {
|
|
32
|
+
#private;
|
|
33
|
+
readonly id = "scripted";
|
|
34
|
+
readonly capabilities: {
|
|
35
|
+
readonly continuousInputDuringOutput: true;
|
|
36
|
+
readonly modelManagedFloor: true;
|
|
37
|
+
readonly supportsToolCalls: true;
|
|
38
|
+
readonly supportsAsyncContext: true;
|
|
39
|
+
readonly supportsPlayoutAcknowledgements: true;
|
|
40
|
+
};
|
|
41
|
+
constructor(options: ScriptedVoiceProviderOptions);
|
|
42
|
+
get session(): ScriptedVoiceProviderSession;
|
|
43
|
+
createSession(options: CreateProviderSessionOptions): Promise<VoiceProviderSession>;
|
|
44
|
+
completed(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export declare class ScriptedVoiceProviderSession implements VoiceProviderSession {
|
|
47
|
+
#private;
|
|
48
|
+
readonly availableTools: readonly ProviderToolDefinition[];
|
|
49
|
+
readonly instructions: string | undefined;
|
|
50
|
+
readonly outputModalities: readonly OutputModality[] | undefined;
|
|
51
|
+
constructor(options: CreateProviderSessionOptions, script: Required<ScriptedVoiceProviderOptions>);
|
|
52
|
+
get events(): AsyncIterable<ProviderEvent>;
|
|
53
|
+
get closed(): boolean;
|
|
54
|
+
completed(): Promise<void>;
|
|
55
|
+
sendAudio(chunk: AudioChunk): Promise<void>;
|
|
56
|
+
sendText(text: string): Promise<void>;
|
|
57
|
+
reportPlayout(progress: PlayoutProgress): Promise<void>;
|
|
58
|
+
sendToolResult(result: ProviderToolResult): Promise<void>;
|
|
59
|
+
sendContext(event: ProviderContextEvent): Promise<void>;
|
|
60
|
+
close(reason?: string): Promise<void>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { AsyncQueue, AudioChunkSchema, PlayoutProgressSchema, ProviderContextEventSchema, ProviderEventSchema, ProviderToolResultSchema, } from "../protocol/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* A closed-loop deterministic provider. Unlike recorded replay, scripts can stop after
|
|
4
|
+
* emitting a tool call and verify the exact result before the next model event appears.
|
|
5
|
+
*/
|
|
6
|
+
export class ScriptedVoiceProvider {
|
|
7
|
+
id = "scripted";
|
|
8
|
+
capabilities = {
|
|
9
|
+
continuousInputDuringOutput: true,
|
|
10
|
+
modelManagedFloor: true,
|
|
11
|
+
supportsToolCalls: true,
|
|
12
|
+
supportsAsyncContext: true,
|
|
13
|
+
supportsPlayoutAcknowledgements: true,
|
|
14
|
+
};
|
|
15
|
+
#options;
|
|
16
|
+
#session;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.#options = {
|
|
19
|
+
steps: options.steps,
|
|
20
|
+
stepTimeoutMs: options.stepTimeoutMs ?? 2_000,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
get session() {
|
|
24
|
+
if (!this.#session)
|
|
25
|
+
throw new Error("Scripted provider session has not been created");
|
|
26
|
+
return this.#session;
|
|
27
|
+
}
|
|
28
|
+
async createSession(options) {
|
|
29
|
+
if (this.#session && !this.#session.closed) {
|
|
30
|
+
throw new Error("ScriptedVoiceProvider supports one active session per instance");
|
|
31
|
+
}
|
|
32
|
+
this.#session = new ScriptedVoiceProviderSession(options, this.#options);
|
|
33
|
+
return this.#session;
|
|
34
|
+
}
|
|
35
|
+
completed() {
|
|
36
|
+
return this.session.completed();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class ScriptedVoiceProviderSession {
|
|
40
|
+
availableTools;
|
|
41
|
+
instructions;
|
|
42
|
+
outputModalities;
|
|
43
|
+
#queue = new AsyncQueue();
|
|
44
|
+
#steps;
|
|
45
|
+
#stepTimeoutMs;
|
|
46
|
+
#completion;
|
|
47
|
+
#resolveCompletion = () => undefined;
|
|
48
|
+
#rejectCompletion = () => undefined;
|
|
49
|
+
#stepIndex = 0;
|
|
50
|
+
#stepTimer;
|
|
51
|
+
#closed = false;
|
|
52
|
+
constructor(options, script) {
|
|
53
|
+
this.availableTools = options.tools;
|
|
54
|
+
this.instructions = options.instructions;
|
|
55
|
+
this.outputModalities = options.outputModalities;
|
|
56
|
+
this.#steps = script.steps;
|
|
57
|
+
this.#stepTimeoutMs = script.stepTimeoutMs;
|
|
58
|
+
this.#completion = new Promise((resolve, reject) => {
|
|
59
|
+
this.#resolveCompletion = resolve;
|
|
60
|
+
this.#rejectCompletion = reject;
|
|
61
|
+
});
|
|
62
|
+
// A caller may choose to observe completion only through the harness event log.
|
|
63
|
+
void this.#completion.catch(() => undefined);
|
|
64
|
+
options.signal.addEventListener("abort", () => {
|
|
65
|
+
void this.close(String(options.signal.reason ?? "aborted"));
|
|
66
|
+
}, { once: true });
|
|
67
|
+
queueMicrotask(() => {
|
|
68
|
+
this.#queue.push({ type: "ready" });
|
|
69
|
+
this.#advance();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
get events() {
|
|
73
|
+
return this.#queue;
|
|
74
|
+
}
|
|
75
|
+
get closed() {
|
|
76
|
+
return this.#closed;
|
|
77
|
+
}
|
|
78
|
+
completed() {
|
|
79
|
+
return this.#completion;
|
|
80
|
+
}
|
|
81
|
+
async sendAudio(chunk) {
|
|
82
|
+
this.#accept("expect.audio", AudioChunkSchema.parse(chunk));
|
|
83
|
+
}
|
|
84
|
+
async sendText(text) {
|
|
85
|
+
this.#accept("expect.text", text);
|
|
86
|
+
}
|
|
87
|
+
async reportPlayout(progress) {
|
|
88
|
+
this.#accept("expect.playout", PlayoutProgressSchema.parse(progress));
|
|
89
|
+
}
|
|
90
|
+
async sendToolResult(result) {
|
|
91
|
+
this.#accept("expect.tool_result", ProviderToolResultSchema.parse(result));
|
|
92
|
+
}
|
|
93
|
+
async sendContext(event) {
|
|
94
|
+
this.#accept("expect.context", ProviderContextEventSchema.parse(event));
|
|
95
|
+
}
|
|
96
|
+
async close(reason = "scripted_closed") {
|
|
97
|
+
if (this.#closed)
|
|
98
|
+
return;
|
|
99
|
+
this.#closed = true;
|
|
100
|
+
this.#clearStepTimer();
|
|
101
|
+
if (this.#stepIndex < this.#steps.length) {
|
|
102
|
+
this.#rejectCompletion(new Error(`Script closed at step ${this.#stepIndex + 1}/${this.#steps.length}: ${reason}`));
|
|
103
|
+
}
|
|
104
|
+
this.#queue.close();
|
|
105
|
+
}
|
|
106
|
+
#accept(type, value) {
|
|
107
|
+
if (this.#closed)
|
|
108
|
+
throw new Error("Scripted provider is closed");
|
|
109
|
+
const step = this.#steps[this.#stepIndex];
|
|
110
|
+
if (!step || step.type !== type) {
|
|
111
|
+
const expected = step?.type ?? "end of script";
|
|
112
|
+
const error = new Error(`Script step ${this.#stepIndex + 1} expected ${expected}, received ${type}: ${formatValue(value)}`);
|
|
113
|
+
this.#fail(error);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (!deepEqual(value, step.value)) {
|
|
117
|
+
const error = new Error(`Script step ${this.#stepIndex + 1} failed: expected ${formatValue(step.value)}, received ${formatValue(value)}`);
|
|
118
|
+
this.#fail(error);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
this.#clearStepTimer();
|
|
122
|
+
this.#stepIndex += 1;
|
|
123
|
+
this.#advance();
|
|
124
|
+
}
|
|
125
|
+
#advance() {
|
|
126
|
+
while (!this.#closed) {
|
|
127
|
+
const step = this.#steps[this.#stepIndex];
|
|
128
|
+
if (!step) {
|
|
129
|
+
this.#closed = true;
|
|
130
|
+
this.#resolveCompletion();
|
|
131
|
+
this.#queue.push({ type: "closed", reason: "script_complete" });
|
|
132
|
+
this.#queue.close();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (step.type !== "emit") {
|
|
136
|
+
this.#armStepTimer(step.type);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.#queue.push(ProviderEventSchema.parse(step.event));
|
|
140
|
+
this.#stepIndex += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
#armStepTimer(type) {
|
|
144
|
+
this.#clearStepTimer();
|
|
145
|
+
this.#stepTimer = setTimeout(() => {
|
|
146
|
+
this.#fail(new Error(`Script stalled at step ${this.#stepIndex + 1}/${this.#steps.length}, waiting for ${type}`));
|
|
147
|
+
}, this.#stepTimeoutMs);
|
|
148
|
+
}
|
|
149
|
+
#clearStepTimer() {
|
|
150
|
+
if (this.#stepTimer)
|
|
151
|
+
clearTimeout(this.#stepTimer);
|
|
152
|
+
this.#stepTimer = undefined;
|
|
153
|
+
}
|
|
154
|
+
#fail(error) {
|
|
155
|
+
if (this.#closed)
|
|
156
|
+
return;
|
|
157
|
+
this.#closed = true;
|
|
158
|
+
this.#clearStepTimer();
|
|
159
|
+
this.#rejectCompletion(error);
|
|
160
|
+
this.#queue.push({ type: "error", message: error.message, recoverable: false });
|
|
161
|
+
this.#queue.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function deepEqual(left, right) {
|
|
165
|
+
if (Object.is(left, right))
|
|
166
|
+
return true;
|
|
167
|
+
if (left instanceof Uint8Array && right instanceof Uint8Array) {
|
|
168
|
+
return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
171
|
+
return left.length === right.length && left.every((value, index) => deepEqual(value, right[index]));
|
|
172
|
+
}
|
|
173
|
+
if (isRecord(left) && isRecord(right)) {
|
|
174
|
+
const leftKeys = Object.keys(left).sort();
|
|
175
|
+
const rightKeys = Object.keys(right).sort();
|
|
176
|
+
return deepEqual(leftKeys, rightKeys) && leftKeys.every((key) => deepEqual(left[key], right[key]));
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
function isRecord(value) {
|
|
181
|
+
return typeof value === "object" && value !== null;
|
|
182
|
+
}
|
|
183
|
+
function formatValue(value) {
|
|
184
|
+
return JSON.stringify(value, (_key, candidate) => candidate instanceof Uint8Array ? [...candidate] : candidate);
|
|
185
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes per-session append work onto one promise tail. `run` reserves the caller's
|
|
3
|
+
* position synchronously — before its first await — so appends enqueued back to back
|
|
4
|
+
* settle in call order even while earlier work is still awaiting. Rejected work rejects
|
|
5
|
+
* its caller but never poisons the tail.
|
|
6
|
+
*/
|
|
7
|
+
export declare class SessionAppendTails {
|
|
8
|
+
#private;
|
|
9
|
+
run<T>(sessionId: string, work: () => Promise<T>): Promise<T>;
|
|
10
|
+
/** Resolves once every append enqueued for the session so far has settled. */
|
|
11
|
+
settled(sessionId: string): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes per-session append work onto one promise tail. `run` reserves the caller's
|
|
3
|
+
* position synchronously — before its first await — so appends enqueued back to back
|
|
4
|
+
* settle in call order even while earlier work is still awaiting. Rejected work rejects
|
|
5
|
+
* its caller but never poisons the tail.
|
|
6
|
+
*/
|
|
7
|
+
export class SessionAppendTails {
|
|
8
|
+
#tails = new Map();
|
|
9
|
+
run(sessionId, work) {
|
|
10
|
+
const previousTail = this.#tails.get(sessionId) ?? Promise.resolve();
|
|
11
|
+
const result = previousTail.then(work);
|
|
12
|
+
this.#tails.set(sessionId, result.then(() => undefined, () => undefined));
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
/** Resolves once every append enqueued for the session so far has settled. */
|
|
16
|
+
settled(sessionId) {
|
|
17
|
+
return this.#tails.get(sessionId) ?? Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HarnessEvent, UnsequencedHarnessEvent } from "../protocol/index.js";
|
|
2
|
+
export interface EventStore {
|
|
3
|
+
/**
|
|
4
|
+
* Durably appends one event to the session log.
|
|
5
|
+
*
|
|
6
|
+
* Implementations must reserve the event's position in the session's serialized append
|
|
7
|
+
* tail synchronously (before their first await) and settle appends in that order —
|
|
8
|
+
* SessionAppendTails is the canonical way to honor this. The harness pipelines media
|
|
9
|
+
* records against this guarantee: it forwards audio while the append is still settling,
|
|
10
|
+
* relying on enqueue order to keep the log causal.
|
|
11
|
+
*/
|
|
12
|
+
append(event: UnsequencedHarnessEvent): Promise<HarnessEvent>;
|
|
13
|
+
/**
|
|
14
|
+
* Durability barrier for every append enqueued for the session before this call.
|
|
15
|
+
* Implementations must reserve the barrier in the same serialized tail as append.
|
|
16
|
+
*/
|
|
17
|
+
flush(sessionId: string): Promise<void>;
|
|
18
|
+
list(sessionId: string): Promise<readonly HarnessEvent[]>;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type HarnessEvent, type UnsequencedHarnessEvent } from "../protocol/index.js";
|
|
2
|
+
import type { EventStore } from "./event-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Local JSONL persistence for one harness process. Cross-process writer fencing and
|
|
5
|
+
* directory fsync for newly published session files are deferred to the resume slice.
|
|
6
|
+
*/
|
|
7
|
+
export declare class JsonlEventStore implements EventStore {
|
|
8
|
+
#private;
|
|
9
|
+
constructor(rootDirectory: string);
|
|
10
|
+
append(event: UnsequencedHarnessEvent): Promise<HarnessEvent>;
|
|
11
|
+
flush(sessionId: string): Promise<void>;
|
|
12
|
+
list(sessionId: string): Promise<readonly HarnessEvent[]>;
|
|
13
|
+
}
|