Browse Source

Add tool calling (server- and client-executed) and a core/project ownership boundary

Extends the realtime session protocol with tool.call/tool.result/response.trigger
so the model can invoke functions that run either on our server or, relayed
through the session, in the browser (e.g. DOM interaction) — a single
outstanding-call counter lets a turn mix both without racing the model's
continuation. createToolRegistry enforces at startup that a client-only tool
can never accidentally be backed by server logic.

Also introduces a project/ namespace alongside core/, server/core/, and
client/core/, for code specific to whichever client project branches from
this template. Core never imports project; the two composition roots
(server.js, vite.config.ts) do the merge instead, so template updates can
still be pulled into a branched project without touching core files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Jonathan Hinkel 2 months ago
parent
commit
ac5bef624d
  1. 1
      AGENTS.md
  2. 3
      docs/ARCHITECTURE.md
  3. 51
      docs/TOOLS.md
  4. 7
      server.js
  5. 85
      src/lib/client/core/tool-dispatcher.spec.ts
  6. 50
      src/lib/client/core/tool-dispatcher.ts
  7. 12
      src/lib/client/project/tools/flash-page.ts
  8. 7
      src/lib/client/project/tools/index.ts
  9. 7
      src/lib/core/protocol.ts
  10. 20
      src/lib/core/tools.ts
  11. 28
      src/lib/project/tools.ts
  12. 6
      src/lib/server/core/defaults.ts
  13. 16
      src/lib/server/core/dev-plugin.ts
  14. 2
      src/lib/server/core/provider.ts
  15. 64
      src/lib/server/core/providers/openai.spec.ts
  16. 46
      src/lib/server/core/providers/openai.ts
  17. 222
      src/lib/server/core/session.spec.ts
  18. 104
      src/lib/server/core/session.ts
  19. 53
      src/lib/server/core/tools.spec.ts
  20. 51
      src/lib/server/core/tools.ts
  21. 5
      src/lib/server/project/tools/get-current-time.ts
  22. 13
      src/lib/server/project/tools/index.ts
  23. 6
      src/routes/demo/realtime/+page.svelte
  24. 7
      tsconfig.server.json
  25. 9
      vite.config.ts

1
AGENTS.md

@ -7,6 +7,7 @@ Base SvelteKit project meant to be branched off for future webavatar client proj
- `src/lib/core/` — shared client+server contracts (types, protocols). No SvelteKit-specific imports (`$env`, `$app`, etc.) — must stay portable.
- `src/lib/server/core/` — server-only implementation. SvelteKit enforces this boundary at build time; browser code cannot import from here, even transitively.
- `src/lib/client/core/` — thin browser-side wrappers around the shared contracts. Transport only, no business logic.
- `src/lib/project/`, `src/lib/server/project/`, `src/lib/client/project/` — mirrors the three directories above, but for code specific to whichever client project branches from this template (tool implementations, custom instructions, etc.), instead of reusable template infrastructure. **Core never imports from project, only the reverse** — see [docs/TOOLS.md](docs/TOOLS.md) for the concrete example this convention was introduced for.
- `src/routes/` — thin route glue; delegates into the above rather than holding logic itself.
- `docs/` — architecture and subsystem documentation (see "Documentation" below).

3
docs/ARCHITECTURE.md

@ -10,6 +10,8 @@ Code that's part of this core infrastructure lives under `src/lib/` in three pla
- **`src/lib/server/core/`** — server-only implementations: session orchestration, provider adapters, token minting/verification, raw HTTP upgrade handling. SvelteKit enforces this boundary at build time — browser code cannot import from here, even transitively.
- **`src/lib/client/core/`** — thin browser-side wrappers around the shared contracts. No business logic; just transport.
This repo is a base template meant to be branched per client project, so there's a second, parallel set of directories for code that's specific to whichever project branches from it — `src/lib/project/`, `src/lib/server/project/`, `src/lib/client/project/`, mirroring the three-way split above. **Core never imports from project, only the reverse** — that one-way dependency is what lets a branched project pull template updates without merge conflicts in core files. Where a project needs something from both (e.g. merging core session defaults with a project's tool registry), that merge happens at a composition root — a repo-root file like `server.js` or `vite.config.ts`, not under `src/lib` and not bound by the core/project layering — never inside a core file. See [TOOLS.md](TOOLS.md) for a concrete example of this split in practice.
Routes (`src/routes/`) stay thin and delegate into these — a route file is glue, not where logic lives.
## Foundational infrastructure decisions
@ -22,5 +24,6 @@ Routes (`src/routes/`) stay thin and delegate into these — a route file is glu
| Subsystem | Doc |
| --------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Realtime session management (OpenAI Realtime API today, extensible to other models/pipelines) | [REALTIME_SESSION.md](REALTIME_SESSION.md) |
| Tool calling (server- and client-executed) and the core/project ownership boundary | [TOOLS.md](TOOLS.md) |
Add a row here (and a corresponding doc) for each new subsystem as it's built — see the "Documentation" section in [AGENTS.md](../AGENTS.md) for when and how.

51
docs/TOOLS.md

@ -0,0 +1,51 @@
# Tool calling
Lets the model call functions during a realtime session — e.g. a RAG lookup, or a client-side DOM interaction. Builds on the [realtime session layer](REALTIME_SESSION.md); read that first if you haven't.
## Two execution locations, one mechanism
A tool call can be satisfied in two different places:
- **Server tools** run on our server (e.g. a RAG lookup hitting a vector DB, or anything touching real data/secrets).
- **Client tools** run in the browser (e.g. "highlight this element", "scroll to section") — the server can't do these itself.
From the protocol's point of view both look the same: the provider emits a normalized `{ type: 'tool.call', callId, name, arguments }` `ServerEvent` ([`protocol.ts`](../src/lib/core/protocol.ts)). What differs is what `RealtimeSession` (`session.ts`) does with it:
- A **server** tool is executed in-process — the browser never sees the call at all.
- A **client** tool is forwarded to the browser as-is; the browser runs its handler and replies with `{ type: 'tool.result', callId, output }`, which `RealtimeSession` relays up to the provider. If the browser never replies (disconnected tab, slow handler), a 10s timeout (`CLIENT_TOOL_TIMEOUT_MS` in `session.ts`) resolves the call with an error so the model's turn never hangs indefinitely.
Either way, the result reaches the provider as the same `tool.result` `ClientEvent` — `session.ts` constructs it directly for server tools, or receives it from the browser for client tools.
## The core/project ownership boundary
This repo is a template meant to be branched per client project. Everything under `core`/`server/core`/`client/core` is template infrastructure; everything under `project`/`server/project`/`client/project` is where a branched project's own code goes:
| Layer | Core (template, gets pulled forward) | Project (branched-client-specific) |
| ----------- | ------------------------------------------------------------- | ---------------------------------------------------------- |
| Isomorphic | `src/lib/core/tools.ts` — `ToolDefinition` shape | `src/lib/project/tools.ts` — this project's tool schemas |
| Server-only | `src/lib/server/core/tools.ts` — registry + validation | `src/lib/server/project/tools/` — this project's executors |
| Client-only | `src/lib/client/core/tool-dispatcher.ts` — dispatch mechanism | `src/lib/client/project/tools/` — this project's handlers |
**Rule: core never imports from project, only the reverse.** This is what lets a branched project pull template updates without merge conflicts in core files. In practice this means `server/core/defaults.ts` cannot import the project's tool registry directly — the merge happens at the two composition roots that are allowed to see both (`server.js` for production, `vite.config.ts` for dev, which passes the merged defaults into `dev-plugin.ts` as a parameter rather than having that core file import project code itself).
## Adding a tool
1. Add a `ToolDefinition` to `src/lib/project/tools.ts` — `name`, `description`, a JSON-schema `parameters` object, and `location: 'server' | 'client'`.
2. If `location: 'server'`, implement a `ToolExecutor` (`(args) => Promise<unknown>`) under `src/lib/server/project/tools/` and register it in that directory's `index.ts`.
3. If `location: 'client'`, implement a `ClientToolHandler` (same signature) under `src/lib/client/project/tools/` and register it in that directory's `index.ts`. Wire it wherever the page constructs its `RealtimeClient`, via `createToolDispatcher(client, CLIENT_TOOL_HANDLERS)` (see `/demo/realtime/+page.svelte` for the reference wiring).
`createToolRegistry` (`server/core/tools.ts`) validates the result at startup and throws if a server tool has no executor, a client tool _does_ have one registered (see next section), or an executor has no matching definition — a misconfigured tool fails loudly at boot, not silently the first time the model happens to call it.
## Why the client/server split is enforced, not just documented
A tool's `location` decides who executes it, and that decision has a trust dimension: a browser is less trusted than our server, so a tool that touches real user data or secrets must never be satisfiable by browser code — even by accident, e.g. someone tags a tool `location: 'client'` but a server executor also happens to get registered for the same name during a refactor. `createToolRegistry` makes that specific mistake impossible to ship: registering a server executor against a `location: 'client'` definition throws at construction time. There's no equivalent runtime check on the client side (a compromised or modified browser could technically claim to handle any tool), which is exactly why this must be enforced server-side, at the trust boundary — a client tool being `client`-routed is a guarantee about what our server will _accept as satisfying that tool_, not a claim about what the browser is capable of.
## Parallel tool calls and `response.trigger`
OpenAI's Realtime API can ask for multiple function calls in a single turn — e.g. one server tool and one client tool together. If every `tool.result` triggered the model to continue immediately (the way `input_text.send` already does for a plain user message), the first call to resolve would prompt a response before the others' results even exist.
Instead, `tool.result` never triggers continuation on its own. `RealtimeSession` tracks a per-session `outstandingToolCalls` counter, incremented the moment a call is routed and decremented when it resolves; only when it reaches zero does the orchestrator send an explicit `{ type: 'response.trigger' }` `ClientEvent`, which the OpenAI adapter maps to a bare `response.create`. This is a single counter per session, not scoped to an individual OpenAI response id — a deliberate simplification that holds under the normal case (OpenAI doesn't start a new response with more tool calls before outstanding ones are resolved), called out here as the thing most worth re-checking if parallel tool calls ever misbehave.
## What's unverified
The mapping from OpenAI's wire event for a completed function call (`response.output_item.done` with `item.type === 'function_call'`, in `providers/openai.ts`'s `fromOpenAiEvent`) is based on a docs fetch, not confirmed live traffic — this codebase has been burned by doc/reality drift on this exact API before (see the `output_` prefix gotcha in [REALTIME_SESSION.md](REALTIME_SESSION.md)), and there's no prior confirmed observation of `output_item.*` events in this codebase at all. If a tool call doesn't seem to reach `session.ts`, add a temporary `console.debug` in `fromOpenAiEvent`'s unmapped-event fallback, restart the dev server (this module isn't part of Vite's HMR graph), and read the real event name/shape from `preview_logs` rather than re-guessing from docs — same playbook as the previous gotcha.

7
server.js

@ -11,6 +11,7 @@ import { handler } from './build/handler.js';
import { DEFAULT_SESSION_DEFAULTS } from './build-server/server/core/defaults.js';
import { OpenAiRealtimeProvider } from './build-server/server/core/providers/openai.js';
import { handleRealtimeUpgrade } from './build-server/server/core/ws-handler.js';
import { toolRegistry } from './build-server/server/project/tools/index.js';
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
@ -18,10 +19,14 @@ if (!apiKey) {
}
const provider = new OpenAiRealtimeProvider({ apiKey });
// server/core never imports project code directly (see docs/TOOLS.md) — this
// composition root is one of the two places (alongside vite.config.ts) that
// merges core defaults with the project's tool registry.
const defaults = { ...DEFAULT_SESSION_DEFAULTS, toolRegistry };
const server = createServer(handler);
server.on('upgrade', (req, socket, head) => {
handleRealtimeUpgrade(req, socket, head, { provider, defaults: DEFAULT_SESSION_DEFAULTS });
handleRealtimeUpgrade(req, socket, head, { provider, defaults });
});
const port = process.env.PORT ? Number(process.env.PORT) : 3000;

85
src/lib/client/core/tool-dispatcher.spec.ts

@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import type { ServerEvent } from '../../core/protocol.js';
import type { RealtimeClient } from './client.js';
import { createToolDispatcher } from './tool-dispatcher.js';
/** Minimal stand-in for RealtimeClient — only what createToolDispatcher touches. */
class FakeClient {
private handler: ((event: ServerEvent) => void) | null = null;
sent: unknown[] = [];
on(handler: (event: ServerEvent) => void): () => void {
this.handler = handler;
return () => {
this.handler = null;
};
}
send(event: unknown): void {
this.sent.push(event);
}
emit(event: ServerEvent): void {
this.handler?.(event);
}
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe('createToolDispatcher', () => {
it('dispatches to the matching handler and sends back its result', async () => {
const client = new FakeClient();
const handler = vi.fn().mockResolvedValue({ ok: true });
createToolDispatcher(client as unknown as RealtimeClient, { my_tool: handler });
client.emit({ type: 'tool.call', callId: 'call-1', name: 'my_tool', arguments: '{"x":1}' });
await flush();
expect(handler).toHaveBeenCalledWith({ x: 1 });
expect(client.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: JSON.stringify({ ok: true })
});
});
it('sends an error result when no handler matches', async () => {
const client = new FakeClient();
createToolDispatcher(client as unknown as RealtimeClient, {});
client.emit({ type: 'tool.call', callId: 'call-1', name: 'missing_tool', arguments: '{}' });
await flush();
expect(client.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: JSON.stringify({ error: 'No client handler for tool "missing_tool".' })
});
});
it('sends an error result when the handler rejects', async () => {
const client = new FakeClient();
const handler = vi.fn().mockRejectedValue(new Error('boom'));
createToolDispatcher(client as unknown as RealtimeClient, { my_tool: handler });
client.emit({ type: 'tool.call', callId: 'call-1', name: 'my_tool', arguments: '{}' });
await flush();
expect(client.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: JSON.stringify({ error: 'boom' })
});
});
it('ignores non-tool.call server events', () => {
const client = new FakeClient();
createToolDispatcher(client as unknown as RealtimeClient, {});
client.emit({ type: 'response.done' });
expect(client.sent).toEqual([]);
});
});

50
src/lib/client/core/tool-dispatcher.ts

@ -0,0 +1,50 @@
import { formatToolError } from '../../core/tools.js';
import type { RealtimeClient } from './client.js';
export type ClientToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
/**
* Wires a `RealtimeClient` to a map of client-side tool handlers: listens
* for `tool.call`, runs the matching handler, and sends its result back —
* kept out of `RealtimeClient` itself, which stays transport-only. Handler
* implementations are project-owned (`$lib/client/project/tools`); this is
* just the generic dispatch mechanism.
*
* Returns an unsubscribe function, matching `RealtimeClient.on()`'s convention.
*/
export function createToolDispatcher(
client: RealtimeClient,
handlers: Record<string, ClientToolHandler>
): () => void {
return client.on((event) => {
if (event.type !== 'tool.call') return;
void dispatch(client, handlers, event);
});
}
async function dispatch(
client: RealtimeClient,
handlers: Record<string, ClientToolHandler>,
event: { callId: string; name: string; arguments: string }
): Promise<void> {
const handler = handlers[event.name];
if (!handler) {
client.send({
type: 'tool.result',
callId: event.callId,
output: formatToolError(`No client handler for tool "${event.name}".`)
});
return;
}
try {
const args = JSON.parse(event.arguments);
const result = await handler(args);
client.send({ type: 'tool.result', callId: event.callId, output: JSON.stringify(result) });
} catch (err) {
client.send({
type: 'tool.result',
callId: event.callId,
output: formatToolError(err instanceof Error ? err.message : 'Tool execution failed.')
});
}
}

12
src/lib/client/project/tools/flash-page.ts

@ -0,0 +1,12 @@
import type { ClientToolHandler } from '../../core/tool-dispatcher.js';
/** Briefly flashes the page background — proves the client tool relay round-trip visibly. */
export const flashPage: ClientToolHandler = async () => {
const original = document.body.style.transition;
document.body.style.transition = 'background-color 150ms ease';
document.body.style.backgroundColor = '#fef08a';
await new Promise((resolve) => setTimeout(resolve, 300));
document.body.style.backgroundColor = '';
document.body.style.transition = original;
return { flashed: true };
};

7
src/lib/client/project/tools/index.ts

@ -0,0 +1,7 @@
import type { ClientToolHandler } from '../../core/tool-dispatcher.js';
import { flashPage } from './flash-page.js';
/** This project's client-side tool handlers, keyed by tool name. */
export const CLIENT_TOOL_HANDLERS: Record<string, ClientToolHandler> = {
flash_page: flashPage
};

7
src/lib/core/protocol.ts

@ -26,6 +26,12 @@ export type ClientEvent =
| { type: 'input_audio.commit' }
| { type: 'input_text.send'; text: string }
| { type: 'response.cancel' }
| { type: 'tool.result'; callId: string; output: string }
// Resumes the model after all outstanding tool calls in a turn are
// resolved. Sent only by RealtimeSession itself (see session.ts) — never
// by the browser directly, since a turn can contain multiple parallel
// tool calls and only the last one resolving should trigger this.
| { type: 'response.trigger' }
| { type: 'session.end' };
export type RealtimeErrorCode =
@ -42,5 +48,6 @@ export type ServerEvent =
| { type: 'transcript.delta'; role: 'user' | 'assistant'; text: string }
| { type: 'transcript.done'; role: 'user' | 'assistant' }
| { type: 'response.done' }
| { type: 'tool.call'; callId: string; name: string; arguments: string }
| { type: 'error'; code: RealtimeErrorCode; message: string }
| { type: 'session.closed'; reason: string };

20
src/lib/core/tools.ts

@ -0,0 +1,20 @@
/**
* Isomorphic tool-calling types shared by server and client. A project
* defines its own tools under `$lib/project/tools.ts` conforming to
* `ToolDefinition` — see `$lib/server/core/tools.ts` for how `location`
* routes execution.
*/
export interface ToolDefinition {
name: string;
description: string;
/** JSON schema for the tool's arguments, passed through to the provider as-is. */
parameters: Record<string, unknown>;
/** Where this tool executes — see `$lib/server/core/tools.ts`'s `createToolRegistry`. */
location: 'server' | 'client';
}
/** Shared error-output shape so server executors and client handlers produce identical results. */
export function formatToolError(message: string): string {
return JSON.stringify({ error: message });
}

28
src/lib/project/tools.ts

@ -0,0 +1,28 @@
import type { ToolDefinition } from '../core/tools.js';
/**
* Project-owned tool definitions — isomorphic (needed by the server at
* session-start to declare tools to the provider, but the schema itself is
* neither server- nor client-only). A branched client project edits this
* file freely; core never imports from it (see docs/TOOLS.md).
*
* The two examples below exercise both execution paths end-to-end and are
* meant to be replaced by real tools (e.g. a RAG lookup) — see
* src/lib/server/project/tools/ for the `get_current_time` executor and
* src/lib/client/project/tools/ for the `flash_page` handler.
*/
export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: 'get_current_time',
description: 'Get the current server time. Use this if the user asks what time it is.',
parameters: { type: 'object', properties: {}, required: [] },
location: 'server'
},
{
name: 'flash_page',
description:
"Briefly flash the page background to get the user's visual attention. Use this when the user asks you to demonstrate a UI action.",
parameters: { type: 'object', properties: {}, required: [] },
location: 'client'
}
];

6
src/lib/server/core/defaults.ts

@ -5,8 +5,12 @@ import type { SessionDefaults } from './session.js';
* production custom server, so both environments start sessions identically.
* A real client project will likely replace this with its own
* persona/instructions, possibly per-route rather than a single constant.
*
* Deliberately missing `toolRegistry`: this file is core, and core never
* imports project code (see docs/TOOLS.md) — the composition roots
* (server.js, vite.config.ts) merge this with the project's tool registry.
*/
export const DEFAULT_SESSION_DEFAULTS: SessionDefaults = {
export const DEFAULT_SESSION_DEFAULTS: Omit<SessionDefaults, 'toolRegistry'> = {
model: 'gpt-realtime',
instructions: 'You are a helpful voice assistant for a webavatar demo. Keep responses concise.'
};

16
src/lib/server/core/dev-plugin.ts

@ -1,8 +1,18 @@
import { loadEnv, type Plugin } from 'vite';
import { DEFAULT_SESSION_DEFAULTS } from './defaults.js';
import { OpenAiRealtimeProvider } from './providers/openai.js';
import type { SessionDefaults } from './session.js';
import { handleRealtimeUpgrade } from './ws-handler.js';
export interface RealtimeDevWsPluginOptions {
/**
* Merged session defaults (core defaults + project tool registry) — taken
* as a parameter rather than imported here so this module (server/core)
* never has to import project code directly. See vite.config.ts, the
* composition root that does the merge, and docs/TOOLS.md.
*/
defaults: SessionDefaults;
}
/**
* Wires the same upgrade handling used in production (see repo-root
* server.js) into Vite's dev server, so `npm run dev` supports the realtime
@ -13,7 +23,7 @@ import { handleRealtimeUpgrade } from './ws-handler.js';
* see the note in ./tokens.ts for why this whole module tree reads
* `process.env` directly instead.
*/
export function realtimeDevWsPlugin(): Plugin {
export function realtimeDevWsPlugin(options: RealtimeDevWsPluginOptions): Plugin {
return {
name: 'webavatar-realtime-ws-dev',
configureServer(server) {
@ -35,7 +45,7 @@ export function realtimeDevWsPlugin(): Plugin {
const provider = new OpenAiRealtimeProvider({ apiKey: apiKey ?? '' });
server.httpServer?.on('upgrade', (req, socket, head) => {
handleRealtimeUpgrade(req, socket, head, { provider, defaults: DEFAULT_SESSION_DEFAULTS });
handleRealtimeUpgrade(req, socket, head, { provider, defaults: options.defaults });
});
}
};

2
src/lib/server/core/provider.ts

@ -1,4 +1,5 @@
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ToolDefinition } from '../../core/tools.js';
/**
* Fully-resolved, server-trusted session config. Never expose this shape to
@ -10,6 +11,7 @@ export interface ResolvedSessionConfig {
model: string;
instructions: string;
voice?: string;
tools: ToolDefinition[];
}
/**

64
src/lib/server/core/providers/openai.spec.ts

@ -1,5 +1,24 @@
import { describe, expect, it } from 'vitest';
import { fromOpenAiEvent, toOpenAiEvents } from './openai.js';
import type { ToolDefinition } from '../../../core/tools.js';
import { fromOpenAiEvent, toOpenAiEvents, toOpenAiTool } from './openai.js';
const TOOL: ToolDefinition = {
name: 'lookup_thing',
description: 'Looks up a thing.',
parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
location: 'server'
};
describe('toOpenAiTool', () => {
it('maps a ToolDefinition to the OpenAI function-tool wire shape, dropping location', () => {
expect(toOpenAiTool(TOOL)).toEqual({
type: 'function',
name: 'lookup_thing',
description: 'Looks up a thing.',
parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }
});
});
});
describe('toOpenAiEvents', () => {
it('translates an audio chunk to input_audio_buffer.append', () => {
@ -32,6 +51,21 @@ describe('toOpenAiEvents', () => {
expect(toOpenAiEvents({ type: 'session.start' })).toEqual([]);
expect(toOpenAiEvents({ type: 'session.end' })).toEqual([]);
});
it('translates a tool result to a function_call_output item, without a bundled response.create', () => {
expect(
toOpenAiEvents({ type: 'tool.result', callId: 'call_123', output: '{"ok":true}' })
).toEqual([
{
type: 'conversation.item.create',
item: { type: 'function_call_output', call_id: 'call_123', output: '{"ok":true}' }
}
]);
});
it('translates response.trigger to a bare response.create', () => {
expect(toOpenAiEvents({ type: 'response.trigger' })).toEqual([{ type: 'response.create' }]);
});
});
describe('fromOpenAiEvent', () => {
@ -88,4 +122,32 @@ describe('fromOpenAiEvent', () => {
expect(fromOpenAiEvent({ type: 'session.created' })).toBeNull();
expect(fromOpenAiEvent({ type: 'rate_limits.updated' })).toBeNull();
});
it('translates a completed function_call output item to tool.call', () => {
expect(
fromOpenAiEvent({
type: 'response.output_item.done',
item: {
type: 'function_call',
name: 'lookup_thing',
call_id: 'call_123',
arguments: '{"id":"42"}'
}
})
).toEqual({
type: 'tool.call',
callId: 'call_123',
name: 'lookup_thing',
arguments: '{"id":"42"}'
});
});
it('ignores response.output_item.done for non-function-call items', () => {
expect(
fromOpenAiEvent({
type: 'response.output_item.done',
item: { type: 'message' }
})
).toBeNull();
});
});

46
src/lib/server/core/providers/openai.ts

@ -1,5 +1,6 @@
import WebSocket from 'ws';
import type { ClientEvent, ServerEvent } from '../../../core/protocol.js';
import type { ToolDefinition } from '../../../core/tools.js';
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from '../provider.js';
const REALTIME_URL = 'wss://api.openai.com/v1/realtime';
@ -41,7 +42,9 @@ export class OpenAiRealtimeProvider implements RealtimeProvider {
model: config.model,
instructions: config.instructions,
voice: config.voice,
output_modalities: ['audio']
output_modalities: ['audio'],
tools: config.tools.map(toOpenAiTool),
tool_choice: 'auto'
}
})
);
@ -111,6 +114,16 @@ class OpenAiProviderSession implements ProviderSession {
}
}
// Exported for direct unit testing without spinning up a real session.
export function toOpenAiTool(definition: ToolDefinition): Record<string, unknown> {
return {
type: 'function',
name: definition.name,
description: definition.description,
parameters: definition.parameters
};
}
// Exported (alongside fromOpenAiEvent below) for direct unit testing of the
// translation logic, without spinning up a real upstream WebSocket.
export function toOpenAiEvents(event: ClientEvent): Array<Record<string, unknown>> {
@ -133,6 +146,19 @@ export function toOpenAiEvents(event: ClientEvent): Array<Record<string, unknown
];
case 'response.cancel':
return [{ type: 'response.cancel' }];
case 'tool.result':
return [
{
type: 'conversation.item.create',
item: { type: 'function_call_output', call_id: event.callId, output: event.output }
}
];
// Deliberately separate from 'tool.result' above — a turn can contain
// multiple parallel tool calls, and the orchestrator (session.ts) only
// sends this once all of them have resolved. See the response.trigger
// note in ../../core/protocol.ts.
case 'response.trigger':
return [{ type: 'response.create' }];
// 'session.start' and 'session.end' are handled by the orchestrator
// (RealtimeSession) before reaching a provider — see ../session.ts.
default:
@ -158,6 +184,24 @@ export function fromOpenAiEvent(raw: Record<string, unknown>): ServerEvent | nul
return { type: 'transcript.delta', role: 'user', text: raw.transcript as string };
case 'response.done':
return { type: 'response.done' };
// UNVERIFIED against live traffic (see docs/REALTIME_SESSION.md's
// tool-calling section) — confirmed via docs that a completed function
// call item looks like { type: 'function_call', name, call_id,
// arguments }, but only confirmed inside response.done's output[]
// array, not literally on this per-item lifecycle event. If tool
// calls silently don't fire, check the unmapped-event fallback below
// via console.debug + a dev-server restart (this module isn't part of
// Vite's HMR graph) before assuming the implementation is broken.
case 'response.output_item.done': {
const item = raw.item as Record<string, unknown> | undefined;
if (item?.type !== 'function_call') return null;
return {
type: 'tool.call',
callId: item.call_id as string,
name: item.name as string,
arguments: item.arguments as string
};
}
case 'error': {
const err = raw.error as { message?: string } | undefined;
return {

222
src/lib/server/core/session.spec.ts

@ -1,11 +1,17 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type WebSocket from 'ws';
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ToolDefinition } from '../../core/tools.js';
import type { ProviderSession, RealtimeProvider } from './provider.js';
import { createRealtimeSession, resolveSessionConfig } from './session.js';
import { createToolRegistry } from './tools.js';
const DEFAULTS = { model: 'test-model', instructions: 'be helpful' };
const DEFAULTS = {
model: 'test-model',
instructions: 'be helpful',
toolRegistry: createToolRegistry([], {})
};
/** Minimal stand-in for a `ws` WebSocket — only what RealtimeSession touches. */
class FakeBrowserSocket extends EventEmitter {
@ -84,11 +90,12 @@ function makeSocket() {
}
describe('resolveSessionConfig', () => {
it('always uses server-owned model/instructions, merging only the client-visible voice', () => {
it('always uses server-owned model/instructions/tools, merging only the client-visible voice', () => {
expect(resolveSessionConfig(DEFAULTS, { voice: 'coral' })).toEqual({
model: 'test-model',
instructions: 'be helpful',
voice: 'coral'
voice: 'coral',
tools: []
});
});
@ -96,7 +103,8 @@ describe('resolveSessionConfig', () => {
expect(resolveSessionConfig(DEFAULTS)).toEqual({
model: 'test-model',
instructions: 'be helpful',
voice: undefined
voice: undefined,
tools: []
});
});
});
@ -203,3 +211,207 @@ describe('RealtimeSession', () => {
await vi.waitFor(() => expect(provider.sessions[0].closed).toBe(true));
});
});
const FAST_TOOL: ToolDefinition = {
name: 'fast_tool',
description: 'Resolves immediately.',
parameters: { type: 'object', properties: {}, required: [] },
location: 'server'
};
const SLOW_TOOL: ToolDefinition = {
name: 'slow_tool',
description: 'Resolves once released.',
parameters: { type: 'object', properties: {}, required: [] },
location: 'server'
};
const CLIENT_TOOL: ToolDefinition = {
name: 'client_tool',
description: 'Executed in the browser.',
parameters: { type: 'object', properties: {}, required: [] },
location: 'client'
};
async function startActiveSession(defaults: typeof DEFAULTS) {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, defaults);
socket.receive({ type: 'session.start' });
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
return { provider, session: provider.sessions[0], socket };
}
describe('RealtimeSession tool calls', () => {
afterEach(() => {
vi.useRealTimers();
});
it('executes a server tool and sends its result plus response.trigger to the provider', async () => {
const toolRegistry = createToolRegistry([FAST_TOOL], {
fast_tool: async () => ({ done: true })
});
const { session } = await startActiveSession({ ...DEFAULTS, toolRegistry });
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-1',
name: 'fast_tool',
arguments: '{}'
});
await vi.waitFor(() =>
expect(session.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: JSON.stringify({ done: true })
})
);
await vi.waitFor(() => expect(session.sent).toContainEqual({ type: 'response.trigger' }));
});
it('only triggers response.trigger once every parallel tool call has resolved', async () => {
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => (releaseSlow = resolve));
const toolRegistry = createToolRegistry([FAST_TOOL, SLOW_TOOL], {
fast_tool: async () => ({ done: 'fast' }),
slow_tool: async () => {
await slowGate;
return { done: 'slow' };
}
});
const { session } = await startActiveSession({ ...DEFAULTS, toolRegistry });
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-fast',
name: 'fast_tool',
arguments: '{}'
});
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-slow',
name: 'slow_tool',
arguments: '{}'
});
await vi.waitFor(() =>
expect(session.sent).toContainEqual({
type: 'tool.result',
callId: 'call-fast',
output: JSON.stringify({ done: 'fast' })
})
);
expect(session.sent).not.toContainEqual({ type: 'response.trigger' });
releaseSlow();
await vi.waitFor(() =>
expect(session.sent).toContainEqual({
type: 'tool.result',
callId: 'call-slow',
output: JSON.stringify({ done: 'slow' })
})
);
expect(session.sent).toContainEqual({ type: 'response.trigger' });
});
it('forwards a client tool call to the browser and relays its result to the provider', async () => {
const toolRegistry = createToolRegistry([CLIENT_TOOL], {});
const { session, socket } = await startActiveSession({ ...DEFAULTS, toolRegistry });
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-1',
name: 'client_tool',
arguments: '{}'
});
await vi.waitFor(() =>
expect(socket.sent).toContainEqual({
type: 'tool.call',
callId: 'call-1',
name: 'client_tool',
arguments: '{}'
})
);
socket.receive({ type: 'tool.result', callId: 'call-1', output: '{"ok":true}' });
await vi.waitFor(() =>
expect(session.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: '{"ok":true}'
})
);
expect(session.sent).toContainEqual({ type: 'response.trigger' });
});
it('resolves an unanswered client tool call with an error after the timeout, without hanging', async () => {
const toolRegistry = createToolRegistry([CLIENT_TOOL], {});
const { session, socket } = await startActiveSession({ ...DEFAULTS, toolRegistry });
vi.useFakeTimers();
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-1',
name: 'client_tool',
arguments: '{}'
});
expect(socket.sent).toContainEqual({
type: 'tool.call',
callId: 'call-1',
name: 'client_tool',
arguments: '{}'
});
await vi.advanceTimersByTimeAsync(10_000);
expect(session.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: JSON.stringify({ error: 'Client tool call timed out.' })
});
expect(session.sent).toContainEqual({ type: 'response.trigger' });
});
it('resolves immediately with an error for an unknown tool name', async () => {
const { session } = await startActiveSession(DEFAULTS);
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-1',
name: 'nonexistent_tool',
arguments: '{}'
});
await vi.waitFor(() =>
expect(session.sent).toContainEqual({
type: 'tool.result',
callId: 'call-1',
output: JSON.stringify({ error: 'Unknown tool "nonexistent_tool".' })
})
);
expect(session.sent).toContainEqual({ type: 'response.trigger' });
});
it('clears a pending client tool timer on close, without throwing when it would have fired', async () => {
const toolRegistry = createToolRegistry([CLIENT_TOOL], {});
const { provider, session, socket } = await startActiveSession({ ...DEFAULTS, toolRegistry });
vi.useFakeTimers();
session.emitToOrchestrator({
type: 'tool.call',
callId: 'call-1',
name: 'client_tool',
arguments: '{}'
});
socket.emit('close'); // synchronous: RealtimeSession.close() tears down the provider session inline
expect(provider.sessions[0].closed).toBe(true);
// If the timer weren't cleared, this would try to send through a
// now-null providerSession — sendToProvider's optional-chaining guard
// (rather than a throw) is what's being verified here: awaiting this
// without the test failing means the timer callback didn't throw.
await vi.advanceTimersByTimeAsync(10_000);
});
});

104
src/lib/server/core/session.ts

@ -1,21 +1,27 @@
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 { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from './provider.js';
import type { ToolRegistry } from './tools.js';
export type RealtimeSessionState = 'idle' | 'connecting' | 'active' | 'closing' | 'closed';
/** How long a client-executed tool call may go unanswered before we give up on it. */
const CLIENT_TOOL_TIMEOUT_MS = 10_000;
/** Server-owned policy for a realtime session. Never client-controlled. */
export interface SessionDefaults {
model: string;
instructions: string;
toolRegistry: ToolRegistry;
}
/**
* Merges the browser's (deliberately tiny) client-visible config into the
* server-owned defaults. `instructions`/`model` always come from `defaults`
* — this is the trust boundary that keeps a shared relay safe to expose to
* untrusted browsers.
* 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,
@ -24,7 +30,8 @@ export function resolveSessionConfig(
return {
model: defaults.model,
instructions: defaults.instructions,
voice: clientConfig?.voice
voice: clientConfig?.voice,
tools: defaults.toolRegistry.definitions
};
}
@ -37,6 +44,10 @@ export function resolveSessionConfig(
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>();
constructor(
private readonly browserSocket: WebSocket,
@ -92,6 +103,14 @@ export class RealtimeSession {
});
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;
}
this.providerSession.send(event);
}
@ -130,14 +149,81 @@ export class RealtimeSession {
}
this.providerSession = providerSession;
this.providerSession.onEvent((event) => this.sendToBrowser(event));
this.providerSession.onEvent((event) => this.handleProviderEvent(event));
this.state = 'active';
this.sendToBrowser({ type: 'session.ready', sessionId: randomUUID() });
}
private handleProviderEvent(event: ServerEvent): void {
if (event.type === 'tool.call') {
void this.handleToolCall(event);
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++;
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.'));
}, CLIENT_TOOL_TIMEOUT_MS);
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 });
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) {
@ -150,6 +236,14 @@ export class RealtimeSession {
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(

53
src/lib/server/core/tools.spec.ts

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import type { ToolDefinition } from '../../core/tools.js';
import { createToolRegistry } from './tools.js';
const SERVER_TOOL: ToolDefinition = {
name: 'server_tool',
description: 'A server-executed tool.',
parameters: { type: 'object', properties: {}, required: [] },
location: 'server'
};
const CLIENT_TOOL: ToolDefinition = {
name: 'client_tool',
description: 'A client-executed tool.',
parameters: { type: 'object', properties: {}, required: [] },
location: 'client'
};
describe('createToolRegistry', () => {
it('builds a registry when every server tool has an executor and no client tool does', () => {
const registry = createToolRegistry([SERVER_TOOL, CLIENT_TOOL], {
server_tool: async () => ({ ok: true })
});
expect(registry.definitions).toEqual([SERVER_TOOL, CLIENT_TOOL]);
expect(registry.executors.has('server_tool')).toBe(true);
expect(registry.executors.has('client_tool')).toBe(false);
});
it('is fine with no tools at all', () => {
const registry = createToolRegistry([], {});
expect(registry.definitions).toEqual([]);
expect(registry.executors.size).toBe(0);
});
it('throws when a server tool has no registered executor', () => {
expect(() => createToolRegistry([SERVER_TOOL], {})).toThrow(/server_tool/);
});
it('throws when a client-only tool has a server executor registered', () => {
expect(() => createToolRegistry([CLIENT_TOOL], { client_tool: async () => ({}) })).toThrow(
/client_tool/
);
});
it('throws when an executor is registered with no matching tool definition', () => {
expect(() =>
createToolRegistry([SERVER_TOOL], {
server_tool: async () => ({}),
orphan_tool: async () => ({})
})
).toThrow(/orphan_tool/);
});
});

51
src/lib/server/core/tools.ts

@ -0,0 +1,51 @@
import type { ToolDefinition } from '../../core/tools.js';
export type ToolExecutor = (args: Record<string, unknown>) => Promise<unknown>;
export interface ToolRegistry {
definitions: ToolDefinition[];
executors: Map<string, ToolExecutor>;
}
/**
* The policy guardrail for tool ownership: throws at construction (startup,
* fail loud) rather than letting a misconfigured tool ship silently.
*
* - Every `location: 'server'` definition must have a matching executor —
* otherwise a server tool call would have nothing to run.
* - No `location: 'client'` definition may have a matching executor
* registered — a tool tagged client-only can never accidentally also be
* backed by server business logic. If it needs server logic, it isn't a
* client tool.
* - Every executor must have a matching definition — no orphaned executors.
*/
export function createToolRegistry(
definitions: ToolDefinition[],
executors: Record<string, ToolExecutor>
): ToolRegistry {
const executorMap = new Map(Object.entries(executors));
const definitionNames = new Set(definitions.map((d) => d.name));
for (const definition of definitions) {
const hasExecutor = executorMap.has(definition.name);
if (definition.location === 'server' && !hasExecutor) {
throw new Error(
`Tool "${definition.name}" is location: 'server' but has no registered executor.`
);
}
if (definition.location === 'client' && hasExecutor) {
throw new Error(
`Tool "${definition.name}" is location: 'client' but has a server executor registered. ` +
`A client-only tool must never be backed by server logic — if it needs server logic, ` +
`it isn't a client tool.`
);
}
}
for (const name of executorMap.keys()) {
if (!definitionNames.has(name)) {
throw new Error(`Executor registered for "${name}" has no matching tool definition.`);
}
}
return { definitions, executors: executorMap };
}

5
src/lib/server/project/tools/get-current-time.ts

@ -0,0 +1,5 @@
import type { ToolExecutor } from '../../core/tools.js';
export const getCurrentTime: ToolExecutor = async () => {
return { iso: new Date().toISOString() };
};

13
src/lib/server/project/tools/index.ts

@ -0,0 +1,13 @@
import { createToolRegistry } from '../../core/tools.js';
import { TOOL_DEFINITIONS } from '../../../project/tools.js';
import { getCurrentTime } from './get-current-time.js';
/**
* Assembles this project's server-side tool executors against the shared
* definitions and validates the result — see `createToolRegistry` for the
* policy this enforces (every server tool has an executor, no client tool
* has one). Add a new server executor here as it's implemented.
*/
export const toolRegistry = createToolRegistry(TOOL_DEFINITIONS, {
get_current_time: getCurrentTime
});

6
src/routes/demo/realtime/+page.svelte

@ -1,6 +1,8 @@
<script lang="ts">
import { RealtimeClient } from '$lib/client/core/client.js';
import { createToolDispatcher } from '$lib/client/core/tool-dispatcher.js';
import type { ServerEvent } from '$lib/core/protocol.js';
import { CLIENT_TOOL_HANDLERS } from '$lib/client/project/tools/index.js';
type Status = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
type TranscriptEntry = { role: 'user' | 'assistant'; text: string };
@ -11,6 +13,7 @@
let draft = $state('');
let client: RealtimeClient | null = null;
let stopToolDispatch: (() => void) | null = null;
async function connect() {
status = 'connecting';
@ -24,6 +27,7 @@
nextClient.on(handleServerEvent);
await nextClient.connect(token);
client = nextClient;
stopToolDispatch = createToolDispatcher(nextClient, CLIENT_TOOL_HANDLERS);
client.send({ type: 'session.start' });
} catch (err) {
status = 'error';
@ -71,6 +75,8 @@
client?.send({ type: 'session.end' });
client?.close();
client = null;
stopToolDispatch?.();
stopToolDispatch = null;
status = 'closed';
}
</script>

7
tsconfig.server.json

@ -12,6 +12,11 @@
"declaration": false,
"types": ["node"]
},
"include": ["src/lib/core/**/*.ts", "src/lib/server/core/**/*.ts"],
"include": [
"src/lib/core/**/*.ts",
"src/lib/server/core/**/*.ts",
"src/lib/project/**/*.ts",
"src/lib/server/project/**/*.ts"
],
"exclude": ["**/*.spec.ts"]
}

9
vite.config.ts

@ -2,7 +2,14 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { DEFAULT_SESSION_DEFAULTS } from './src/lib/server/core/defaults.js';
import { realtimeDevWsPlugin } from './src/lib/server/core/dev-plugin.js';
import { toolRegistry } from './src/lib/server/project/tools/index.js';
// Composition root: merges core defaults with the project's tool registry.
// server/core never imports project code directly (see docs/TOOLS.md) — this
// is one of the two places (alongside server.js) that's allowed to.
const realtimeDefaults = { ...DEFAULT_SESSION_DEFAULTS, toolRegistry };
export default defineConfig({
plugins: [
@ -20,7 +27,7 @@ export default defineConfig({
}),
// Attaches the same WS upgrade handling used in production (server.js)
// to Vite's dev server, so `npm run dev` supports it without a build.
realtimeDevWsPlugin()
realtimeDevWsPlugin({ defaults: realtimeDefaults })
],
test: {
expect: { requireAssertions: true },

Loading…
Cancel
Save