@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 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
- | `pipeline` | `{ kind: "realtime", front, delegateToolName? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner? }`. |
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.0.0",
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.0.0",
16
- "@kuralle-syrinx/realtime": "3.0.0",
17
- "@kuralle-syrinx/aisdk": "3.0.0",
18
- "@kuralle-syrinx/server-websocket": "3.0.0",
19
- "@kuralle-syrinx/ws": "3.0.0",
20
- "@kuralle-syrinx/recorder": "3.0.0",
21
- "@kuralle-syrinx/kuralle": "3.0.0"
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 { withVoice, type WithVoiceOptions, type WithVoiceMembers } from "./with-voice.js";
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,
@@ -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 { withVoice } from "./with-voice.js";
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 { socket, controller } = connectionManagedSocket(
160
- connection as unknown as VoiceConnection,
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
- return buildVoiceSession(options.pipeline, env, reasoner, { sessionId });
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.