@kuralle-syrinx/realtime 3.1.0 → 4.0.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 +38 -1
- package/package.json +3 -3
- package/src/from-gemini-live.test.ts +30 -0
- package/src/from-gemini-live.ts +20 -0
- package/src/from-openai-realtime.test.ts +203 -0
- package/src/from-openai-realtime.ts +8 -1
- package/src/index.ts +11 -2
- package/src/openai-compatible-realtime.ts +60 -4
- package/src/realtime-adapter.ts +16 -0
- package/src/realtime-bridge.test.ts +267 -2
- package/src/realtime-bridge.ts +121 -9
package/README.md
CHANGED
|
@@ -105,6 +105,41 @@ Cartesia, …) all dial through `createWorkersSocket` so auth headers ride on th
|
|
|
105
105
|
Regression lock: `edge-safety.test.ts` runs the adapter + bridge with `Buffer` and `process` removed from
|
|
106
106
|
`globalThis`.
|
|
107
107
|
|
|
108
|
+
## The Responder-Thinker primitive (the delegate seam)
|
|
109
|
+
|
|
110
|
+
The bi-model shape has a name: **Responder-Thinker** — a fast realtime **responder** on the audio
|
|
111
|
+
loop, an async reasoning/RAG **thinker** behind it, delegated to via one tool. `RealtimeBridge` +
|
|
112
|
+
`Reasoner` *is* this architecture, and the delegate seam ships with four built-in behaviors
|
|
113
|
+
(RFC `docs/rfc-bimodel-delegate-seam.md`) so a consumer never hand-rolls them:
|
|
114
|
+
|
|
115
|
+
- **G1 — structured result envelope (default).** The thinker's answer reaches the responder as
|
|
116
|
+
authoritative JSON — `{ response_text, require_repeat_verbatim: true, render? }`
|
|
117
|
+
(`DelegateResultEnvelope`, OpenAI's documented *Tool Output Formatting* shape) — so the front
|
|
118
|
+
voices it faithfully instead of paraphrasing or inventing. Options:
|
|
119
|
+
`toolResultFormat: "envelope" | "string"` (default `"envelope"`), `renderDirective` (e.g.
|
|
120
|
+
`"translate_faithfully"`). Applied synchronously to the already-buffered delegate answer —
|
|
121
|
+
latency-neutral; bus packets keep the raw answer.
|
|
122
|
+
- **G2 — delegate observability.** The bridge emits `delegate.query` / `delegate.result`
|
|
123
|
+
(Background route) around every `reasoner.stream(...)`: query, answer, `durationMs`,
|
|
124
|
+
`grounded` (the stream surfaced a `tool-result` part). Subscribe on the bus — or via
|
|
125
|
+
`VoiceAgentSession`'s `delegate_query` / `delegate_result` events — instead of wrapping the
|
|
126
|
+
`Reasoner` to log.
|
|
127
|
+
- **G3 — typed preamble/filler lifecycle.** `VoiceAgentSession` turns each delegate tool call into
|
|
128
|
+
a `tool_call_cue` lifecycle — `started` (before the thinker runs) / `delayed` (time-triggered
|
|
129
|
+
"still working" after `delayCueAfterMs`) / `complete` / `failed` (error, barge-in, supersede) —
|
|
130
|
+
and the WS transports surface it as `tool_call_*` wire messages the standard clients parse.
|
|
131
|
+
Decoupled from any blocking-tool contract: it wraps the thinker-latency window itself.
|
|
132
|
+
- **G4 — durable session + resume.** `caps.supportsNativeResume` splits providers: Gemini Live
|
|
133
|
+
resumes server-side (`sessionResumptionHandle` option in + `resumption_handle` events out —
|
|
134
|
+
never replay on top of it); OpenAI-compatible fronts replay via `resumeHistory: () => [...]`
|
|
135
|
+
(sent as `conversation.item.create` after every (re)connect's `session.update`, never a
|
|
136
|
+
`response.create` — a resumed session cannot double-answer). The thinker side re-seeds from a
|
|
137
|
+
`ReasonerSessionStore` (`@kuralle-syrinx/core`), DO-SQLite-backed in `@kuralle-syrinx/cf-agents`.
|
|
138
|
+
|
|
139
|
+
On Cloudflare, `withVoice(Agent)` (`@kuralle-syrinx/cf-agents`) wires all four up turnkey — a new
|
|
140
|
+
consumer supplies a front + a `Reasoner` and gets envelope + observability + cues + durable resume
|
|
141
|
+
for free.
|
|
142
|
+
|
|
108
143
|
## Capability model
|
|
109
144
|
|
|
110
145
|
`RealtimeAdapter.caps` lets the bridge adapt per provider:
|
|
@@ -145,7 +180,9 @@ Syrinx with the provider region to minimize the added leg.
|
|
|
145
180
|
| Delegate → `Reasoner` (bi-model), `function_call_output` injection | ✅ live-verified (university turn) |
|
|
146
181
|
| Barge-in: `speech_started`→interrupt, `cancel`+`truncate`, abort delegate, cancel-when-idle guard | ✅ logic unit-verified + detection live-confirmed; live "resume-after-barge" smoke is flaky (orchestration) |
|
|
147
182
|
| First-audio direct-vs-bridged latency delta harness | ⏳ open (WBS-5) |
|
|
148
|
-
| `fromGeminiLive`
|
|
183
|
+
| `fromGeminiLive` adapter (incl. native session resume) | ✅ shipped |
|
|
184
|
+
| Responder-Thinker delegate seam: envelope + observability + cues + durable resume | ✅ shipped (RFC bimodel-delegate-seam) |
|
|
185
|
+
| `fromMoshi` adapter | ⏳ future |
|
|
149
186
|
|
|
150
187
|
Tests: `pnpm --filter @kuralle-syrinx/realtime test`. Live gates (need `OPENAI_API_KEY`):
|
|
151
188
|
`smoke:realtime-frame` / `:realtime-oneturn` / `:realtime-university` / `:realtime-bargein` in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"types": "./src/index.ts",
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@google/genai": "^2.8.0",
|
|
11
|
-
"@kuralle-syrinx/
|
|
12
|
-
"@kuralle-syrinx/
|
|
11
|
+
"@kuralle-syrinx/core": "4.0.0",
|
|
12
|
+
"@kuralle-syrinx/ws": "4.0.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"typescript": "^5.7.0",
|
|
@@ -129,6 +129,35 @@ describe("fromGeminiLive", () => {
|
|
|
129
129
|
expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
it("G4/WBS-4: native resume — always enables sessionResumption, passes a prior handle through, surfaces new handles", async () => {
|
|
133
|
+
const adapter = fromGeminiLive({ apiKey: "test-key", sessionResumptionHandle: "handle-prev" });
|
|
134
|
+
expect(adapter.caps.supportsNativeResume).toBe(true);
|
|
135
|
+
|
|
136
|
+
const eventsTask = collectEvents(adapter.events, 1);
|
|
137
|
+
await adapter.open(new AbortController().signal);
|
|
138
|
+
|
|
139
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
140
|
+
// Handle passthrough — the server restores the conversation; nothing is replayed
|
|
141
|
+
// client-side (sendClientContent untouched — R6: no double-apply).
|
|
142
|
+
expect(connectArg.config["sessionResumption"]).toEqual({ handle: "handle-prev" });
|
|
143
|
+
expect(sendClientContent).not.toHaveBeenCalled();
|
|
144
|
+
|
|
145
|
+
inject({ sessionResumptionUpdate: { newHandle: "handle-next", resumable: true } });
|
|
146
|
+
// Non-resumable updates carry no usable handle and must be ignored.
|
|
147
|
+
inject({ sessionResumptionUpdate: { newHandle: "", resumable: false } });
|
|
148
|
+
|
|
149
|
+
expect(await eventsTask).toEqual([{ type: "resumption_handle", handle: "handle-next" }]);
|
|
150
|
+
await adapter.close();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("G4/WBS-4: enables handle issuance even without a prior handle", async () => {
|
|
154
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
155
|
+
await adapter.open(new AbortController().signal);
|
|
156
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
157
|
+
expect(connectArg.config["sessionResumption"]).toEqual({});
|
|
158
|
+
await adapter.close();
|
|
159
|
+
});
|
|
160
|
+
|
|
132
161
|
it("sends a typed user turn via sendClientContent with turnComplete", async () => {
|
|
133
162
|
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
134
163
|
await adapter.open(new AbortController().signal);
|
|
@@ -203,6 +232,7 @@ describe("fromGeminiLive", () => {
|
|
|
203
232
|
expect(adapter.caps).toEqual({
|
|
204
233
|
inputSampleRateHz: 16_000,
|
|
205
234
|
outputSampleRateHz: 24_000,
|
|
235
|
+
supportsNativeResume: true,
|
|
206
236
|
supportsConcurrentToolAudio: false,
|
|
207
237
|
supportsTruncate: false,
|
|
208
238
|
emitsServerSpeechStarted: true,
|
package/src/from-gemini-live.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface GeminiLiveOptions {
|
|
|
15
15
|
readonly model?: string;
|
|
16
16
|
readonly systemInstruction?: string;
|
|
17
17
|
readonly tools?: readonly RealtimeToolDef[];
|
|
18
|
+
/**
|
|
19
|
+
* G4 native resume: a `sessionResumption` handle from a prior session's
|
|
20
|
+
* `resumption_handle` events. Gemini restores the conversation server-side —
|
|
21
|
+
* do NOT also replay the transcript (that would double-apply history, R6).
|
|
22
|
+
* Session resumption is always enabled so handles are issued; this passes a
|
|
23
|
+
* prior handle back on reconnect.
|
|
24
|
+
*/
|
|
25
|
+
readonly sessionResumptionHandle?: string;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
class GeminiLiveAdapter implements RealtimeAdapter {
|
|
@@ -24,6 +32,7 @@ class GeminiLiveAdapter implements RealtimeAdapter {
|
|
|
24
32
|
supportsConcurrentToolAudio: false,
|
|
25
33
|
supportsTruncate: false,
|
|
26
34
|
emitsServerSpeechStarted: true,
|
|
35
|
+
supportsNativeResume: true,
|
|
27
36
|
} as const;
|
|
28
37
|
|
|
29
38
|
readonly events: AsyncIterable<RealtimeEvent>;
|
|
@@ -56,6 +65,11 @@ class GeminiLiveAdapter implements RealtimeAdapter {
|
|
|
56
65
|
responseModalities: [Modality.AUDIO],
|
|
57
66
|
inputAudioTranscription: {},
|
|
58
67
|
outputAudioTranscription: {},
|
|
68
|
+
// G4: always on so the server issues resumption handles; a prior handle
|
|
69
|
+
// resumes the conversation server-side (native resume — no replay).
|
|
70
|
+
sessionResumption: this.opts.sessionResumptionHandle
|
|
71
|
+
? { handle: this.opts.sessionResumptionHandle }
|
|
72
|
+
: {},
|
|
59
73
|
};
|
|
60
74
|
if (this.opts.systemInstruction) {
|
|
61
75
|
config["systemInstruction"] = this.opts.systemInstruction;
|
|
@@ -161,6 +175,12 @@ class GeminiLiveAdapter implements RealtimeAdapter {
|
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
private handleMessage(msg: LiveServerMessage): void {
|
|
178
|
+
// G4: surface fresh resumption handles so a durable host can persist the latest.
|
|
179
|
+
const resumption = msg.sessionResumptionUpdate;
|
|
180
|
+
if (resumption?.resumable && resumption.newHandle) {
|
|
181
|
+
this.stream.push({ type: "resumption_handle", handle: resumption.newHandle });
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
if (msg.setupComplete) {
|
|
165
185
|
if (!this.activeResponse) {
|
|
166
186
|
this.activeResponse = true;
|
|
@@ -44,6 +44,80 @@ function createMockSocketHarness(): MockSocketHarness {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
interface ReconnectableHarness {
|
|
48
|
+
readonly factory: SocketFactory;
|
|
49
|
+
readonly sent: string[];
|
|
50
|
+
readonly connectCount: () => number;
|
|
51
|
+
/** Fire the current socket's onClose with a reconnectable code → triggers auto-reconnect. */
|
|
52
|
+
drop(): void;
|
|
53
|
+
inject(msg: Record<string, unknown>): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mock harness that models the connection lifecycle so a reconnect can be simulated: each
|
|
58
|
+
* factory call mints a fresh socket that auto-fires onOpen, and `drop()` closes the current
|
|
59
|
+
* socket which drives WebSocketConnection to reconnect (calling the factory again).
|
|
60
|
+
*/
|
|
61
|
+
function createReconnectableHarness(): ReconnectableHarness {
|
|
62
|
+
const sent: string[] = [];
|
|
63
|
+
let connectCount = 0;
|
|
64
|
+
let current: {
|
|
65
|
+
open: boolean;
|
|
66
|
+
closeHandler?: (code: number, reason: string) => void;
|
|
67
|
+
messageHandler?: (data: SocketData, isBinary: boolean) => void;
|
|
68
|
+
} | null = null;
|
|
69
|
+
|
|
70
|
+
const factory: SocketFactory = () => {
|
|
71
|
+
connectCount += 1;
|
|
72
|
+
const state: {
|
|
73
|
+
open: boolean;
|
|
74
|
+
closeHandler?: (code: number, reason: string) => void;
|
|
75
|
+
messageHandler?: (data: SocketData, isBinary: boolean) => void;
|
|
76
|
+
} = { open: false };
|
|
77
|
+
current = state;
|
|
78
|
+
const socket: ManagedSocket = {
|
|
79
|
+
get isOpen() {
|
|
80
|
+
return state.open;
|
|
81
|
+
},
|
|
82
|
+
send: (data: SocketData) => {
|
|
83
|
+
sent.push(typeof data === "string" ? data : "");
|
|
84
|
+
},
|
|
85
|
+
keepAlivePing: () => {},
|
|
86
|
+
verify: async () => true,
|
|
87
|
+
dispose: () => {
|
|
88
|
+
state.open = false;
|
|
89
|
+
},
|
|
90
|
+
onOpen: (handler) => {
|
|
91
|
+
queueMicrotask(() => {
|
|
92
|
+
state.open = true;
|
|
93
|
+
handler();
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
onMessage: (handler) => {
|
|
97
|
+
state.messageHandler = handler;
|
|
98
|
+
},
|
|
99
|
+
onClose: (handler) => {
|
|
100
|
+
state.closeHandler = handler;
|
|
101
|
+
},
|
|
102
|
+
onError: () => {},
|
|
103
|
+
};
|
|
104
|
+
return socket;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
factory,
|
|
109
|
+
sent,
|
|
110
|
+
connectCount: () => connectCount,
|
|
111
|
+
drop: () => {
|
|
112
|
+
const state = current;
|
|
113
|
+
if (!state) return;
|
|
114
|
+
state.open = false;
|
|
115
|
+
state.closeHandler?.(1006, "dropped");
|
|
116
|
+
},
|
|
117
|
+
inject: (msg) => current?.messageHandler?.(JSON.stringify(msg), false),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
47
121
|
async function collectEvents(
|
|
48
122
|
events: AsyncIterable<RealtimeEvent>,
|
|
49
123
|
max = 8,
|
|
@@ -155,6 +229,82 @@ describe("fromOpenAIRealtime", () => {
|
|
|
155
229
|
expect(JSON.parse(mock.sent[5]!)).toEqual({ type: "response.create" });
|
|
156
230
|
});
|
|
157
231
|
|
|
232
|
+
it("G4/WBS-4: replays resumeHistory as conversation items after session.update, without response.create", async () => {
|
|
233
|
+
const mock = createMockSocketHarness();
|
|
234
|
+
const adapter = fromOpenAIRealtime({
|
|
235
|
+
apiKey: "test-key",
|
|
236
|
+
socketFactory: mock.factory,
|
|
237
|
+
url: () => "wss://example.test/realtime?model=gpt-realtime-2",
|
|
238
|
+
resumeHistory: () => [
|
|
239
|
+
{ role: "user", content: "First question" },
|
|
240
|
+
{ role: "assistant", content: "First answer" },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const openTask = adapter.open(new AbortController().signal);
|
|
245
|
+
await waitFor(() => mock.sent.length >= 3);
|
|
246
|
+
mock.inject({ type: "session.updated" });
|
|
247
|
+
await openTask;
|
|
248
|
+
|
|
249
|
+
expect(JSON.parse(mock.sent[0]!)).toMatchObject({ type: "session.update" });
|
|
250
|
+
expect(JSON.parse(mock.sent[1]!)).toEqual({
|
|
251
|
+
type: "conversation.item.create",
|
|
252
|
+
item: {
|
|
253
|
+
type: "message",
|
|
254
|
+
role: "user",
|
|
255
|
+
content: [{ type: "input_text", text: "First question" }],
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
expect(JSON.parse(mock.sent[2]!)).toEqual({
|
|
259
|
+
type: "conversation.item.create",
|
|
260
|
+
item: {
|
|
261
|
+
type: "message",
|
|
262
|
+
role: "assistant",
|
|
263
|
+
content: [{ type: "text", text: "First answer" }],
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
// Replay must never make the model speak (R6: no double-answer).
|
|
267
|
+
expect(mock.sent.map((frame) => (JSON.parse(frame) as { type: string }).type)).not.toContain(
|
|
268
|
+
"response.create",
|
|
269
|
+
);
|
|
270
|
+
expect(adapter.caps.supportsNativeResume).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("G4/WBS-4: replays the CURRENT resumeHistory again on reconnect (no amnesia error)", async () => {
|
|
274
|
+
const harness = createReconnectableHarness();
|
|
275
|
+
const history: Array<{ role: "user" | "assistant"; content: string }> = [
|
|
276
|
+
{ role: "user", content: "Q1" },
|
|
277
|
+
];
|
|
278
|
+
const adapter = fromOpenAIRealtime({
|
|
279
|
+
apiKey: "test-key",
|
|
280
|
+
socketFactory: harness.factory,
|
|
281
|
+
url: () => "wss://example.test/realtime?model=gpt-realtime-2",
|
|
282
|
+
resumeHistory: () => history,
|
|
283
|
+
});
|
|
284
|
+
const events: RealtimeEvent[] = [];
|
|
285
|
+
void (async () => {
|
|
286
|
+
for await (const event of adapter.events) events.push(event);
|
|
287
|
+
})();
|
|
288
|
+
|
|
289
|
+
const openTask = adapter.open(new AbortController().signal);
|
|
290
|
+
await waitFor(() => harness.sent.length >= 2);
|
|
291
|
+
harness.inject({ type: "session.updated" });
|
|
292
|
+
await openTask;
|
|
293
|
+
|
|
294
|
+
history.push({ role: "assistant", content: "A1" });
|
|
295
|
+
harness.sent.length = 0;
|
|
296
|
+
harness.drop();
|
|
297
|
+
await waitFor(() => harness.connectCount() === 2 && harness.sent.length >= 3);
|
|
298
|
+
|
|
299
|
+
const types = harness.sent.map((frame) => (JSON.parse(frame) as { type: string }).type);
|
|
300
|
+
expect(types[0]).toBe("session.update");
|
|
301
|
+
expect(types.filter((type) => type === "conversation.item.create")).toHaveLength(2);
|
|
302
|
+
// With replay wired, the reconnect no longer surfaces the history-lost error.
|
|
303
|
+
expect(events.some((event) => event.type === "error" && event.cause.message.includes("history was lost"))).toBe(false);
|
|
304
|
+
|
|
305
|
+
await adapter.close();
|
|
306
|
+
});
|
|
307
|
+
|
|
158
308
|
it("sends a typed user turn as conversation.item.create(input_text) + response.create", async () => {
|
|
159
309
|
const mock = createMockSocketHarness();
|
|
160
310
|
const adapter = fromOpenAIRealtime({
|
|
@@ -469,4 +619,57 @@ describe("fromOpenAIRealtime", () => {
|
|
|
469
619
|
.filter((msg) => msg["type"] === "conversation.item.truncate");
|
|
470
620
|
expect(truncateMessages).toHaveLength(0);
|
|
471
621
|
});
|
|
622
|
+
|
|
623
|
+
it("BUG2: re-sends session.update (config + tools) on reconnect and surfaces history loss", async () => {
|
|
624
|
+
const harness = createReconnectableHarness();
|
|
625
|
+
const adapter = fromOpenAIRealtime({
|
|
626
|
+
apiKey: "test-key",
|
|
627
|
+
socketFactory: harness.factory,
|
|
628
|
+
url: () => "wss://example.test/realtime?model=gpt-realtime-2",
|
|
629
|
+
tools: [
|
|
630
|
+
{
|
|
631
|
+
name: "consult_knowledge",
|
|
632
|
+
description: "Answer knowledge questions.",
|
|
633
|
+
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const errors: RealtimeEvent[] = [];
|
|
639
|
+
const drainTask = (async () => {
|
|
640
|
+
for await (const ev of adapter.events) {
|
|
641
|
+
if (ev.type === "error") errors.push(ev);
|
|
642
|
+
}
|
|
643
|
+
})();
|
|
644
|
+
|
|
645
|
+
const openTask = adapter.open(new AbortController().signal);
|
|
646
|
+
await waitFor(() => harness.sent.some((s) => (JSON.parse(s) as { type?: string }).type === "session.update"));
|
|
647
|
+
harness.inject({ type: "session.updated" });
|
|
648
|
+
await openTask;
|
|
649
|
+
|
|
650
|
+
const countSessionUpdate = (): string[] =>
|
|
651
|
+
harness.sent.filter((s) => (JSON.parse(s) as { type?: string }).type === "session.update");
|
|
652
|
+
expect(countSessionUpdate()).toHaveLength(1);
|
|
653
|
+
expect(harness.connectCount()).toBe(1);
|
|
654
|
+
|
|
655
|
+
// Mid-call socket drop → WebSocketConnection auto-reconnects.
|
|
656
|
+
harness.drop();
|
|
657
|
+
await waitFor(() => countSessionUpdate().length >= 2);
|
|
658
|
+
expect(harness.connectCount()).toBeGreaterThanOrEqual(2);
|
|
659
|
+
|
|
660
|
+
// The reconnected session got a fresh session.update carrying the tools + config.
|
|
661
|
+
const secondUpdate = JSON.parse(countSessionUpdate()[1]!) as {
|
|
662
|
+
session: { tools: Array<{ name: string }> };
|
|
663
|
+
};
|
|
664
|
+
expect(secondUpdate.session.tools).toHaveLength(1);
|
|
665
|
+
expect(secondUpdate.session.tools[0]!.name).toBe("consult_knowledge");
|
|
666
|
+
|
|
667
|
+
// History loss is surfaced as a recoverable error, not silence.
|
|
668
|
+
await waitFor(() =>
|
|
669
|
+
errors.some((e) => e.type === "error" && e.recoverable && /history/i.test(e.cause.message)),
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
await adapter.close();
|
|
673
|
+
await drainTask;
|
|
674
|
+
});
|
|
472
675
|
});
|
|
@@ -4,7 +4,7 @@ import type { SocketFactory } from "@kuralle-syrinx/ws";
|
|
|
4
4
|
|
|
5
5
|
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
6
6
|
import { createOpenAiCompatibleRealtimeAdapter } from "./openai-compatible-realtime.js";
|
|
7
|
-
import type { RealtimeAdapter, RealtimeToolDef } from "./realtime-adapter.js";
|
|
7
|
+
import type { RealtimeAdapter, RealtimeResumeMessage, RealtimeToolDef } from "./realtime-adapter.js";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_MODEL = "gpt-realtime-2";
|
|
10
10
|
const DEFAULT_VOICE = "marin";
|
|
@@ -33,6 +33,12 @@ export interface OpenAIRealtimeOptions {
|
|
|
33
33
|
readonly toolChoice?: string | Record<string, unknown>;
|
|
34
34
|
readonly inputRateHz?: number;
|
|
35
35
|
readonly outputRateHz?: number;
|
|
36
|
+
/**
|
|
37
|
+
* G4 resume-by-replay: returns the prior conversation to replay as
|
|
38
|
+
* `conversation.item.create` items on every (re)connect — OpenAI Realtime has no
|
|
39
|
+
* native session resume. Replay never triggers a response (no double-answer).
|
|
40
|
+
*/
|
|
41
|
+
readonly resumeHistory?: () => readonly RealtimeResumeMessage[];
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter {
|
|
@@ -98,6 +104,7 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
|
|
|
98
104
|
supportsTruncate: true,
|
|
99
105
|
requiresResponseCreateAfterToolOutput: opts.requiresResponseCreateAfterToolOutput,
|
|
100
106
|
defaultErrorMessage: "OpenAI Realtime error",
|
|
107
|
+
...(opts.resumeHistory ? { buildResumeItems: opts.resumeHistory } : {}),
|
|
101
108
|
});
|
|
102
109
|
}
|
|
103
110
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
-
export type {
|
|
3
|
+
export type {
|
|
4
|
+
RealtimeAdapter,
|
|
5
|
+
RealtimeEvent,
|
|
6
|
+
RealtimeResumeMessage,
|
|
7
|
+
RealtimeToolDef,
|
|
8
|
+
} from "./realtime-adapter.js";
|
|
4
9
|
export { bytesToBase64, base64ToBytes } from "./base64.js";
|
|
5
10
|
export {
|
|
6
11
|
createOpenAiCompatibleRealtimeAdapter,
|
|
@@ -14,4 +19,8 @@ export {
|
|
|
14
19
|
type GeminiTranslateSession,
|
|
15
20
|
type GeminiTranslateSessionOptions,
|
|
16
21
|
} from "./gemini-translate.js";
|
|
17
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
RealtimeBridge,
|
|
24
|
+
type DelegateResultEnvelope,
|
|
25
|
+
type RealtimeBridgeOptions,
|
|
26
|
+
} from "./realtime-bridge.js";
|
|
@@ -5,7 +5,7 @@ import { RealtimeSocket } from "@kuralle-syrinx/ws/realtime";
|
|
|
5
5
|
|
|
6
6
|
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
7
7
|
import { RealtimeEventStream } from "./realtime-event-stream.js";
|
|
8
|
-
import type { RealtimeAdapter, RealtimeEvent } from "./realtime-adapter.js";
|
|
8
|
+
import type { RealtimeAdapter, RealtimeEvent, RealtimeResumeMessage } from "./realtime-adapter.js";
|
|
9
9
|
|
|
10
10
|
export interface OpenAiCompatibleRealtimeConfig {
|
|
11
11
|
readonly apiKey: string;
|
|
@@ -18,6 +18,15 @@ export interface OpenAiCompatibleRealtimeConfig {
|
|
|
18
18
|
readonly buildUrl?: (model: string) => string;
|
|
19
19
|
readonly caps: RealtimeAdapter["caps"];
|
|
20
20
|
readonly buildSessionUpdate: () => Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* G4 resume-by-replay: returns the prior conversation to re-create as
|
|
23
|
+
* `conversation.item.create` items after each (re)connect's `session.update` —
|
|
24
|
+
* these providers have no native resume, so the transcript is replayed. Item
|
|
25
|
+
* creation never triggers a response (no `response.create`), so a resumed
|
|
26
|
+
* session cannot double-answer (R6). Called on the initial connect AND every
|
|
27
|
+
* reconnect, so return the *current* durable history, not a snapshot.
|
|
28
|
+
*/
|
|
29
|
+
readonly buildResumeItems?: () => readonly RealtimeResumeMessage[];
|
|
21
30
|
readonly supportsTruncate: boolean;
|
|
22
31
|
readonly requiresResponseCreateAfterToolOutput?: boolean;
|
|
23
32
|
readonly defaultErrorMessage: string;
|
|
@@ -44,6 +53,7 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
44
53
|
private assistantTranscript = "";
|
|
45
54
|
private activeResponse = false;
|
|
46
55
|
private pendingResponseCreate = false;
|
|
56
|
+
private opened = false;
|
|
47
57
|
|
|
48
58
|
constructor(private readonly config: OpenAiCompatibleRealtimeConfig) {
|
|
49
59
|
this.events = this.stream;
|
|
@@ -65,6 +75,12 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
65
75
|
},
|
|
66
76
|
socketFactory: this.config.socketFactory,
|
|
67
77
|
onMessage: (json) => this.handleServerMessage(json),
|
|
78
|
+
// Re-applied on EVERY (re)connection. WebSocketConnection auto-reconnects after a mid-call
|
|
79
|
+
// drop; without re-sending session.update the fresh provider session comes back with no
|
|
80
|
+
// tools and default instructions — the front model silently loses its persona and the
|
|
81
|
+
// delegate tool (delegation permanently dead). onReady fires after the socket is verified
|
|
82
|
+
// and before replay frames flush, so config lands ahead of any buffered audio.
|
|
83
|
+
onReady: () => this.applySessionConfig(),
|
|
68
84
|
onConnectionLost: (err) => {
|
|
69
85
|
this.stream.push({ type: "error", cause: err, recoverable: true });
|
|
70
86
|
this.rejectOpen(err);
|
|
@@ -86,13 +102,53 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
86
102
|
};
|
|
87
103
|
signal.addEventListener("abort", this.abortHandler, { once: true });
|
|
88
104
|
|
|
105
|
+
// connect() invokes onReady synchronously once the socket is open, which sends the first
|
|
106
|
+
// session.update; every subsequent reconnect re-invokes onReady and re-applies it.
|
|
89
107
|
await this.socket.connect();
|
|
90
|
-
|
|
108
|
+
await openPromise;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* (Re)configure the provider session — model, audio formats, turn detection, tools,
|
|
113
|
+
* instructions. Fired on the initial connect and on every reconnect. On a reconnect the provider
|
|
114
|
+
* session is brand new: config + tools are restored here, but the prior conversation history is
|
|
115
|
+
* gone, so surface that as a recoverable error rather than silently continuing amnesiac.
|
|
116
|
+
*/
|
|
117
|
+
private applySessionConfig(): void {
|
|
118
|
+
const reconnected = this.opened;
|
|
119
|
+
this.opened = true;
|
|
120
|
+
this.socket?.send({
|
|
91
121
|
type: "session.update",
|
|
92
122
|
session: this.config.buildSessionUpdate(),
|
|
93
123
|
});
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
// G4 resume-by-replay: re-create the prior conversation as items. Providers
|
|
125
|
+
// without native resume start every (re)connection amnesiac; replaying the
|
|
126
|
+
// transcript here restores context. No response.create — replay must never
|
|
127
|
+
// make the model speak (R6).
|
|
128
|
+
const resumeItems = this.config.buildResumeItems?.() ?? [];
|
|
129
|
+
for (const message of resumeItems) {
|
|
130
|
+
this.socket?.send({
|
|
131
|
+
type: "conversation.item.create",
|
|
132
|
+
item: {
|
|
133
|
+
type: "message",
|
|
134
|
+
role: message.role,
|
|
135
|
+
content: [
|
|
136
|
+
message.role === "user"
|
|
137
|
+
? { type: "input_text", text: message.content }
|
|
138
|
+
: { type: "text", text: message.content },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (reconnected && !this.config.buildResumeItems) {
|
|
144
|
+
this.stream.push({
|
|
145
|
+
type: "error",
|
|
146
|
+
cause: new Error(
|
|
147
|
+
"Realtime session reconnected: model config and tools restored, but prior conversation history was lost",
|
|
148
|
+
),
|
|
149
|
+
recoverable: true,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
96
152
|
}
|
|
97
153
|
|
|
98
154
|
sendAudio(pcm16: Uint8Array): void {
|
package/src/realtime-adapter.ts
CHANGED
|
@@ -7,6 +7,13 @@ export interface RealtimeAdapter {
|
|
|
7
7
|
readonly supportsConcurrentToolAudio: boolean;
|
|
8
8
|
readonly supportsTruncate: boolean;
|
|
9
9
|
readonly emitsServerSpeechStarted: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* G4: the provider resumes a prior session server-side from a handle (Gemini Live
|
|
12
|
+
* `sessionResumption`). Absent/false → resuming requires the host to replay the
|
|
13
|
+
* transcript (e.g. OpenAI `resumeHistory`). A native-resume provider must NOT also
|
|
14
|
+
* be replayed — that would double-apply history (RFC bimodel-delegate-seam R6).
|
|
15
|
+
*/
|
|
16
|
+
readonly supportsNativeResume?: boolean;
|
|
10
17
|
};
|
|
11
18
|
|
|
12
19
|
open(signal: AbortSignal): Promise<void>;
|
|
@@ -34,6 +41,12 @@ export interface RealtimeToolDef {
|
|
|
34
41
|
readonly parameters: Record<string, unknown>;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/** A prior-conversation message replayed into a front model on resume (G4). */
|
|
45
|
+
export interface RealtimeResumeMessage {
|
|
46
|
+
readonly role: "user" | "assistant";
|
|
47
|
+
readonly content: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
export type RealtimeEvent =
|
|
38
51
|
| { type: "audio"; pcm16: Uint8Array; sampleRateHz: number }
|
|
39
52
|
| { type: "speech_started" }
|
|
@@ -41,4 +54,7 @@ export type RealtimeEvent =
|
|
|
41
54
|
| { type: "tool_call"; toolId: string; toolName: string; args: Record<string, unknown> }
|
|
42
55
|
| { type: "response_started" }
|
|
43
56
|
| { type: "response_done" }
|
|
57
|
+
// G4: a native-resume provider issued a fresh resumption handle — persist the
|
|
58
|
+
// latest one and pass it back on reconnect (Gemini `sessionResumption`).
|
|
59
|
+
| { type: "resumption_handle"; handle: string }
|
|
44
60
|
| { type: "error"; cause: Error; recoverable: boolean };
|
|
@@ -6,8 +6,11 @@ import {
|
|
|
6
6
|
PipelineBusImpl,
|
|
7
7
|
Route,
|
|
8
8
|
VoiceAgentSession,
|
|
9
|
+
type DelegateQueryPacket,
|
|
10
|
+
type DelegateResultPacket,
|
|
9
11
|
type EndOfSpeechPacket,
|
|
10
12
|
type InterruptTtsPacket,
|
|
13
|
+
type InterruptionDetectedPacket,
|
|
11
14
|
type LlmDeltaPacket,
|
|
12
15
|
type LlmResponseDonePacket,
|
|
13
16
|
type LlmErrorPacket,
|
|
@@ -288,9 +291,14 @@ describe("RealtimeBridge", () => {
|
|
|
288
291
|
|
|
289
292
|
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
290
293
|
|
|
294
|
+
// The bus packet keeps the raw answer; the front model receives the G1 envelope (default).
|
|
291
295
|
expect(toolResults).toHaveLength(1);
|
|
292
296
|
expect(toolResults[0]!.result).toBe(answerText);
|
|
293
|
-
expect(adapter.injectedToolResults[0]).
|
|
297
|
+
expect(adapter.injectedToolResults[0]!.toolId).toBe("call_delegate_1");
|
|
298
|
+
expect(JSON.parse(adapter.injectedToolResults[0]!.text)).toEqual({
|
|
299
|
+
response_text: answerText,
|
|
300
|
+
require_repeat_verbatim: true,
|
|
301
|
+
});
|
|
294
302
|
|
|
295
303
|
await bridge.close();
|
|
296
304
|
bus.stop();
|
|
@@ -321,7 +329,203 @@ describe("RealtimeBridge", () => {
|
|
|
321
329
|
});
|
|
322
330
|
|
|
323
331
|
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
324
|
-
expect(adapter.injectedToolResults[0]!.text).
|
|
332
|
+
expect(JSON.parse(adapter.injectedToolResults[0]!.text)).toMatchObject({ response_text: "Partial " });
|
|
333
|
+
|
|
334
|
+
await bridge.close();
|
|
335
|
+
bus.stop();
|
|
336
|
+
await started;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("G1/WBS-2: envelope carries the render directive when configured", async () => {
|
|
340
|
+
const adapter = new FakeRealtimeAdapter();
|
|
341
|
+
const reasoner: Reasoner = {
|
|
342
|
+
stream: () => (async function* () {
|
|
343
|
+
yield { type: "finish", reason: "stop", text: "The fee is 5000 rupees." };
|
|
344
|
+
})(),
|
|
345
|
+
};
|
|
346
|
+
const bridge = new RealtimeBridge(adapter, reasoner, "consult_knowledge", {
|
|
347
|
+
renderDirective: "translate_faithfully",
|
|
348
|
+
});
|
|
349
|
+
const bus = new PipelineBusImpl();
|
|
350
|
+
buses.push(bus);
|
|
351
|
+
|
|
352
|
+
const started = bus.start();
|
|
353
|
+
await bridge.initialize(bus, {});
|
|
354
|
+
|
|
355
|
+
adapter.emit({ type: "response_started" });
|
|
356
|
+
adapter.emit({
|
|
357
|
+
type: "tool_call",
|
|
358
|
+
toolId: "call_env_1",
|
|
359
|
+
toolName: "consult_knowledge",
|
|
360
|
+
args: { query: "fees" },
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
364
|
+
expect(JSON.parse(adapter.injectedToolResults[0]!.text)).toEqual({
|
|
365
|
+
response_text: "The fee is 5000 rupees.",
|
|
366
|
+
require_repeat_verbatim: true,
|
|
367
|
+
render: "translate_faithfully",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await bridge.close();
|
|
371
|
+
bus.stop();
|
|
372
|
+
await started;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('G1/WBS-2: toolResultFormat "string" opts out to the raw answer (pre-RFC behavior)', async () => {
|
|
376
|
+
const adapter = new FakeRealtimeAdapter();
|
|
377
|
+
const reasoner: Reasoner = {
|
|
378
|
+
stream: () => (async function* () {
|
|
379
|
+
yield { type: "finish", reason: "stop", text: "raw answer" };
|
|
380
|
+
})(),
|
|
381
|
+
};
|
|
382
|
+
const bridge = new RealtimeBridge(adapter, reasoner, "consult_knowledge", {
|
|
383
|
+
toolResultFormat: "string",
|
|
384
|
+
});
|
|
385
|
+
const bus = new PipelineBusImpl();
|
|
386
|
+
buses.push(bus);
|
|
387
|
+
|
|
388
|
+
const started = bus.start();
|
|
389
|
+
await bridge.initialize(bus, {});
|
|
390
|
+
|
|
391
|
+
adapter.emit({ type: "response_started" });
|
|
392
|
+
adapter.emit({
|
|
393
|
+
type: "tool_call",
|
|
394
|
+
toolId: "call_env_2",
|
|
395
|
+
toolName: "consult_knowledge",
|
|
396
|
+
args: { query: "q" },
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
400
|
+
expect(adapter.injectedToolResults[0]!.text).toBe("raw answer");
|
|
401
|
+
|
|
402
|
+
await bridge.close();
|
|
403
|
+
bus.stop();
|
|
404
|
+
await started;
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("G2/WBS-1: delegate turn emits delegate.query then delegate.result on the Background route", async () => {
|
|
408
|
+
const adapter = new FakeRealtimeAdapter();
|
|
409
|
+
const reasoner: Reasoner = {
|
|
410
|
+
stream: () => (async function* () {
|
|
411
|
+
yield { type: "tool-call", toolId: "rag-1", toolName: "retrieve", args: { q: "deadline" } };
|
|
412
|
+
yield { type: "tool-result", toolId: "rag-1", toolName: "retrieve", result: "chunk" };
|
|
413
|
+
yield { type: "text-delta", text: "The deadline is March 31." };
|
|
414
|
+
yield { type: "finish", reason: "stop", text: "The deadline is March 31." };
|
|
415
|
+
})(),
|
|
416
|
+
};
|
|
417
|
+
const bridge = new RealtimeBridge(adapter, reasoner);
|
|
418
|
+
const background: Array<{ route: Route; packet: VoicePacket }> = [];
|
|
419
|
+
const bus = new PipelineBusImpl({
|
|
420
|
+
onPacket: (route, packet) => {
|
|
421
|
+
if (packet.kind.startsWith("delegate.")) background.push({ route, packet });
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
buses.push(bus);
|
|
425
|
+
|
|
426
|
+
const started = bus.start();
|
|
427
|
+
await bridge.initialize(bus, {});
|
|
428
|
+
|
|
429
|
+
adapter.emit({ type: "response_started" });
|
|
430
|
+
adapter.emit({
|
|
431
|
+
type: "tool_call",
|
|
432
|
+
toolId: "call_obs_1",
|
|
433
|
+
toolName: "consult_knowledge",
|
|
434
|
+
args: { query: "When is the exam registration deadline?" },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await waitForCondition(() => adapter.injectedToolResults.length === 1);
|
|
438
|
+
await waitForCondition(() => background.length === 2);
|
|
439
|
+
|
|
440
|
+
expect(background.map((entry) => entry.route)).toEqual([Route.Background, Route.Background]);
|
|
441
|
+
const [query, result] = background.map((entry) => entry.packet);
|
|
442
|
+
expect(query).toMatchObject({
|
|
443
|
+
kind: "delegate.query",
|
|
444
|
+
query: "When is the exam registration deadline?",
|
|
445
|
+
toolId: "call_obs_1",
|
|
446
|
+
toolName: "consult_knowledge",
|
|
447
|
+
});
|
|
448
|
+
expect(query!.contextId).toBeTruthy();
|
|
449
|
+
expect(result).toMatchObject({
|
|
450
|
+
kind: "delegate.result",
|
|
451
|
+
query: "When is the exam registration deadline?",
|
|
452
|
+
answer: "The deadline is March 31.",
|
|
453
|
+
grounded: true,
|
|
454
|
+
toolId: "call_obs_1",
|
|
455
|
+
toolName: "consult_knowledge",
|
|
456
|
+
contextId: query!.contextId,
|
|
457
|
+
});
|
|
458
|
+
expect((result as DelegateResultPacket).durationMs).toBeGreaterThanOrEqual(0);
|
|
459
|
+
|
|
460
|
+
await bridge.close();
|
|
461
|
+
bus.stop();
|
|
462
|
+
await started;
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("G2/WBS-1: delegate.result grounded=false when the reasoner used no tool", async () => {
|
|
466
|
+
const adapter = new FakeRealtimeAdapter();
|
|
467
|
+
const reasoner: Reasoner = {
|
|
468
|
+
stream: () => (async function* () {
|
|
469
|
+
yield { type: "text-delta", text: "From memory." };
|
|
470
|
+
yield { type: "finish", reason: "stop", text: "From memory." };
|
|
471
|
+
})(),
|
|
472
|
+
};
|
|
473
|
+
const bridge = new RealtimeBridge(adapter, reasoner);
|
|
474
|
+
const results: DelegateResultPacket[] = [];
|
|
475
|
+
const bus = new PipelineBusImpl();
|
|
476
|
+
buses.push(bus);
|
|
477
|
+
bus.on("delegate.result", (pkt) => { results.push(pkt as DelegateResultPacket); });
|
|
478
|
+
|
|
479
|
+
const started = bus.start();
|
|
480
|
+
await bridge.initialize(bus, {});
|
|
481
|
+
|
|
482
|
+
adapter.emit({ type: "response_started" });
|
|
483
|
+
adapter.emit({
|
|
484
|
+
type: "tool_call",
|
|
485
|
+
toolId: "call_obs_2",
|
|
486
|
+
toolName: "consult_knowledge",
|
|
487
|
+
args: { query: "ungrounded" },
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
await waitForCondition(() => results.length === 1);
|
|
491
|
+
expect(results[0]!.grounded).toBe(false);
|
|
492
|
+
|
|
493
|
+
await bridge.close();
|
|
494
|
+
bus.stop();
|
|
495
|
+
await started;
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("G2/WBS-1: failed delegate emits delegate.query but no delegate.result", async () => {
|
|
499
|
+
const adapter = new FakeRealtimeAdapter();
|
|
500
|
+
const reasoner: Reasoner = {
|
|
501
|
+
stream: () => (async function* () {
|
|
502
|
+
yield { type: "error", cause: new Error("brain down"), recoverable: false };
|
|
503
|
+
})(),
|
|
504
|
+
};
|
|
505
|
+
const bridge = new RealtimeBridge(adapter, reasoner);
|
|
506
|
+
const queries: DelegateQueryPacket[] = [];
|
|
507
|
+
const results: DelegateResultPacket[] = [];
|
|
508
|
+
const errors: LlmErrorPacket[] = [];
|
|
509
|
+
const bus = new PipelineBusImpl();
|
|
510
|
+
buses.push(bus);
|
|
511
|
+
bus.on("delegate.query", (pkt) => { queries.push(pkt as DelegateQueryPacket); });
|
|
512
|
+
bus.on("delegate.result", (pkt) => { results.push(pkt as DelegateResultPacket); });
|
|
513
|
+
bus.on("llm.error", (pkt) => { errors.push(pkt as LlmErrorPacket); });
|
|
514
|
+
|
|
515
|
+
const started = bus.start();
|
|
516
|
+
await bridge.initialize(bus, {});
|
|
517
|
+
|
|
518
|
+
adapter.emit({ type: "response_started" });
|
|
519
|
+
adapter.emit({
|
|
520
|
+
type: "tool_call",
|
|
521
|
+
toolId: "call_obs_3",
|
|
522
|
+
toolName: "consult_knowledge",
|
|
523
|
+
args: { query: "will fail" },
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await waitForCondition(() => errors.length === 1);
|
|
527
|
+
expect(queries).toHaveLength(1);
|
|
528
|
+
expect(results).toHaveLength(0);
|
|
325
529
|
|
|
326
530
|
await bridge.close();
|
|
327
531
|
bus.stop();
|
|
@@ -879,4 +1083,65 @@ describe("RealtimeBridge", () => {
|
|
|
879
1083
|
|
|
880
1084
|
await session.close();
|
|
881
1085
|
});
|
|
1086
|
+
|
|
1087
|
+
it("BUG1: a barge-in DURING the reasoner gap drains speech_started, fires interrupt.detected, and aborts the delegate before it completes", async () => {
|
|
1088
|
+
// The barge-in is delivered on the adapter EVENT STREAM (not a direct interrupt.tts bus
|
|
1089
|
+
// push). With the old serial pump, awaiting the delegate froze the pump for the whole
|
|
1090
|
+
// reasoner gap, so this speech_started stayed queued and never became interrupt.detected —
|
|
1091
|
+
// the unwanted answer was then voiced over the user. The delegate must run off the pump.
|
|
1092
|
+
const adapter = new FakeRealtimeAdapter();
|
|
1093
|
+
let delegateSignal: AbortSignal | undefined;
|
|
1094
|
+
let reasonerCompleted = false;
|
|
1095
|
+
const reasoner: Reasoner = {
|
|
1096
|
+
stream: ({ signal }) => {
|
|
1097
|
+
delegateSignal = signal;
|
|
1098
|
+
return (async function* () {
|
|
1099
|
+
// The ~1.5–3s reasoner "thinking gap", exaggerated: the barge-in must abort it, not
|
|
1100
|
+
// wait it out.
|
|
1101
|
+
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
|
1102
|
+
reasonerCompleted = true;
|
|
1103
|
+
yield { type: "finish", reason: "stop", text: "unwanted late answer" };
|
|
1104
|
+
})();
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
const bridge = new RealtimeBridge(adapter, reasoner);
|
|
1108
|
+
const session = new VoiceAgentSession({
|
|
1109
|
+
plugins: { realtime: {} },
|
|
1110
|
+
endpointingOwner: "timer",
|
|
1111
|
+
minInterruptionMs: 0,
|
|
1112
|
+
});
|
|
1113
|
+
session.registerPlugin("realtime", bridge);
|
|
1114
|
+
|
|
1115
|
+
const interruptDetected: InterruptionDetectedPacket[] = [];
|
|
1116
|
+
const turnChanges: TurnChangePacket[] = [];
|
|
1117
|
+
session.bus.on("interrupt.detected", (pkt) => { interruptDetected.push(pkt as InterruptionDetectedPacket); });
|
|
1118
|
+
session.bus.on("turn.change", (pkt) => { turnChanges.push(pkt as TurnChangePacket); });
|
|
1119
|
+
|
|
1120
|
+
await session.start();
|
|
1121
|
+
|
|
1122
|
+
// Turn A: front model responds, emits audio, then calls the delegate tool.
|
|
1123
|
+
adapter.emit({ type: "response_started" });
|
|
1124
|
+
await waitForCondition(() => turnChanges.length >= 1);
|
|
1125
|
+
adapter.emit({ type: "audio", pcm16: frameSizedPcm24k(), sampleRateHz: 24_000 });
|
|
1126
|
+
adapter.emit({
|
|
1127
|
+
type: "tool_call",
|
|
1128
|
+
toolId: "call_gap_delegate",
|
|
1129
|
+
toolName: "consult_knowledge",
|
|
1130
|
+
args: { query: "late add policy" },
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Delegate is now in flight (reasoner sleeping) — signal wired, not yet aborted.
|
|
1134
|
+
await waitForCondition(() => delegateSignal !== undefined);
|
|
1135
|
+
expect(delegateSignal!.aborted).toBe(false);
|
|
1136
|
+
|
|
1137
|
+
// User barges in mid-gap, on the adapter event stream.
|
|
1138
|
+
adapter.emit({ type: "speech_started" });
|
|
1139
|
+
|
|
1140
|
+
// The pump kept draining: speech_started → interrupt.detected → session → interrupt.tts → abort.
|
|
1141
|
+
await waitForCondition(() => interruptDetected.length >= 1);
|
|
1142
|
+
await waitForCondition(() => delegateSignal!.aborted);
|
|
1143
|
+
expect(reasonerCompleted).toBe(false);
|
|
1144
|
+
|
|
1145
|
+
await session.close();
|
|
1146
|
+
});
|
|
882
1147
|
});
|
package/src/realtime-bridge.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import {
|
|
4
4
|
Route,
|
|
5
5
|
categorizeLlmError,
|
|
6
|
+
type DelegateQueryPacket,
|
|
7
|
+
type DelegateResultPacket,
|
|
6
8
|
type EndOfSpeechPacket,
|
|
7
9
|
type InterruptTtsPacket,
|
|
8
10
|
type InterruptionDetectedPacket,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
type LlmToolResultPacket,
|
|
14
16
|
type PipelineBus,
|
|
15
17
|
type PluginConfig,
|
|
18
|
+
type RealtimeResumptionHandlePacket,
|
|
16
19
|
type Reasoner,
|
|
17
20
|
type ReasonerMessage,
|
|
18
21
|
type UserAudioReceivedPacket,
|
|
@@ -32,8 +35,39 @@ const ENGINE_SAMPLE_RATE_HZ = 16_000;
|
|
|
32
35
|
const FRAME_SAMPLES_20MS = 320;
|
|
33
36
|
const FRAME_BYTES_20MS = FRAME_SAMPLES_20MS * 2;
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* G1 — the structured delegate-result envelope (RFC bimodel-delegate-seam §4).
|
|
40
|
+
* The bridge wraps the accumulated reasoner answer in this JSON shape before
|
|
41
|
+
* `injectToolResult`, so the front model treats it as authoritative tool data
|
|
42
|
+
* and repeats it faithfully instead of paraphrasing or inventing facts. Field
|
|
43
|
+
* names mirror OpenAI's Realtime Prompting Guide "Tool Output Formatting"
|
|
44
|
+
* (`response_text` + `require_repeat_verbatim`) — the only field-validated
|
|
45
|
+
* envelope shape, and gpt-realtime is the primary front (OQ1).
|
|
46
|
+
*/
|
|
47
|
+
export interface DelegateResultEnvelope {
|
|
48
|
+
/** The authoritative answer — the front model's ONLY source of facts for the turn. */
|
|
49
|
+
readonly response_text: string;
|
|
50
|
+
readonly require_repeat_verbatim: true;
|
|
51
|
+
/** Optional app directive the front prompt keys on, e.g. "translate_faithfully". */
|
|
52
|
+
readonly render?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
export interface RealtimeBridgeOptions {
|
|
36
56
|
readonly debug?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* How the delegate answer reaches the front model at `injectToolResult` time.
|
|
59
|
+
* `"envelope"` (default) wraps it as a {@link DelegateResultEnvelope}; `"string"`
|
|
60
|
+
* injects the raw answer (pre-envelope behavior). The envelope is applied to the
|
|
61
|
+
* already-buffered delegate answer — a synchronous JSON.stringify, no added latency
|
|
62
|
+
* (R2) — and never touches the cascade text-delta path. Bus packets
|
|
63
|
+
* (`llm.tool_result`, `delegate.result`) always carry the raw answer.
|
|
64
|
+
*/
|
|
65
|
+
readonly toolResultFormat?: "envelope" | "string";
|
|
66
|
+
/**
|
|
67
|
+
* Optional `render` directive included in the envelope (e.g. `"translate_faithfully"`),
|
|
68
|
+
* for front prompts that key a rendering rule on it. Ignored with `toolResultFormat: "string"`.
|
|
69
|
+
*/
|
|
70
|
+
readonly renderDirective?: string;
|
|
37
71
|
/**
|
|
38
72
|
* Supplies prior conversation context for delegate Reasoner turns. When omitted the delegate is
|
|
39
73
|
* stateless — each tool call receives only the query extracted from the front-model tool args.
|
|
@@ -67,6 +101,7 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
67
101
|
private turnAssistantDeltas = "";
|
|
68
102
|
private sessionAbort: AbortController | null = null;
|
|
69
103
|
private inflight: AbortController | undefined;
|
|
104
|
+
private delegateTask: Promise<void> | null = null;
|
|
70
105
|
private playedMs = 0;
|
|
71
106
|
private audioRemainder: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
72
107
|
private readonly disposers: Array<() => void> = [];
|
|
@@ -109,6 +144,7 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
109
144
|
|
|
110
145
|
async close(): Promise<void> {
|
|
111
146
|
this.sessionAbort?.abort();
|
|
147
|
+
this.inflight?.abort();
|
|
112
148
|
for (const dispose of this.disposers.splice(0)) dispose();
|
|
113
149
|
await this.adapter.close();
|
|
114
150
|
this.bus = null;
|
|
@@ -163,6 +199,18 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
163
199
|
case "speech_started":
|
|
164
200
|
this.onSpeechStarted(bus);
|
|
165
201
|
break;
|
|
202
|
+
case "resumption_handle": {
|
|
203
|
+
// G4: persist-worthy native-resume handle (Gemini). Background route —
|
|
204
|
+
// a durable host stores the latest and passes it back on reconnect.
|
|
205
|
+
const packet: RealtimeResumptionHandlePacket = {
|
|
206
|
+
kind: "realtime.resumption_handle",
|
|
207
|
+
contextId: this.contextId,
|
|
208
|
+
timestampMs: Date.now(),
|
|
209
|
+
handle: ev.handle,
|
|
210
|
+
};
|
|
211
|
+
bus.push(Route.Background, packet);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
166
214
|
case "error":
|
|
167
215
|
this.onError(bus, ev.cause, ev.recoverable);
|
|
168
216
|
break;
|
|
@@ -176,7 +224,7 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
176
224
|
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
177
225
|
): Promise<void> {
|
|
178
226
|
if (ev.toolName === this.delegateToolName) {
|
|
179
|
-
if (this.reasoner)
|
|
227
|
+
if (this.reasoner) this.dispatchDelegate(bus, ev);
|
|
180
228
|
return;
|
|
181
229
|
}
|
|
182
230
|
if (this.opts.onFrontToolCall) {
|
|
@@ -207,15 +255,38 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
207
255
|
this.adapter.injectToolResult(ev.toolId, result);
|
|
208
256
|
}
|
|
209
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Start the reasoner delegate OFF the serial event pump. The pump must keep draining adapter
|
|
260
|
+
* events during the multi-second reasoner "thinking gap" — above all `speech_started`, so a
|
|
261
|
+
* barge-in mid-delegate still fires `interrupt.detected` and (via `interrupt.tts`) aborts the
|
|
262
|
+
* now-unwanted delegate before its answer is voiced over the user. Awaiting the delegate inside
|
|
263
|
+
* the pump loop (the old shape) froze barge-in for the entire gap. `runDelegate` sets
|
|
264
|
+
* `this.inflight` synchronously before its first await, so a subsequently-pumped barge-in sees
|
|
265
|
+
* a live controller to abort.
|
|
266
|
+
*/
|
|
267
|
+
private dispatchDelegate(
|
|
268
|
+
bus: PipelineBus,
|
|
269
|
+
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
270
|
+
): void {
|
|
271
|
+
this.delegateTask = this.runDelegate(bus, ev).catch((err) => {
|
|
272
|
+
if (!this.bus || isAbortError(err)) return;
|
|
273
|
+
this.onError(bus, err instanceof Error ? err : new Error(String(err)), false);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
210
277
|
private async runDelegate(
|
|
211
278
|
bus: PipelineBus,
|
|
212
279
|
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
213
280
|
): Promise<void> {
|
|
214
|
-
|
|
281
|
+
// Pin the turn's contextId now: the pump keeps advancing while the delegate streams, so
|
|
282
|
+
// this.contextId may move to a later turn before we finish. The tool_call/tool_result pair
|
|
283
|
+
// must stay bound to the turn that issued the call (R1: never merge parts across turns).
|
|
284
|
+
const contextId = this.contextId;
|
|
285
|
+
if (!contextId) return;
|
|
215
286
|
|
|
216
287
|
const toolCall: LlmToolCallPacket = {
|
|
217
288
|
kind: "llm.tool_call",
|
|
218
|
-
contextId
|
|
289
|
+
contextId,
|
|
219
290
|
timestampMs: Date.now(),
|
|
220
291
|
toolId: ev.toolId,
|
|
221
292
|
toolName: ev.toolName,
|
|
@@ -235,8 +306,22 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
235
306
|
return;
|
|
236
307
|
}
|
|
237
308
|
|
|
238
|
-
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
this.inflight = controller;
|
|
239
311
|
let answer = "";
|
|
312
|
+
let grounded = false;
|
|
313
|
+
|
|
314
|
+
// G2 observability: the query is on its way to the reasoner (Background route, R4).
|
|
315
|
+
const queryStartedMs = Date.now();
|
|
316
|
+
const delegateQuery: DelegateQueryPacket = {
|
|
317
|
+
kind: "delegate.query",
|
|
318
|
+
contextId,
|
|
319
|
+
timestampMs: queryStartedMs,
|
|
320
|
+
query: userText,
|
|
321
|
+
toolId: ev.toolId,
|
|
322
|
+
toolName: ev.toolName,
|
|
323
|
+
};
|
|
324
|
+
bus.push(Route.Background, delegateQuery);
|
|
240
325
|
|
|
241
326
|
try {
|
|
242
327
|
const messages = this.opts.contextProvider?.() ?? [];
|
|
@@ -244,13 +329,14 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
244
329
|
userText,
|
|
245
330
|
toolArgs: ev.args,
|
|
246
331
|
messages,
|
|
247
|
-
signal:
|
|
332
|
+
signal: controller.signal,
|
|
248
333
|
})) {
|
|
249
334
|
switch (part.type) {
|
|
250
335
|
case "text-delta":
|
|
251
336
|
answer += part.text;
|
|
252
337
|
break;
|
|
253
338
|
case "tool-result":
|
|
339
|
+
grounded = true;
|
|
254
340
|
break;
|
|
255
341
|
case "finish":
|
|
256
342
|
if (!answer && part.text) answer = part.text;
|
|
@@ -274,7 +360,8 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
274
360
|
this.onError(bus, err instanceof Error ? err : new Error(String(err)), false);
|
|
275
361
|
return;
|
|
276
362
|
} finally {
|
|
277
|
-
this.inflight
|
|
363
|
+
// Only clear if still ours — a newer delegate may have replaced this.inflight.
|
|
364
|
+
if (this.inflight === controller) this.inflight = undefined;
|
|
278
365
|
}
|
|
279
366
|
|
|
280
367
|
if (answer.length === 0) {
|
|
@@ -282,16 +369,41 @@ export class RealtimeBridge implements VoicePlugin {
|
|
|
282
369
|
return;
|
|
283
370
|
}
|
|
284
371
|
|
|
372
|
+
const answeredMs = Date.now();
|
|
285
373
|
const toolResult: LlmToolResultPacket = {
|
|
286
374
|
kind: "llm.tool_result",
|
|
287
|
-
contextId
|
|
288
|
-
timestampMs:
|
|
375
|
+
contextId,
|
|
376
|
+
timestampMs: answeredMs,
|
|
289
377
|
toolId: ev.toolId,
|
|
290
378
|
toolName: ev.toolName,
|
|
291
379
|
result: answer,
|
|
292
380
|
};
|
|
293
381
|
bus.push(Route.Main, toolResult);
|
|
294
|
-
|
|
382
|
+
// G2 observability: the reasoner produced the final answer (Background route, R4).
|
|
383
|
+
const delegateResult: DelegateResultPacket = {
|
|
384
|
+
kind: "delegate.result",
|
|
385
|
+
contextId,
|
|
386
|
+
timestampMs: answeredMs,
|
|
387
|
+
query: userText,
|
|
388
|
+
answer,
|
|
389
|
+
durationMs: answeredMs - queryStartedMs,
|
|
390
|
+
grounded,
|
|
391
|
+
toolId: ev.toolId,
|
|
392
|
+
toolName: ev.toolName,
|
|
393
|
+
};
|
|
394
|
+
bus.push(Route.Background, delegateResult);
|
|
395
|
+
this.adapter.injectToolResult(ev.toolId, this.formatToolResult(answer));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** G1: wrap the buffered delegate answer for the front model (synchronous — R2). */
|
|
399
|
+
private formatToolResult(answer: string): string {
|
|
400
|
+
if (this.opts.toolResultFormat === "string") return answer;
|
|
401
|
+
const envelope: DelegateResultEnvelope = {
|
|
402
|
+
response_text: answer,
|
|
403
|
+
require_repeat_verbatim: true,
|
|
404
|
+
...(this.opts.renderDirective ? { render: this.opts.renderDirective } : {}),
|
|
405
|
+
};
|
|
406
|
+
return JSON.stringify(envelope);
|
|
295
407
|
}
|
|
296
408
|
|
|
297
409
|
private onSpeechStarted(bus: PipelineBus): void {
|