You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
12 KiB
368 lines
12 KiB
import { randomUUID } from 'node:crypto';
|
|
import WebSocket from 'ws';
|
|
import type { ClientEvent, ServerEvent, SessionStartConfig } from '../../core/protocol.js';
|
|
import { formatToolError } from '../../core/tools.js';
|
|
import type { AnalyticsRecorder } from './analytics.js';
|
|
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from './provider.js';
|
|
import type { ToolRegistry } from './tools.js';
|
|
|
|
export type RealtimeSessionState = 'idle' | 'connecting' | 'active' | 'closing' | 'closed';
|
|
|
|
/** Server-owned policy for a realtime session. Never client-controlled. */
|
|
export interface SessionDefaults {
|
|
model: string;
|
|
instructions: string;
|
|
/** Used only when the browser's `session.start` doesn't specify a voice. */
|
|
defaultVoice?: string;
|
|
toolRegistry: ToolRegistry;
|
|
}
|
|
|
|
/**
|
|
* Merges the browser's (deliberately tiny) client-visible config into the
|
|
* server-owned defaults. `instructions`/`model`/`tools` always come from
|
|
* `defaults` — this is the trust boundary that keeps a shared relay safe to
|
|
* expose to untrusted browsers.
|
|
*/
|
|
export function resolveSessionConfig(
|
|
defaults: SessionDefaults,
|
|
clientConfig?: SessionStartConfig
|
|
): ResolvedSessionConfig {
|
|
return {
|
|
model: defaults.model,
|
|
instructions: defaults.instructions,
|
|
voice: clientConfig?.voice ?? defaults.defaultVoice,
|
|
tools: defaults.toolRegistry.definitions
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Owns the lifecycle of one browser WS connection: turns its first
|
|
* `session.start` into a `ProviderSession` via the injected `RealtimeProvider`,
|
|
* pipes events both directions afterward, and guarantees that either side
|
|
* closing/erroring tears down the other.
|
|
*/
|
|
export class RealtimeSession {
|
|
private state: RealtimeSessionState = 'idle';
|
|
private providerSession: ProviderSession | null = null;
|
|
/** Tool calls currently awaiting resolution in the current turn (server executor pending or client round-trip pending). */
|
|
private outstandingToolCalls = 0;
|
|
/** Client-executed tool calls forwarded to the browser, awaiting `tool.result`. */
|
|
private readonly pendingClientToolCalls = new Map<string, NodeJS.Timeout>();
|
|
/** Set once `start()` reaches 'active'; null before then and stays that way if the session never gets that far. */
|
|
private sessionId: string | null = null;
|
|
/** Populated at the top of handleToolCall (before any branching) so even the unknown-tool path is covered; consumed in resolveToolCall. */
|
|
private readonly toolCallMeta = new Map<
|
|
string,
|
|
{ name: string; arguments: string; startedAt: number }
|
|
>();
|
|
/** Assistant voice replies stream as many transcript.delta chunks then one text-less transcript.done — see docs/ANALYTICS.md. */
|
|
private assistantTranscriptBuffer = '';
|
|
|
|
constructor(
|
|
private readonly browserSocket: WebSocket,
|
|
private readonly provider: RealtimeProvider,
|
|
private readonly defaults: SessionDefaults,
|
|
private readonly recorder: AnalyticsRecorder,
|
|
/** How long a client-executed tool call may go unanswered before we give up on it. */
|
|
private readonly clientToolTimeoutMs: number
|
|
) {
|
|
this.browserSocket.on('message', (data, isBinary) => {
|
|
void this.handleBrowserMessage(data, isBinary);
|
|
});
|
|
this.browserSocket.on('close', () => this.close('client_disconnected'));
|
|
this.browserSocket.on('error', () => this.close('client_disconnected'));
|
|
}
|
|
|
|
getState(): RealtimeSessionState {
|
|
return this.state;
|
|
}
|
|
|
|
private async handleBrowserMessage(data: WebSocket.RawData, isBinary: boolean): Promise<void> {
|
|
if (isBinary) {
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Binary frames are not supported yet.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
let event: ClientEvent;
|
|
try {
|
|
event = JSON.parse(data.toString());
|
|
} catch {
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Malformed JSON.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (event.type === 'session.start') {
|
|
await this.start(event.config);
|
|
return;
|
|
}
|
|
if (event.type === 'session.end') {
|
|
this.close('client_requested');
|
|
return;
|
|
}
|
|
if (this.state !== 'active' || !this.providerSession) {
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: `Cannot handle '${event.type}' before session.start.`
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === 'tool.result') {
|
|
const timeout = this.pendingClientToolCalls.get(event.callId);
|
|
if (!timeout) return; // stale/unknown callId (e.g. already timed out) — ignore
|
|
clearTimeout(timeout);
|
|
this.pendingClientToolCalls.delete(event.callId);
|
|
this.resolveToolCall(event.callId, event.output);
|
|
return;
|
|
}
|
|
if (event.type === 'input_text.send') {
|
|
// Typed text is always already a complete string — unlike the voice
|
|
// path, no buffering is needed.
|
|
this.recorder.messageLogged({
|
|
sessionId: this.sessionId!,
|
|
role: 'user',
|
|
text: event.text,
|
|
loggedAt: Date.now()
|
|
});
|
|
}
|
|
this.providerSession.send(event);
|
|
}
|
|
|
|
private async start(clientConfig?: SessionStartConfig): Promise<void> {
|
|
if (this.state !== 'idle') {
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Session already started.'
|
|
});
|
|
return;
|
|
}
|
|
this.state = 'connecting';
|
|
const config = resolveSessionConfig(this.defaults, clientConfig);
|
|
|
|
let providerSession: ProviderSession;
|
|
try {
|
|
providerSession = await this.provider.createSession(config);
|
|
} catch (err) {
|
|
this.state = 'closed';
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'upstream_unavailable',
|
|
message: err instanceof Error ? err.message : 'Failed to start session.'
|
|
});
|
|
this.browserSocket.close();
|
|
return;
|
|
}
|
|
|
|
// The browser may have disconnected (session.end / close) while we were
|
|
// awaiting the provider handshake — don't leak the upstream connection.
|
|
const stateAfterConnect: RealtimeSessionState = this.state;
|
|
if (stateAfterConnect !== 'connecting') {
|
|
providerSession.close('client_requested');
|
|
return;
|
|
}
|
|
|
|
this.providerSession = providerSession;
|
|
const sessionId = randomUUID();
|
|
this.sessionId = sessionId;
|
|
this.state = 'active';
|
|
this.recorder.sessionStarted({
|
|
sessionId,
|
|
model: config.model,
|
|
voice: config.voice,
|
|
startedAt: Date.now()
|
|
});
|
|
// Recorded above before wiring onEvent, so there's no window where a
|
|
// provider event could reach handleProviderEvent before sessionId is set.
|
|
this.providerSession.onEvent((event) => this.handleProviderEvent(event));
|
|
this.sendToBrowser({ type: 'session.ready', sessionId });
|
|
}
|
|
|
|
private handleProviderEvent(event: ServerEvent): void {
|
|
if (event.type === 'tool.call') {
|
|
void this.handleToolCall(event);
|
|
return;
|
|
}
|
|
if (event.type === 'transcript.delta') {
|
|
if (event.role === 'assistant') {
|
|
this.assistantTranscriptBuffer += event.text;
|
|
} else {
|
|
// User voice transcripts arrive as a single already-complete chunk
|
|
// (conversation.item.input_audio_transcription.completed in
|
|
// providers/openai.ts) with no corresponding
|
|
// transcript.done{role:'user'} ever emitted today — log
|
|
// immediately rather than buffering. Rests on unverified live
|
|
// behavior; see docs/ANALYTICS.md.
|
|
this.recorder.messageLogged({
|
|
sessionId: this.sessionId!,
|
|
role: 'user',
|
|
text: event.text,
|
|
loggedAt: Date.now()
|
|
});
|
|
}
|
|
this.sendToBrowser(event);
|
|
return;
|
|
}
|
|
if (event.type === 'transcript.done' && event.role === 'assistant') {
|
|
const text = this.assistantTranscriptBuffer;
|
|
this.assistantTranscriptBuffer = '';
|
|
this.recorder.messageLogged({
|
|
sessionId: this.sessionId!,
|
|
role: 'assistant',
|
|
text,
|
|
loggedAt: Date.now()
|
|
});
|
|
this.sendToBrowser(event);
|
|
return;
|
|
}
|
|
if (event.type === 'session.closed') {
|
|
// Forward first, then close: close() moves the browser socket out of
|
|
// OPEN, and sendToBrowser no-ops once that happens, so closing first
|
|
// would silently swallow this notification.
|
|
this.sendToBrowser(event);
|
|
this.close('upstream_disconnected');
|
|
return;
|
|
}
|
|
this.sendToBrowser(event);
|
|
}
|
|
|
|
/**
|
|
* Routes a tool call by its registered `location`: a server tool is
|
|
* executed here directly (the browser never sees it); a client tool is
|
|
* forwarded to the browser and tracked with a timeout so an unresponsive
|
|
* tab can't hang the model's turn forever.
|
|
*/
|
|
private async handleToolCall(event: Extract<ServerEvent, { type: 'tool.call' }>): Promise<void> {
|
|
this.outstandingToolCalls++;
|
|
this.toolCallMeta.set(event.callId, {
|
|
name: event.name,
|
|
arguments: event.arguments,
|
|
startedAt: Date.now()
|
|
});
|
|
const definition = this.defaults.toolRegistry.definitions.find((d) => d.name === event.name);
|
|
if (!definition) {
|
|
this.resolveToolCall(event.callId, formatToolError(`Unknown tool "${event.name}".`));
|
|
return;
|
|
}
|
|
|
|
if (definition.location === 'client') {
|
|
this.sendToBrowser(event);
|
|
const timeout = setTimeout(() => {
|
|
this.pendingClientToolCalls.delete(event.callId);
|
|
this.resolveToolCall(event.callId, formatToolError('Client tool call timed out.'));
|
|
}, this.clientToolTimeoutMs);
|
|
this.pendingClientToolCalls.set(event.callId, timeout);
|
|
return;
|
|
}
|
|
|
|
let args: Record<string, unknown>;
|
|
try {
|
|
args = JSON.parse(event.arguments);
|
|
} catch {
|
|
this.resolveToolCall(event.callId, formatToolError('Malformed tool arguments.'));
|
|
return;
|
|
}
|
|
try {
|
|
const executor = this.defaults.toolRegistry.executors.get(event.name);
|
|
const result = await executor!(args);
|
|
this.resolveToolCall(event.callId, JSON.stringify(result));
|
|
} catch (err) {
|
|
this.resolveToolCall(
|
|
event.callId,
|
|
formatToolError(err instanceof Error ? err.message : 'Tool execution failed.')
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a tool's result to the provider and, once every tool call
|
|
* outstanding in the current turn has resolved, explicitly triggers the
|
|
* model to continue — see the `response.trigger` note in protocol.ts for
|
|
* why this can't just be bundled with each individual result.
|
|
*/
|
|
private resolveToolCall(callId: string, output: string): void {
|
|
this.sendToProvider({ type: 'tool.result', callId, output });
|
|
const meta = this.toolCallMeta.get(callId);
|
|
if (meta) {
|
|
// Should always be present given handleToolCall sets it for every
|
|
// path (including the unknown-tool branch) before this can run —
|
|
// guarded defensively rather than asserted, so a future refactor
|
|
// that breaks the invariant fails soft here instead of crashing.
|
|
this.toolCallMeta.delete(callId);
|
|
this.recorder.toolCallLogged({
|
|
sessionId: this.sessionId!,
|
|
callId,
|
|
name: meta.name,
|
|
arguments: meta.arguments,
|
|
output,
|
|
durationMs: Date.now() - meta.startedAt,
|
|
loggedAt: Date.now()
|
|
});
|
|
}
|
|
this.outstandingToolCalls--;
|
|
if (this.outstandingToolCalls === 0) {
|
|
this.sendToProvider({ type: 'response.trigger' });
|
|
}
|
|
}
|
|
|
|
private close(reason: string): void {
|
|
if (this.state === 'closing' || this.state === 'closed') return;
|
|
this.state = 'closing';
|
|
for (const timeout of this.pendingClientToolCalls.values()) clearTimeout(timeout);
|
|
this.pendingClientToolCalls.clear();
|
|
this.providerSession?.close(reason);
|
|
this.providerSession = null;
|
|
if (this.browserSocket.readyState === WebSocket.OPEN) {
|
|
this.browserSocket.close();
|
|
}
|
|
this.state = 'closed';
|
|
if (this.sessionId) {
|
|
// Guarded: sessionId is still null if the browser disconnected
|
|
// mid-handshake, before start() ever reached 'active' — no sessions
|
|
// row exists yet to update in that case.
|
|
this.recorder.sessionEnded({ sessionId: this.sessionId, endedAt: Date.now(), reason });
|
|
}
|
|
}
|
|
|
|
private sendToBrowser(event: ServerEvent): void {
|
|
if (event.type === 'error' || event.type === 'session.closed') {
|
|
// Logged before the delivery guard below: the event "happened" from
|
|
// the session's point of view even if the browser socket is already
|
|
// dead by the time we try to deliver it.
|
|
this.recorder.eventLogged({
|
|
sessionId: this.sessionId,
|
|
type: event.type,
|
|
detail: JSON.stringify(event),
|
|
loggedAt: Date.now()
|
|
});
|
|
}
|
|
if (this.browserSocket.readyState !== WebSocket.OPEN) return;
|
|
this.browserSocket.send(JSON.stringify(event));
|
|
}
|
|
|
|
// Guarded the same way sendToBrowser is: tool resolution can now reach
|
|
// the provider from an async callback (executor await, timeout) rather
|
|
// than only from a browser message already known to be in the 'active'
|
|
// state, so providerSession may have gone null by the time this runs.
|
|
private sendToProvider(event: ClientEvent): void {
|
|
this.providerSession?.send(event);
|
|
}
|
|
}
|
|
|
|
export function createRealtimeSession(
|
|
browserSocket: WebSocket,
|
|
provider: RealtimeProvider,
|
|
defaults: SessionDefaults,
|
|
recorder: AnalyticsRecorder,
|
|
clientToolTimeoutMs: number
|
|
): RealtimeSession {
|
|
return new RealtimeSession(browserSocket, provider, defaults, recorder, clientToolTimeoutMs);
|
|
}
|
|
|