@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.
- package/LICENSE +22 -0
- package/README.md +79 -0
- package/package.json +48 -0
- package/src/build-session.test.ts +83 -0
- package/src/build-session.ts +126 -0
- package/src/connection-socket.test.ts +114 -0
- package/src/connection-socket.ts +113 -0
- package/src/index.ts +15 -0
- package/src/r2-recorder.test.ts +100 -0
- package/src/r2-recorder.ts +177 -0
- package/src/real-agent.compile-check.ts +41 -0
- package/src/with-voice.test.ts +277 -0
- package/src/with-voice.ts +279 -0
- package/tsconfig.json +22 -0
|
@@ -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
|
+
}
|