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
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# xo-harness
|
|
2
|
+
|
|
3
|
+
`xo-harness` is a TypeScript-first agent harness for continuous, fully duplex voice models. One package
|
|
4
|
+
contains the harness, provider adapters, durable stores, shared protocol, and deterministic testing
|
|
5
|
+
providers.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install xo-harness
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { XO } from "xo-harness/harness";
|
|
13
|
+
import { OpenAIRealtimeVoiceProvider } from "xo-harness/provider";
|
|
14
|
+
import { JsonlEventStore } from "xo-harness/storage";
|
|
15
|
+
|
|
16
|
+
const harness = new XO({ store: new JsonlEventStore(".xo/sessions") });
|
|
17
|
+
const session = await harness.startSession({
|
|
18
|
+
provider: new OpenAIRealtimeVoiceProvider({ apiKey: process.env.OPENAI_API_KEY! }),
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Additional entry points are available for focused imports:
|
|
23
|
+
|
|
24
|
+
- `xo-harness/protocol`
|
|
25
|
+
- `xo-harness/harness`
|
|
26
|
+
- `xo-harness/provider`
|
|
27
|
+
- `xo-harness/provider/workers`
|
|
28
|
+
- `xo-harness/storage`
|
|
29
|
+
- `xo-harness/testing`
|
|
30
|
+
|
|
31
|
+
See the [XO repository](https://github.com/zvadaadam/XO) for the complete documentation and voice
|
|
32
|
+
application.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./internal/harness/index.js";
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./internal/harness/index.js";
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AsyncQueue } from "../protocol/index.js";
|
|
2
|
+
export class ReplayEventStream {
|
|
3
|
+
#history = [];
|
|
4
|
+
#subscribers = new Set();
|
|
5
|
+
#closed = false;
|
|
6
|
+
publish(event) {
|
|
7
|
+
if (this.#closed)
|
|
8
|
+
throw new Error("Cannot publish after the event stream is closed");
|
|
9
|
+
this.#history.push(event);
|
|
10
|
+
for (const subscriber of this.#subscribers)
|
|
11
|
+
subscriber.push(event);
|
|
12
|
+
}
|
|
13
|
+
subscribe(afterSequence = 0) {
|
|
14
|
+
const queue = new AsyncQueue();
|
|
15
|
+
const backlog = this.#history.filter((event) => event.sequence > afterSequence);
|
|
16
|
+
for (const event of backlog)
|
|
17
|
+
queue.push(event);
|
|
18
|
+
if (this.#closed)
|
|
19
|
+
queue.close();
|
|
20
|
+
else
|
|
21
|
+
this.#subscribers.add(queue);
|
|
22
|
+
const subscribers = this.#subscribers;
|
|
23
|
+
return {
|
|
24
|
+
async *[Symbol.asyncIterator]() {
|
|
25
|
+
try {
|
|
26
|
+
yield* queue;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
subscribers.delete(queue);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
close() {
|
|
35
|
+
if (this.#closed)
|
|
36
|
+
return;
|
|
37
|
+
this.#closed = true;
|
|
38
|
+
for (const subscriber of this.#subscribers)
|
|
39
|
+
subscriber.close();
|
|
40
|
+
this.#subscribers.clear();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HarnessEvent, Message } from "../protocol/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Projects the flat, sequence-ordered event log into the v2 `Message[]` part model —
|
|
4
|
+
* the shape every agent harness renders and persists (opencode / Vercel AI SDK v5 /
|
|
5
|
+
* agent-server / echo). Pure AND deterministic over the recorded events: ids derive
|
|
6
|
+
* from the opening event, so the same log always projects to the same identities —
|
|
7
|
+
* re-folds diff cleanly, upserts stay idempotent, and renderers can key on part ids.
|
|
8
|
+
*
|
|
9
|
+
* Folding rules:
|
|
10
|
+
* - `message.input` → a user message; its recorded `parts` project with full fidelity
|
|
11
|
+
* (image/file parts become `file` parts), a bare-text input becomes one text part.
|
|
12
|
+
* - user `transcript` → a user message with one text part.
|
|
13
|
+
* - assistant `transcript.delta` → a `streaming` text part accumulating deltas,
|
|
14
|
+
* finalized in place by the matching `transcript` (streamId-correlated, so a
|
|
15
|
+
* barge-in interrupted stream still finalizes into its original part).
|
|
16
|
+
* - `audio.output` → an `audio` part on the current assistant message (deduped per
|
|
17
|
+
* streamId), linked to its transcript text part by that streamId.
|
|
18
|
+
* - `tool.requested` → ONE `tool` part in `running` state; `tool.completed`/`tool.failed`
|
|
19
|
+
* settle it with the canonical outcome, and a task terminal upgrades an
|
|
20
|
+
* `accepted_task` outcome to the task's real result. This is why a tool result
|
|
21
|
+
* (e.g. a crisis card) replays from history: the outcome lives on the part.
|
|
22
|
+
*
|
|
23
|
+
* A tool part belongs to the assistant message open when it was requested; a new
|
|
24
|
+
* assistant transcript or user turn opens the next message.
|
|
25
|
+
*/
|
|
26
|
+
export declare function projectMessages(events: readonly HarnessEvent[]): Message[];
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects the flat, sequence-ordered event log into the v2 `Message[]` part model —
|
|
3
|
+
* the shape every agent harness renders and persists (opencode / Vercel AI SDK v5 /
|
|
4
|
+
* agent-server / echo). Pure AND deterministic over the recorded events: ids derive
|
|
5
|
+
* from the opening event, so the same log always projects to the same identities —
|
|
6
|
+
* re-folds diff cleanly, upserts stay idempotent, and renderers can key on part ids.
|
|
7
|
+
*
|
|
8
|
+
* Folding rules:
|
|
9
|
+
* - `message.input` → a user message; its recorded `parts` project with full fidelity
|
|
10
|
+
* (image/file parts become `file` parts), a bare-text input becomes one text part.
|
|
11
|
+
* - user `transcript` → a user message with one text part.
|
|
12
|
+
* - assistant `transcript.delta` → a `streaming` text part accumulating deltas,
|
|
13
|
+
* finalized in place by the matching `transcript` (streamId-correlated, so a
|
|
14
|
+
* barge-in interrupted stream still finalizes into its original part).
|
|
15
|
+
* - `audio.output` → an `audio` part on the current assistant message (deduped per
|
|
16
|
+
* streamId), linked to its transcript text part by that streamId.
|
|
17
|
+
* - `tool.requested` → ONE `tool` part in `running` state; `tool.completed`/`tool.failed`
|
|
18
|
+
* settle it with the canonical outcome, and a task terminal upgrades an
|
|
19
|
+
* `accepted_task` outcome to the task's real result. This is why a tool result
|
|
20
|
+
* (e.g. a crisis card) replays from history: the outcome lives on the part.
|
|
21
|
+
*
|
|
22
|
+
* A tool part belongs to the assistant message open when it was requested; a new
|
|
23
|
+
* assistant transcript or user turn opens the next message.
|
|
24
|
+
*/
|
|
25
|
+
export function projectMessages(events) {
|
|
26
|
+
const started = events.find((event) => event.type === "session.started");
|
|
27
|
+
const sessionId = started?.sessionId ?? events[0]?.sessionId ?? "unknown";
|
|
28
|
+
const messages = [];
|
|
29
|
+
let current;
|
|
30
|
+
// callId → the tool part being advanced, so settlement updates the same part.
|
|
31
|
+
const toolParts = new Map();
|
|
32
|
+
// streamId → the assistant message + audio part, so repeated chunks fold into one.
|
|
33
|
+
const audioParts = new Map();
|
|
34
|
+
// stream key → the streaming text part accumulating deltas, finalized by `transcript`.
|
|
35
|
+
// Deltas without a streamId key on the open assistant message, so a new message
|
|
36
|
+
// naturally starts a new default stream — no sentinel, no clearing.
|
|
37
|
+
const textParts = new Map();
|
|
38
|
+
// Identity is the opening event: message id = event id, part id = event id + ordinal.
|
|
39
|
+
const partBase = (message, event, ordinal = 0) => ({
|
|
40
|
+
id: `${event.id}/p${ordinal}`,
|
|
41
|
+
sessionId,
|
|
42
|
+
messageId: message.id,
|
|
43
|
+
});
|
|
44
|
+
const openMessage = (role, event) => {
|
|
45
|
+
current = { id: event.id, sessionId, role, createdAtMs: event.recordedAtMs, parts: [] };
|
|
46
|
+
messages.push(current);
|
|
47
|
+
return current;
|
|
48
|
+
};
|
|
49
|
+
const assistantMessage = (event) => {
|
|
50
|
+
if (current?.role === "assistant")
|
|
51
|
+
return current;
|
|
52
|
+
return openMessage("assistant", event);
|
|
53
|
+
};
|
|
54
|
+
for (const event of events) {
|
|
55
|
+
switch (event.type) {
|
|
56
|
+
case "message.input": {
|
|
57
|
+
const message = openMessage("user", event);
|
|
58
|
+
for (const [ordinal, part] of (event.parts ?? [{ type: "text", text: event.text }]).entries()) {
|
|
59
|
+
message.parts.push(part.type === "text"
|
|
60
|
+
? { ...partBase(message, event, ordinal), type: "text", text: part.text, state: "done" }
|
|
61
|
+
: {
|
|
62
|
+
...partBase(message, event, ordinal),
|
|
63
|
+
type: "file",
|
|
64
|
+
mediaType: part.mediaType,
|
|
65
|
+
url: part.url,
|
|
66
|
+
...(part.type === "file" && part.filename !== undefined ? { filename: part.filename } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "transcript.delta": {
|
|
72
|
+
if (event.role !== "assistant")
|
|
73
|
+
break;
|
|
74
|
+
const message = assistantMessage(event);
|
|
75
|
+
const key = event.streamId ?? message.id;
|
|
76
|
+
const existing = textParts.get(key);
|
|
77
|
+
if (existing) {
|
|
78
|
+
existing.text += event.delta;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const part = {
|
|
82
|
+
...partBase(message, event),
|
|
83
|
+
type: "text",
|
|
84
|
+
text: event.delta,
|
|
85
|
+
state: "streaming",
|
|
86
|
+
};
|
|
87
|
+
textParts.set(key, part);
|
|
88
|
+
message.parts.push(part);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "transcript": {
|
|
92
|
+
if (event.role === "user") {
|
|
93
|
+
const message = openMessage("user", event);
|
|
94
|
+
message.parts.push({ ...partBase(message, event), type: "text", text: event.text, state: "done" });
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
// Finalize the streaming part these deltas were building, if any — even when a
|
|
98
|
+
// barge-in already moved the conversation on — else record a fresh done part.
|
|
99
|
+
const key = event.streamId ?? (current?.role === "assistant" ? current.id : undefined);
|
|
100
|
+
const streaming = key === undefined ? undefined : textParts.get(key);
|
|
101
|
+
if (key !== undefined && streaming !== undefined) {
|
|
102
|
+
streaming.text = event.text;
|
|
103
|
+
streaming.state = "done";
|
|
104
|
+
textParts.delete(key);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const message = assistantMessage(event);
|
|
108
|
+
message.parts.push({ ...partBase(message, event), type: "text", text: event.text, state: "done" });
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "audio.output": {
|
|
112
|
+
const streamId = event.chunk.streamId;
|
|
113
|
+
if (audioParts.has(streamId))
|
|
114
|
+
break;
|
|
115
|
+
const message = assistantMessage(event);
|
|
116
|
+
const part = {
|
|
117
|
+
...partBase(message, event),
|
|
118
|
+
type: "audio",
|
|
119
|
+
streamId,
|
|
120
|
+
sampleRate: event.chunk.sampleRate,
|
|
121
|
+
state: "done",
|
|
122
|
+
};
|
|
123
|
+
audioParts.set(streamId, part);
|
|
124
|
+
message.parts.push(part);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case "tool.requested": {
|
|
128
|
+
if (toolParts.has(event.call.callId))
|
|
129
|
+
break;
|
|
130
|
+
const message = assistantMessage(event);
|
|
131
|
+
const part = {
|
|
132
|
+
...partBase(message, event),
|
|
133
|
+
type: "tool",
|
|
134
|
+
toolCallId: event.call.callId,
|
|
135
|
+
toolName: event.call.name,
|
|
136
|
+
state: { status: "running", input: event.call.arguments, startedAtMs: event.recordedAtMs },
|
|
137
|
+
};
|
|
138
|
+
toolParts.set(event.call.callId, part);
|
|
139
|
+
message.parts.push(part);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "tool.completed": {
|
|
143
|
+
const part = toolParts.get(event.callId);
|
|
144
|
+
if (part?.state.status !== "running")
|
|
145
|
+
break;
|
|
146
|
+
part.state = {
|
|
147
|
+
status: "settled",
|
|
148
|
+
input: part.state.input,
|
|
149
|
+
outcome: event.outcome,
|
|
150
|
+
startedAtMs: part.state.startedAtMs,
|
|
151
|
+
endedAtMs: event.recordedAtMs,
|
|
152
|
+
};
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "tool.failed": {
|
|
156
|
+
const part = toolParts.get(event.callId);
|
|
157
|
+
if (part?.state.status !== "running")
|
|
158
|
+
break;
|
|
159
|
+
part.state = {
|
|
160
|
+
status: "settled",
|
|
161
|
+
input: part.state.input,
|
|
162
|
+
outcome: { type: "failed", error: event.error },
|
|
163
|
+
startedAtMs: part.state.startedAtMs,
|
|
164
|
+
endedAtMs: event.recordedAtMs,
|
|
165
|
+
};
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "task.completed":
|
|
169
|
+
case "task.failed":
|
|
170
|
+
case "task.cancelled": {
|
|
171
|
+
// A background task settling upgrades its tool part's accepted_task handoff to
|
|
172
|
+
// the real result, so task-shaped tools replay from history like direct ones.
|
|
173
|
+
const part = toolParts.get(event.callId);
|
|
174
|
+
if (part?.state.status !== "settled" || part.state.outcome.type !== "accepted_task")
|
|
175
|
+
break;
|
|
176
|
+
part.state = {
|
|
177
|
+
...part.state,
|
|
178
|
+
outcome: event.type === "task.completed"
|
|
179
|
+
? { type: "completed", value: event.result }
|
|
180
|
+
: event.type === "task.failed"
|
|
181
|
+
? { type: "failed", error: event.error }
|
|
182
|
+
: { type: "failed", error: `Task cancelled: ${event.reason}` },
|
|
183
|
+
endedAtMs: event.recordedAtMs,
|
|
184
|
+
};
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return messages;
|
|
192
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type HarnessEvent, type ToolOutcome } from "../protocol/index.js";
|
|
2
|
+
export interface ToolCallReport {
|
|
3
|
+
callId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
arguments: Record<string, unknown>;
|
|
6
|
+
requestedAtMs: number;
|
|
7
|
+
outcome?: ToolOutcome;
|
|
8
|
+
latencyMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface TranscriptLine {
|
|
11
|
+
role: "user" | "assistant";
|
|
12
|
+
text: string;
|
|
13
|
+
atMs: number;
|
|
14
|
+
}
|
|
15
|
+
/** Session token totals summed over provider usage reports; zeros when none arrived. */
|
|
16
|
+
export interface UsageTotals {
|
|
17
|
+
responses: number;
|
|
18
|
+
inputTokens: number;
|
|
19
|
+
outputTokens: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
cachedInputTokens: number;
|
|
22
|
+
audioInputTokens: number;
|
|
23
|
+
audioOutputTokens: number;
|
|
24
|
+
cachedAudioInputTokens: number;
|
|
25
|
+
}
|
|
26
|
+
export interface SessionReport {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
providerId: string;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
endedReason?: string;
|
|
31
|
+
readyAtMs?: number;
|
|
32
|
+
firstInputAtMs?: number;
|
|
33
|
+
firstOutputAtMs?: number;
|
|
34
|
+
/** First model audio relative to first microphone audio. */
|
|
35
|
+
responseLatencyMs?: number;
|
|
36
|
+
inputAudioMs: number;
|
|
37
|
+
outputAudioMs: number;
|
|
38
|
+
toolCalls: ToolCallReport[];
|
|
39
|
+
transcripts: TranscriptLine[];
|
|
40
|
+
taskIds: string[];
|
|
41
|
+
errors: {
|
|
42
|
+
message: string;
|
|
43
|
+
recoverable: boolean;
|
|
44
|
+
}[];
|
|
45
|
+
usage: UsageTotals;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Derives latency and outcome metrics from a session log. Pure over the recorded events,
|
|
49
|
+
* so it works identically on live sessions, replays, and shadow runs.
|
|
50
|
+
*/
|
|
51
|
+
export declare function summarizeSession(events: readonly HarnessEvent[]): SessionReport;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { audioSampleCount } from "../protocol/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Derives latency and outcome metrics from a session log. Pure over the recorded events,
|
|
4
|
+
* so it works identically on live sessions, replays, and shadow runs.
|
|
5
|
+
*/
|
|
6
|
+
export function summarizeSession(events) {
|
|
7
|
+
const started = events.find((event) => event.type === "session.started");
|
|
8
|
+
if (!started)
|
|
9
|
+
throw new Error("Session log has no session.started event");
|
|
10
|
+
let endedReason;
|
|
11
|
+
let readyAtMs;
|
|
12
|
+
let firstInputAtMs;
|
|
13
|
+
let firstOutputAtMs;
|
|
14
|
+
let inputAudioMs = 0;
|
|
15
|
+
let outputAudioMs = 0;
|
|
16
|
+
const toolCalls = new Map();
|
|
17
|
+
const transcripts = [];
|
|
18
|
+
const taskIds = new Set();
|
|
19
|
+
const errors = [];
|
|
20
|
+
const usage = {
|
|
21
|
+
responses: 0,
|
|
22
|
+
inputTokens: 0,
|
|
23
|
+
outputTokens: 0,
|
|
24
|
+
totalTokens: 0,
|
|
25
|
+
cachedInputTokens: 0,
|
|
26
|
+
audioInputTokens: 0,
|
|
27
|
+
audioOutputTokens: 0,
|
|
28
|
+
cachedAudioInputTokens: 0,
|
|
29
|
+
};
|
|
30
|
+
for (const event of events) {
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case "provider.ready":
|
|
33
|
+
readyAtMs ??= event.sessionTimeMs;
|
|
34
|
+
break;
|
|
35
|
+
case "usage":
|
|
36
|
+
usage.responses += 1;
|
|
37
|
+
usage.inputTokens += event.inputTokens;
|
|
38
|
+
usage.outputTokens += event.outputTokens;
|
|
39
|
+
usage.totalTokens += event.totalTokens;
|
|
40
|
+
usage.cachedInputTokens += event.cachedInputTokens ?? 0;
|
|
41
|
+
usage.audioInputTokens += event.audioInputTokens ?? 0;
|
|
42
|
+
usage.audioOutputTokens += event.audioOutputTokens ?? 0;
|
|
43
|
+
usage.cachedAudioInputTokens += event.cachedAudioInputTokens ?? 0;
|
|
44
|
+
break;
|
|
45
|
+
case "audio.input":
|
|
46
|
+
firstInputAtMs ??= event.sessionTimeMs;
|
|
47
|
+
inputAudioMs += (audioSampleCount(event.chunk) / event.chunk.sampleRate) * 1000;
|
|
48
|
+
break;
|
|
49
|
+
case "audio.output":
|
|
50
|
+
firstOutputAtMs ??= event.sessionTimeMs;
|
|
51
|
+
outputAudioMs += (audioSampleCount(event.chunk) / event.chunk.sampleRate) * 1000;
|
|
52
|
+
break;
|
|
53
|
+
case "transcript":
|
|
54
|
+
transcripts.push({ role: event.role, text: event.text, atMs: event.sessionTimeMs });
|
|
55
|
+
break;
|
|
56
|
+
case "message.input":
|
|
57
|
+
transcripts.push({ role: "user", text: event.text, atMs: event.sessionTimeMs });
|
|
58
|
+
break;
|
|
59
|
+
case "tool.requested":
|
|
60
|
+
toolCalls.set(event.call.callId, {
|
|
61
|
+
callId: event.call.callId,
|
|
62
|
+
name: event.call.name,
|
|
63
|
+
arguments: event.call.arguments,
|
|
64
|
+
requestedAtMs: event.sessionTimeMs,
|
|
65
|
+
});
|
|
66
|
+
break;
|
|
67
|
+
case "tool.completed": {
|
|
68
|
+
const call = toolCalls.get(event.callId);
|
|
69
|
+
if (call) {
|
|
70
|
+
call.outcome = event.outcome;
|
|
71
|
+
call.latencyMs = event.sessionTimeMs - call.requestedAtMs;
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "tool.failed": {
|
|
76
|
+
const call = toolCalls.get(event.callId);
|
|
77
|
+
if (call) {
|
|
78
|
+
call.outcome = { type: "failed", error: event.error };
|
|
79
|
+
call.latencyMs = event.sessionTimeMs - call.requestedAtMs;
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "task.accepted":
|
|
84
|
+
taskIds.add(event.taskId);
|
|
85
|
+
break;
|
|
86
|
+
case "provider.error":
|
|
87
|
+
errors.push({ message: event.message, recoverable: event.recoverable });
|
|
88
|
+
break;
|
|
89
|
+
case "session.ended":
|
|
90
|
+
endedReason = event.reason;
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
sessionId: started.sessionId,
|
|
98
|
+
providerId: started.providerId,
|
|
99
|
+
durationMs: events.at(-1)?.sessionTimeMs ?? 0,
|
|
100
|
+
inputAudioMs: Math.round(inputAudioMs),
|
|
101
|
+
outputAudioMs: Math.round(outputAudioMs),
|
|
102
|
+
toolCalls: [...toolCalls.values()],
|
|
103
|
+
transcripts,
|
|
104
|
+
taskIds: [...taskIds],
|
|
105
|
+
usage,
|
|
106
|
+
errors,
|
|
107
|
+
...(endedReason === undefined ? {} : { endedReason }),
|
|
108
|
+
...(readyAtMs === undefined ? {} : { readyAtMs }),
|
|
109
|
+
...(firstInputAtMs === undefined ? {} : { firstInputAtMs }),
|
|
110
|
+
...(firstOutputAtMs === undefined ? {} : { firstOutputAtMs }),
|
|
111
|
+
...(firstInputAtMs === undefined || firstOutputAtMs === undefined
|
|
112
|
+
? {}
|
|
113
|
+
: { responseLatencyMs: firstOutputAtMs - firstInputAtMs }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HarnessEvent } from "../protocol/index.js";
|
|
2
|
+
import type { VoiceProvider } from "../provider/index.js";
|
|
3
|
+
import { type EventStore } from "../storage/index.js";
|
|
4
|
+
import { type SessionReport } from "./report.js";
|
|
5
|
+
import type { VoiceTool } from "./tools.js";
|
|
6
|
+
export interface ShadowRunOptions {
|
|
7
|
+
/** The recorded session whose microphone audio is re-driven. */
|
|
8
|
+
events: readonly HarnessEvent[];
|
|
9
|
+
/** The provider to shadow-test with the recorded input. */
|
|
10
|
+
provider: VoiceProvider;
|
|
11
|
+
/** Tools to expose; usually the same implementations the original session had. */
|
|
12
|
+
tools?: readonly VoiceTool[];
|
|
13
|
+
/** Where the shadow session records. Defaults to an in-memory store. */
|
|
14
|
+
store?: EventStore;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
/** Defaults to the recorded session's instructions. */
|
|
17
|
+
instructions?: string;
|
|
18
|
+
/** The run settles once the provider has produced audio and then stayed quiet this long. */
|
|
19
|
+
quietMs?: number;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The silent-test primitive: re-drives a recorded session's input audio against another
|
|
24
|
+
* provider and returns the resulting session report for comparison with the original.
|
|
25
|
+
* Input is sent as fast as the provider accepts it; server-side VAD sees the recorded
|
|
26
|
+
* speech and trailing silence exactly as the original session did.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runShadowSession(options: ShadowRunOptions): Promise<SessionReport>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { MemoryEventStore } from "../storage/index.js";
|
|
2
|
+
import { summarizeSession } from "./report.js";
|
|
3
|
+
import { XO } from "./xo.js";
|
|
4
|
+
/**
|
|
5
|
+
* The silent-test primitive: re-drives a recorded session's input audio against another
|
|
6
|
+
* provider and returns the resulting session report for comparison with the original.
|
|
7
|
+
* Input is sent as fast as the provider accepts it; server-side VAD sees the recorded
|
|
8
|
+
* speech and trailing silence exactly as the original session did.
|
|
9
|
+
*/
|
|
10
|
+
export async function runShadowSession(options) {
|
|
11
|
+
const recordedStart = options.events.find((event) => event.type === "session.started");
|
|
12
|
+
const inputs = options.events.filter((event) => event.type === "audio.input");
|
|
13
|
+
if (inputs.length === 0)
|
|
14
|
+
throw new Error("Recorded session has no input audio to re-drive");
|
|
15
|
+
const instructions = options.instructions ?? recordedStart?.instructions;
|
|
16
|
+
const quietMs = options.quietMs ?? 3_000;
|
|
17
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
18
|
+
const harness = new XO({
|
|
19
|
+
store: options.store ?? new MemoryEventStore(),
|
|
20
|
+
...(options.tools === undefined ? {} : { tools: options.tools }),
|
|
21
|
+
});
|
|
22
|
+
const session = await harness.startSession({
|
|
23
|
+
provider: options.provider,
|
|
24
|
+
maxDurationMs: timeoutMs,
|
|
25
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
26
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
await waitForReady(session, timeoutMs);
|
|
30
|
+
for (const input of inputs) {
|
|
31
|
+
await session.sendAudio(input.chunk);
|
|
32
|
+
}
|
|
33
|
+
await settleAfterOutput(session, quietMs, timeoutMs);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await session.close("shadow_complete");
|
|
37
|
+
}
|
|
38
|
+
return summarizeSession(await session.history());
|
|
39
|
+
}
|
|
40
|
+
function createEventCursor(session) {
|
|
41
|
+
const iterator = session.events()[Symbol.asyncIterator]();
|
|
42
|
+
// A raced-and-lost next() must be reused, not abandoned: an abandoned call would
|
|
43
|
+
// consume the very event the following next() is waiting for.
|
|
44
|
+
let pendingNext;
|
|
45
|
+
return {
|
|
46
|
+
async next(timeoutMs) {
|
|
47
|
+
pendingNext ??= iterator.next();
|
|
48
|
+
const result = await Promise.race([pendingNext, delay(timeoutMs).then(() => "timeout")]);
|
|
49
|
+
if (result === "timeout")
|
|
50
|
+
return "timeout";
|
|
51
|
+
pendingNext = undefined;
|
|
52
|
+
return result.done ? "done" : result.value;
|
|
53
|
+
},
|
|
54
|
+
dispose() {
|
|
55
|
+
// Not awaited: with a next() in flight, an async generator's return() settles only
|
|
56
|
+
// after that next() does — which here means only once the session closes.
|
|
57
|
+
void iterator.return?.();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function waitForReady(session, timeoutMs) {
|
|
62
|
+
const cursor = createEventCursor(session);
|
|
63
|
+
const deadline = Date.now() + timeoutMs;
|
|
64
|
+
try {
|
|
65
|
+
while (true) {
|
|
66
|
+
const remaining = deadline - Date.now();
|
|
67
|
+
if (remaining <= 0)
|
|
68
|
+
throw new Error("Shadow provider did not become ready in time");
|
|
69
|
+
const event = await cursor.next(remaining);
|
|
70
|
+
if (event === "timeout")
|
|
71
|
+
throw new Error("Shadow provider did not become ready in time");
|
|
72
|
+
if (event === "done")
|
|
73
|
+
throw new Error("Shadow session ended before the provider was ready");
|
|
74
|
+
if (event.type === "provider.ready")
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
cursor.dispose();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function settleAfterOutput(session, quietMs, timeoutMs) {
|
|
83
|
+
const cursor = createEventCursor(session);
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
let lastActivity = Date.now();
|
|
86
|
+
let sawOutput = false;
|
|
87
|
+
try {
|
|
88
|
+
while (true) {
|
|
89
|
+
const untilDeadline = deadline - Date.now();
|
|
90
|
+
if (untilDeadline <= 0)
|
|
91
|
+
return;
|
|
92
|
+
const untilQuiet = quietMs - (Date.now() - lastActivity);
|
|
93
|
+
if (sawOutput && untilQuiet <= 0)
|
|
94
|
+
return;
|
|
95
|
+
const wait = Math.max(10, sawOutput ? Math.min(untilQuiet, untilDeadline) : untilDeadline);
|
|
96
|
+
const event = await cursor.next(wait);
|
|
97
|
+
if (event === "timeout")
|
|
98
|
+
continue;
|
|
99
|
+
if (event === "done")
|
|
100
|
+
return;
|
|
101
|
+
const type = event.type;
|
|
102
|
+
if (type === "audio.output" ||
|
|
103
|
+
type === "transcript" ||
|
|
104
|
+
type === "tool.requested" ||
|
|
105
|
+
type === "tool.completed") {
|
|
106
|
+
lastActivity = Date.now();
|
|
107
|
+
if (type === "audio.output")
|
|
108
|
+
sawOutput = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
cursor.dispose();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function delay(ms) {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
118
|
+
}
|