@kuralle-syrinx/cf-agents 3.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.
@@ -0,0 +1,279 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // withVoice(Agent) — adds a Syrinx voice pipeline (realtime OR cascaded) to a
4
+ // Cloudflare `agents` SDK Agent.
5
+ //
6
+ // Design (issue #7): a mixin OVER the Agent, not a raw Durable Object. It reuses
7
+ // the Agent's native hibernation, `keepAlive()` lease, `Connection`, and SQL —
8
+ // it does not reimplement them. Syrinx is the engine: each connection is handed
9
+ // to the published `runVoiceEdgeWebSocketConnection(socket, request, options)`
10
+ // over the Agent's `Connection` wrapped as a `ManagedSocket`. The agent's own
11
+ // kuralle runtime is the brain by default (`fromKuralleRuntime(this.runtime)`),
12
+ // so an existing agent gets voice with zero brain re-wiring.
13
+ //
14
+ // Lifecycle wrap (capture-and-patch of onConnect/onMessage/onClose) mirrors
15
+ // `@cloudflare/voice`'s withVoice (voice.ts, MIT, © Cloudflare). Unlike that
16
+ // mixin, this one does not implement the voice protocol itself — it delegates the
17
+ // whole connection to Syrinx's edge runner.
18
+
19
+ import type { Agent, Connection, ConnectionContext, WSMessage } from "agents";
20
+ import {
21
+ runVoiceEdgeWebSocketConnection,
22
+ type EdgeRecorder,
23
+ } from "@kuralle-syrinx/server-websocket/edge";
24
+ import { runTwilioEdgeWebSocketConnection } from "@kuralle-syrinx/server-websocket/edge-twilio";
25
+ import { InMemorySessionStore } from "@kuralle-syrinx/server-websocket/session-store";
26
+ import type { Reasoner } from "@kuralle-syrinx/core";
27
+ import { fromKuralleRuntime, type KuralleRuntimeLike } from "@kuralle-syrinx/kuralle";
28
+ import {
29
+ connectionManagedSocket,
30
+ type ConnectionSocketController,
31
+ type VoiceConnection,
32
+ } from "./connection-socket.js";
33
+ import {
34
+ buildVoiceSession,
35
+ type VoicePipeline,
36
+ type VoicePipelineContext,
37
+ } from "./build-session.js";
38
+
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require `any[]` rest params (TS2545)
40
+ type Constructor<T = object> = new (...args: any[]) => T;
41
+
42
+ /** The Agent surface the voice mixin relies on. (`env`/`name`/`runtime` are read via VoiceHostSurface.) */
43
+ type AgentLike = Constructor<
44
+ Pick<Agent<Record<string, unknown>>, "sql" | "getConnections" | "keepAlive">
45
+ >;
46
+
47
+ export interface WithVoiceOptions<Env> {
48
+ /**
49
+ * The connection wire protocol this host speaks. `"edge"` (default) is the Syrinx
50
+ * browser/edge JSON+envelope protocol (`runVoiceEdgeWebSocketConnection`). `"twilio"`
51
+ * speaks the Twilio Media Streams protocol (μ-law 8 kHz both ways,
52
+ * `runTwilioEdgeWebSocketConnection`) for a PSTN leg. One transport per Agent class —
53
+ * route `/ws` to an `"edge"` agent and `/twilio` to a `"twilio"` agent.
54
+ */
55
+ readonly transport?: "edge" | "twilio";
56
+ /** The voice pipeline: `{ kind: "realtime", ... }` or `{ kind: "cascaded", ... }`. */
57
+ readonly pipeline: VoicePipeline<Env>;
58
+ /**
59
+ * The brain. Defaults to `fromKuralleRuntime(this.runtime, { sessionId })` when
60
+ * the Agent exposes a kuralle `runtime`. Required for cascaded pipelines on
61
+ * agents without a `runtime`.
62
+ */
63
+ readonly reasoner?: (env: Env, ctx: VoicePipelineContext) => Reasoner | Promise<Reasoner>;
64
+ /** Optional per-call recorder (e.g. an R2-backed `EdgeRecorder`). Applies to the `"edge"` transport. */
65
+ readonly recorder?: (env: Env, ctx: VoicePipelineContext) => EdgeRecorder | undefined;
66
+ readonly inputSampleRateHz?: number;
67
+ readonly outputSampleRateHz?: number;
68
+ readonly resumeWindowMs?: number;
69
+ /**
70
+ * Derive the Syrinx session id. Defaults to the `?sessionId=` query param, then
71
+ * the Agent instance `name` (each routed DO instance is one stable session).
72
+ */
73
+ readonly sessionId?: (request: Request, agentName: string) => string;
74
+ }
75
+
76
+ export interface WithVoiceMembers {
77
+ /** Force-end the voice session on a connection (e.g. moderation, takeover). */
78
+ forceEndVoice(connection: VoiceConnection): void;
79
+ }
80
+
81
+ /** Loose view of the host instance for the capture-and-patch wiring. */
82
+ interface VoiceHostSurface {
83
+ readonly env: unknown;
84
+ readonly name: string;
85
+ readonly runtime?: unknown;
86
+ keepAlive(): Promise<() => void>;
87
+ onConnect?(connection: Connection, ctx: ConnectionContext): unknown;
88
+ onMessage?(connection: Connection, message: WSMessage): unknown;
89
+ onClose?(connection: Connection, code: number, reason: string, wasClean: boolean): unknown;
90
+ }
91
+
92
+ function isKuralleRuntime(value: unknown): value is KuralleRuntimeLike {
93
+ return (
94
+ typeof value === "object" &&
95
+ value !== null &&
96
+ typeof (value as { run?: unknown }).run === "function"
97
+ );
98
+ }
99
+
100
+ export function withVoice<Env, TBase extends AgentLike>(
101
+ Base: TBase,
102
+ options: WithVoiceOptions<Env>,
103
+ ): TBase & Constructor<WithVoiceMembers> {
104
+ class VoiceAgentMixin extends Base implements WithVoiceMembers {
105
+ // One session store per DO instance — resumes a dropped connection within
106
+ // the agent's lifetime (keyed by the stable session id).
107
+ readonly #store = new InMemorySessionStore();
108
+ readonly #controllers = new Map<string, ConnectionSocketController>();
109
+ readonly #keepAliveDispose = new Map<string, () => void>();
110
+
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructor must accept any args
112
+ constructor(...args: any[]) {
113
+ super(...args);
114
+ const host = this as unknown as VoiceHostSurface;
115
+ const onConnect = host.onConnect?.bind(this);
116
+ const onMessage = host.onMessage?.bind(this);
117
+ const onClose = host.onClose?.bind(this);
118
+
119
+ host.onConnect = (connection: Connection, ctx: ConnectionContext) => {
120
+ this.#startVoice(connection, ctx);
121
+ return onConnect?.(connection, ctx);
122
+ };
123
+
124
+ host.onMessage = (connection: Connection, message: WSMessage) => {
125
+ const controller = this.#controllers.get(connection.id);
126
+ if (controller) {
127
+ controller.message(message as string | ArrayBuffer);
128
+ return undefined;
129
+ }
130
+ return onMessage?.(connection, message);
131
+ };
132
+
133
+ host.onClose = (connection: Connection, code: number, reason: string, wasClean: boolean) => {
134
+ this.#endVoice(connection, code, reason);
135
+ return onClose?.(connection, code, reason, wasClean);
136
+ };
137
+ }
138
+
139
+ forceEndVoice(connection: VoiceConnection): void {
140
+ // Pump close so the edge runner tears down and the lease releases, then
141
+ // close the underlying socket.
142
+ this.#controllers.get(connection.id)?.close(1000, "force_end");
143
+ try {
144
+ connection.close(1000, "force_end");
145
+ } catch {
146
+ /* already closing */
147
+ }
148
+ }
149
+
150
+ #startVoice(connection: Connection, ctx: ConnectionContext): void {
151
+ const host = this as unknown as VoiceHostSurface;
152
+ const env = host.env as Env;
153
+ const request = ctx.request;
154
+ const sessionId = this.#resolveSessionId(request);
155
+ // The agents `Connection.send` is runtime-compatible with a ManagedSocket
156
+ // (workerd's WebSocket.send accepts string/ArrayBuffer/ArrayBufferView), but
157
+ // its lib type is nominally stricter (ArrayBuffer- vs ArrayBufferLike-backed
158
+ // views). Bridge that single boundary structurally.
159
+ const { socket, controller } = connectionManagedSocket(
160
+ connection as unknown as VoiceConnection,
161
+ );
162
+ this.#controllers.set(connection.id, controller);
163
+
164
+ // Release the keepAlive lease + drop the controller on ANY close — a client
165
+ // close (pumped via the Agent's onClose hook) OR an edge-runner-initiated
166
+ // socket.dispose() (idle / max-duration / startup failure). The socket fires
167
+ // its close handlers in both cases, so this does not depend on the platform
168
+ // delivering a server-initiated onClose.
169
+ socket.onClose(() => this.#releaseConnection(connection.id));
170
+
171
+ // Hold a keepAlive lease for the duration of the call so the DO is not
172
+ // evicted mid-conversation.
173
+ void this.keepAlive()
174
+ .then((dispose) => {
175
+ if (this.#controllers.has(connection.id)) {
176
+ this.#keepAliveDispose.set(connection.id, dispose);
177
+ } else {
178
+ // Connection already closed before the lease resolved.
179
+ dispose();
180
+ }
181
+ })
182
+ .catch(() => {
183
+ // keepAlive() failed — the call still runs (the open socket keeps the
184
+ // isolate live), it is just not protected from idle eviction.
185
+ });
186
+
187
+ // Both runners assemble the session the same way — pipeline + (resolved) reasoner.
188
+ const createSession = async () => {
189
+ const reasoner = await this.#resolveReasoner(env, sessionId);
190
+ return buildVoiceSession(options.pipeline, env, reasoner, { sessionId });
191
+ };
192
+ // The runner reports startup failures to the client and disposes the socket
193
+ // itself; nothing to do here beyond not crashing the isolate.
194
+ const onRunnerSettled = () => undefined;
195
+
196
+ if (options.transport === "twilio") {
197
+ // Twilio Media Streams: μ-law 8 kHz both ways. The runner derives the session
198
+ // id from the `?sessionId=` query (the callSid), resamples to the engine rate,
199
+ // and manages its own lease/heartbeat. Recorder is edge-only.
200
+ void runTwilioEdgeWebSocketConnection(socket, request, {
201
+ sessionStore: this.#store,
202
+ createSession,
203
+ ...(options.inputSampleRateHz !== undefined
204
+ ? { engineSampleRateHz: options.inputSampleRateHz }
205
+ : {}),
206
+ ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
207
+ }).catch(onRunnerSettled);
208
+ return;
209
+ }
210
+
211
+ let recorder: EdgeRecorder | undefined;
212
+ try {
213
+ recorder = options.recorder?.(env, { sessionId });
214
+ } catch {
215
+ // A recorder factory that throws synchronously must not strand the
216
+ // connection (and its lease) — dispose, which fires the close path above.
217
+ socket.dispose();
218
+ return;
219
+ }
220
+
221
+ void runVoiceEdgeWebSocketConnection(socket, request, {
222
+ sessionStore: this.#store,
223
+ sessionId: () => sessionId,
224
+ recorder,
225
+ ...(options.inputSampleRateHz !== undefined
226
+ ? { inputSampleRateHz: options.inputSampleRateHz }
227
+ : {}),
228
+ ...(options.outputSampleRateHz !== undefined
229
+ ? { outputSampleRateHz: options.outputSampleRateHz }
230
+ : {}),
231
+ ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
232
+ createSession,
233
+ }).catch(onRunnerSettled);
234
+ }
235
+
236
+ /** Pump the close so the edge runner tears down; the lease releases via #releaseConnection. */
237
+ #endVoice(connection: { readonly id: string }, code: number, reason: string): void {
238
+ this.#controllers.get(connection.id)?.close(code, reason);
239
+ }
240
+
241
+ #releaseConnection(id: string): void {
242
+ this.#controllers.delete(id);
243
+ const dispose = this.#keepAliveDispose.get(id);
244
+ if (dispose) {
245
+ this.#keepAliveDispose.delete(id);
246
+ try {
247
+ dispose();
248
+ } catch {
249
+ /* ignore */
250
+ }
251
+ }
252
+ }
253
+
254
+ async #resolveReasoner(env: Env, sessionId: string): Promise<Reasoner | undefined> {
255
+ if (options.reasoner) return options.reasoner(env, { sessionId });
256
+ const runtime = (this as unknown as VoiceHostSurface).runtime;
257
+ if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId });
258
+ return undefined;
259
+ }
260
+
261
+ // Default: the client-supplied `?sessionId=` (so a reconnecting client can
262
+ // resume its session within the resume window), else a per-connection random
263
+ // id. Crucially NOT the Agent name — two concurrent connections to one
264
+ // instance must not silently share (and cross-wire) a single VoiceAgentSession.
265
+ #resolveSessionId(request: Request): string {
266
+ const name = (this as unknown as VoiceHostSurface).name;
267
+ if (options.sessionId) return options.sessionId(request, name);
268
+ try {
269
+ const fromQuery = new URL(request.url).searchParams.get("sessionId");
270
+ if (fromQuery) return fromQuery;
271
+ } catch {
272
+ /* malformed URL — fall through to a random id */
273
+ }
274
+ return crypto.randomUUID();
275
+ }
276
+ }
277
+
278
+ return VoiceAgentMixin as unknown as TBase & Constructor<WithVoiceMembers>;
279
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022", "WebWorker"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noImplicitReturns": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "outDir": "./dist",
15
+ "rootDir": "./src",
16
+ "esModuleInterop": true,
17
+ "skipLibCheck": true,
18
+ "forceConsistentCasingInFileNames": true
19
+ },
20
+ "include": ["src/**/*.ts"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }