@kuralle-syrinx/cf-agents 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +8 -8
- package/src/index.ts +6 -1
- package/src/with-voice.test.ts +98 -1
- package/src/with-voice.ts +39 -4
package/README.md
CHANGED
|
@@ -65,9 +65,11 @@ export class SupportVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<E
|
|
|
65
65
|
|
|
66
66
|
| Option | Description |
|
|
67
67
|
| --- | --- |
|
|
68
|
-
| `
|
|
68
|
+
| `transport` | `"edge"` (default — Syrinx browser/edge protocol over `/ws`) or `"twilio"` (Twilio Media Streams, μ-law 8 kHz, for a PSTN leg). One transport per Agent class. |
|
|
69
|
+
| `pipeline` | `{ kind: "realtime", front, delegateToolName? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner?, sttForceFinalizeTimeoutMs? }`. |
|
|
69
70
|
| `reasoner` | `(env, { sessionId }) => Reasoner`. Defaults to `fromKuralleRuntime(this.runtime)` when the Agent exposes a kuralle `runtime`. Required for cascaded agents without one. |
|
|
70
|
-
| `recorder` | `(env, { sessionId }) => EdgeRecorder \| undefined` — optional per-call recorder (e.g. R2). |
|
|
71
|
+
| `recorder` | `(env, { sessionId }) => EdgeRecorder \| undefined` — optional per-call recorder (e.g. the R2 recorder at `@kuralle-syrinx/cf-agents/r2-recorder`). Edge transport. |
|
|
72
|
+
| `onToolCallStart` | `(ctx: { toolName, args, sessionId, connection }) => void \| Promise<void>` — fired the instant the front model invokes the delegate tool, **before** the reasoner runs. The seam for a deterministic latency-masking preamble / "thinking" earcon: `ctx.connection.send(...)` to trigger a cached client-side cue. A throwing callback never affects the call. |
|
|
71
73
|
| `inputSampleRateHz` / `outputSampleRateHz` | Edge audio rates (default 16000). |
|
|
72
74
|
| `resumeWindowMs` | How long a dropped connection can resume its session. |
|
|
73
75
|
| `sessionId` | `(request, agentName) => string`. Defaults to the `?sessionId=` query param (so a reconnecting client can resume), else a per-connection random id. (Not the Agent name — concurrent connections to one instance must not share a session.) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/cf-agents",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"./r2-recorder": "./src/r2-recorder.ts"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@kuralle-syrinx/core": "3.
|
|
16
|
-
"@kuralle-syrinx/realtime": "3.
|
|
17
|
-
"@kuralle-syrinx/aisdk": "3.
|
|
18
|
-
"@kuralle-syrinx/
|
|
19
|
-
"@kuralle-syrinx/
|
|
20
|
-
"@kuralle-syrinx/
|
|
21
|
-
"@kuralle-syrinx/
|
|
15
|
+
"@kuralle-syrinx/core": "3.1.0",
|
|
16
|
+
"@kuralle-syrinx/realtime": "3.1.0",
|
|
17
|
+
"@kuralle-syrinx/aisdk": "3.1.0",
|
|
18
|
+
"@kuralle-syrinx/kuralle": "3.1.0",
|
|
19
|
+
"@kuralle-syrinx/server-websocket": "3.1.0",
|
|
20
|
+
"@kuralle-syrinx/ws": "3.1.0",
|
|
21
|
+
"@kuralle-syrinx/recorder": "3.1.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"agents": ">=0.14.0 <1.0.0"
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// @kuralle-syrinx/cf-agents — add a Syrinx voice pipeline (realtime or cascaded) to
|
|
4
4
|
// a Cloudflare `agents` SDK Agent via the `withVoice(Agent, options)` mixin.
|
|
5
5
|
|
|
6
|
-
export {
|
|
6
|
+
export {
|
|
7
|
+
withVoice,
|
|
8
|
+
type WithVoiceOptions,
|
|
9
|
+
type WithVoiceMembers,
|
|
10
|
+
type ToolCallStartContext,
|
|
11
|
+
} from "./with-voice.js";
|
|
7
12
|
export type {
|
|
8
13
|
VoicePipeline,
|
|
9
14
|
RealtimePipeline,
|
package/src/with-voice.test.ts
CHANGED
|
@@ -8,9 +8,47 @@
|
|
|
8
8
|
import { describe, it, expect, vi } from "vitest";
|
|
9
9
|
import type { PipelineBus, Reasoner, UserAudioReceivedPacket, VoicePlugin } from "@kuralle-syrinx/core";
|
|
10
10
|
import { encodePcm16ToMuLaw } from "@kuralle-syrinx/core/audio";
|
|
11
|
-
import {
|
|
11
|
+
import type { RealtimeAdapter, RealtimeEvent } from "@kuralle-syrinx/realtime";
|
|
12
|
+
import { withVoice, type ToolCallStartContext } from "./with-voice.js";
|
|
12
13
|
import type { VoicePipeline } from "./build-session.js";
|
|
13
14
|
|
|
15
|
+
/** Controllable fake realtime front — `emit()` pushes provider events into the bridge. */
|
|
16
|
+
class FakeFront implements RealtimeAdapter {
|
|
17
|
+
readonly caps = {
|
|
18
|
+
inputSampleRateHz: 24_000,
|
|
19
|
+
outputSampleRateHz: 24_000,
|
|
20
|
+
supportsConcurrentToolAudio: true,
|
|
21
|
+
supportsTruncate: true,
|
|
22
|
+
emitsServerSpeechStarted: true,
|
|
23
|
+
} as const;
|
|
24
|
+
#queued: RealtimeEvent[] = [];
|
|
25
|
+
#waiters: Array<(e: RealtimeEvent | null) => void> = [];
|
|
26
|
+
#closed = false;
|
|
27
|
+
readonly events: AsyncIterable<RealtimeEvent> = {
|
|
28
|
+
[Symbol.asyncIterator]: () => ({
|
|
29
|
+
next: async (): Promise<IteratorResult<RealtimeEvent>> => {
|
|
30
|
+
if (this.#queued.length) return { value: this.#queued.shift()!, done: false };
|
|
31
|
+
if (this.#closed) return { value: undefined, done: true };
|
|
32
|
+
const e = await new Promise<RealtimeEvent | null>((r) => this.#waiters.push(r));
|
|
33
|
+
return e === null ? { value: undefined, done: true } : { value: e, done: false };
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
async open(): Promise<void> {}
|
|
38
|
+
sendAudio(): void {}
|
|
39
|
+
cancelResponse(): void {}
|
|
40
|
+
injectToolResult(): void {}
|
|
41
|
+
async close(): Promise<void> {
|
|
42
|
+
this.#closed = true;
|
|
43
|
+
for (const w of this.#waiters.splice(0)) w(null);
|
|
44
|
+
}
|
|
45
|
+
emit(e: RealtimeEvent): void {
|
|
46
|
+
const w = this.#waiters.shift();
|
|
47
|
+
if (w) w(e);
|
|
48
|
+
else this.#queued.push(e);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
14
52
|
// --- Fakes ---------------------------------------------------------------
|
|
15
53
|
|
|
16
54
|
class FakeAgentBase {
|
|
@@ -263,6 +301,65 @@ describe("withVoice(Agent)", () => {
|
|
|
263
301
|
expect(jsonFrames(conn).some((f) => f["type"] === "error")).toBe(false);
|
|
264
302
|
});
|
|
265
303
|
|
|
304
|
+
it("fires onToolCallStart with the tool + the live connection when the delegate tool is invoked", async () => {
|
|
305
|
+
const front = new FakeFront();
|
|
306
|
+
const calls: ToolCallStartContext[] = [];
|
|
307
|
+
const realtimePipeline: VoicePipeline<Record<string, unknown>> = {
|
|
308
|
+
kind: "realtime",
|
|
309
|
+
front: () => front,
|
|
310
|
+
delegateToolName: "consult_knowledge",
|
|
311
|
+
};
|
|
312
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
313
|
+
asBase(FakeAgentBase),
|
|
314
|
+
{
|
|
315
|
+
pipeline: realtimePipeline,
|
|
316
|
+
reasoner: () => stubReasoner(),
|
|
317
|
+
onToolCallStart: (c) => { calls.push(c); },
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
const agent = new VoiceAgent({});
|
|
321
|
+
const conn = fakeConnection();
|
|
322
|
+
|
|
323
|
+
agent.onConnect(conn, ctx());
|
|
324
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
325
|
+
|
|
326
|
+
// The front model starts the turn, then invokes the delegate tool — the latency-mask moment.
|
|
327
|
+
front.emit({ type: "response_started" });
|
|
328
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "fees" } });
|
|
329
|
+
|
|
330
|
+
await vi.waitFor(() => expect(calls.length).toBeGreaterThan(0));
|
|
331
|
+
expect(calls[0]!.toolName).toBe("consult_knowledge");
|
|
332
|
+
expect(calls[0]!.args).toEqual({ query: "fees" });
|
|
333
|
+
expect(calls[0]!.sessionId).toBe("test-session");
|
|
334
|
+
// The app gets the real connection — it can `connection.send(...)` a preamble/earcon trigger.
|
|
335
|
+
expect(calls[0]!.connection).toBe(conn);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("a throwing onToolCallStart never breaks the call", async () => {
|
|
339
|
+
const front = new FakeFront();
|
|
340
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
341
|
+
asBase(FakeAgentBase),
|
|
342
|
+
{
|
|
343
|
+
pipeline: { kind: "realtime", front: () => front, delegateToolName: "consult_knowledge" },
|
|
344
|
+
reasoner: () => stubReasoner(),
|
|
345
|
+
onToolCallStart: () => { throw new Error("app hook blew up"); },
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
const agent = new VoiceAgent({});
|
|
349
|
+
const conn = fakeConnection();
|
|
350
|
+
|
|
351
|
+
agent.onConnect(conn, ctx());
|
|
352
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
353
|
+
|
|
354
|
+
front.emit({ type: "response_started" });
|
|
355
|
+
expect(() => {
|
|
356
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "x" } });
|
|
357
|
+
}).not.toThrow();
|
|
358
|
+
// The connection stays usable (a ping after the throwing hook is still handled).
|
|
359
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
360
|
+
expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
|
|
361
|
+
});
|
|
362
|
+
|
|
266
363
|
it("forceEndVoice closes the connection", () => {
|
|
267
364
|
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
268
365
|
asBase(FakeAgentBase),
|
package/src/with-voice.ts
CHANGED
|
@@ -44,6 +44,21 @@ type AgentLike = Constructor<
|
|
|
44
44
|
Pick<Agent<Record<string, unknown>>, "sql" | "getConnections" | "keepAlive">
|
|
45
45
|
>;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Fired the instant the front model invokes the delegate tool — BEFORE the reasoner runs.
|
|
49
|
+
* Lets an app emit a deterministic, in-language preamble or a "thinking" earcon that masks the
|
|
50
|
+
* reasoner's wait, instead of relying on the realtime front LLM to remember to speak one (cf.
|
|
51
|
+
* Vapi/Pipecat `on_function_calls_started`). Use `connection.send(...)` to signal the client
|
|
52
|
+
* (the idiomatic agents-SDK pattern) — e.g. trigger a cached client-side earcon/preamble.
|
|
53
|
+
*/
|
|
54
|
+
export interface ToolCallStartContext {
|
|
55
|
+
readonly toolName: string;
|
|
56
|
+
readonly args: Record<string, unknown>;
|
|
57
|
+
readonly sessionId: string;
|
|
58
|
+
/** The live agents-SDK connection — `connection.send(json)` to message the client. */
|
|
59
|
+
readonly connection: VoiceConnection;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
export interface WithVoiceOptions<Env> {
|
|
48
63
|
/**
|
|
49
64
|
* The connection wire protocol this host speaks. `"edge"` (default) is the Syrinx
|
|
@@ -63,6 +78,12 @@ export interface WithVoiceOptions<Env> {
|
|
|
63
78
|
readonly reasoner?: (env: Env, ctx: VoicePipelineContext) => Reasoner | Promise<Reasoner>;
|
|
64
79
|
/** Optional per-call recorder (e.g. an R2-backed `EdgeRecorder`). Applies to the `"edge"` transport. */
|
|
65
80
|
readonly recorder?: (env: Env, ctx: VoicePipelineContext) => EdgeRecorder | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Fired when the front model starts a delegate tool call, before the reasoner runs — the seam
|
|
83
|
+
* for a deterministic latency-masking preamble / "thinking" earcon. Throwing here never affects
|
|
84
|
+
* the call. See {@link ToolCallStartContext}.
|
|
85
|
+
*/
|
|
86
|
+
readonly onToolCallStart?: (ctx: ToolCallStartContext) => void | Promise<void>;
|
|
66
87
|
readonly inputSampleRateHz?: number;
|
|
67
88
|
readonly outputSampleRateHz?: number;
|
|
68
89
|
readonly resumeWindowMs?: number;
|
|
@@ -156,9 +177,8 @@ export function withVoice<Env, TBase extends AgentLike>(
|
|
|
156
177
|
// (workerd's WebSocket.send accepts string/ArrayBuffer/ArrayBufferView), but
|
|
157
178
|
// its lib type is nominally stricter (ArrayBuffer- vs ArrayBufferLike-backed
|
|
158
179
|
// views). Bridge that single boundary structurally.
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
);
|
|
180
|
+
const voiceConnection = connection as unknown as VoiceConnection;
|
|
181
|
+
const { socket, controller } = connectionManagedSocket(voiceConnection);
|
|
162
182
|
this.#controllers.set(connection.id, controller);
|
|
163
183
|
|
|
164
184
|
// Release the keepAlive lease + drop the controller on ANY close — a client
|
|
@@ -187,7 +207,22 @@ export function withVoice<Env, TBase extends AgentLike>(
|
|
|
187
207
|
// Both runners assemble the session the same way — pipeline + (resolved) reasoner.
|
|
188
208
|
const createSession = async () => {
|
|
189
209
|
const reasoner = await this.#resolveReasoner(env, sessionId);
|
|
190
|
-
|
|
210
|
+
const session = buildVoiceSession(options.pipeline, env, reasoner, { sessionId });
|
|
211
|
+
// Surface the tool-call-start seam: VoiceAgentSession emits `agent_tool_call` the instant
|
|
212
|
+
// the front model invokes the delegate tool, before the reasoner runs. A throwing app
|
|
213
|
+
// callback must never break the call, so it is fully isolated.
|
|
214
|
+
if (options.onToolCallStart) {
|
|
215
|
+
session.on("agent_tool_call", (e) => {
|
|
216
|
+
try {
|
|
217
|
+
void Promise.resolve(
|
|
218
|
+
options.onToolCallStart!({ toolName: e.name, args: e.args, sessionId, connection: voiceConnection }),
|
|
219
|
+
).catch(() => undefined);
|
|
220
|
+
} catch {
|
|
221
|
+
/* app hook threw synchronously — ignore */
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return session;
|
|
191
226
|
};
|
|
192
227
|
// The runner reports startup failures to the client and disposes the socket
|
|
193
228
|
// itself; nothing to do here beyond not crashing the isolate.
|