@kuralle-syrinx/cf-agents 3.0.0 → 4.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/src/with-voice.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from "@kuralle-syrinx/server-websocket/edge";
24
24
  import { runTwilioEdgeWebSocketConnection } from "@kuralle-syrinx/server-websocket/edge-twilio";
25
25
  import { InMemorySessionStore } from "@kuralle-syrinx/server-websocket/session-store";
26
- import type { Reasoner } from "@kuralle-syrinx/core";
26
+ import type { Reasoner, ReasonerMessage } from "@kuralle-syrinx/core";
27
27
  import { fromKuralleRuntime, type KuralleRuntimeLike } from "@kuralle-syrinx/kuralle";
28
28
  import {
29
29
  connectionManagedSocket,
@@ -34,16 +34,71 @@ import {
34
34
  buildVoiceSession,
35
35
  type VoicePipeline,
36
36
  type VoicePipelineContext,
37
+ type VoiceSessionWiring,
37
38
  } from "./build-session.js";
39
+ import { SqliteReasonerSessionStore, type SqlTag } from "./durable-history.js";
38
40
 
39
41
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require `any[]` rest params (TS2545)
40
42
  type Constructor<T = object> = new (...args: any[]) => T;
41
43
 
44
+ /** Bound on the durable realtime transcript (12 turns), mirroring ReasoningBridge's default window. */
45
+ const MAX_DURABLE_HISTORY_MESSAGES = 24;
46
+
42
47
  /** The Agent surface the voice mixin relies on. (`env`/`name`/`runtime` are read via VoiceHostSurface.) */
43
48
  type AgentLike = Constructor<
44
49
  Pick<Agent<Record<string, unknown>>, "sql" | "getConnections" | "keepAlive">
45
50
  >;
46
51
 
52
+ /**
53
+ * Fired the instant the front model invokes the delegate tool — BEFORE the reasoner runs.
54
+ * Lets an app emit a deterministic, in-language preamble or a "thinking" earcon that masks the
55
+ * reasoner's wait, instead of relying on the realtime front LLM to remember to speak one (cf.
56
+ * Vapi/Pipecat `on_function_calls_started`). Use `connection.send(...)` to signal the client
57
+ * (the idiomatic agents-SDK pattern) — e.g. trigger a cached client-side earcon/preamble.
58
+ */
59
+ export interface ToolCallStartContext {
60
+ readonly toolName: string;
61
+ readonly args: Record<string, unknown>;
62
+ readonly sessionId: string;
63
+ /** The live agents-SDK connection — `connection.send(json)` to message the client. */
64
+ readonly connection: VoiceConnection;
65
+ }
66
+
67
+ /**
68
+ * Delegate (Responder-Thinker) observability — fired when the bridge hands a query to
69
+ * the Reasoner (G2, RFC bimodel-delegate-seam). Replaces the consumer-side pattern of
70
+ * wrapping the Reasoner just to log the query. `toolId`/`toolName` are present on
71
+ * realtime delegate turns, absent on cascade turns.
72
+ */
73
+ export interface DelegateQueryContext<Env = unknown> {
74
+ readonly query: string;
75
+ readonly toolId?: string;
76
+ readonly toolName?: string;
77
+ readonly turnId: string;
78
+ readonly sessionId: string;
79
+ readonly connection: VoiceConnection;
80
+ /** The Worker env — bindings for logging/persistence (e.g. an R2 bucket). */
81
+ readonly env: Env;
82
+ }
83
+
84
+ /**
85
+ * Fired when the Reasoner produced the turn's final answer. Self-contained (carries the
86
+ * query again) so a consumer can log/persist the grounded Q&A pair from this one hook.
87
+ */
88
+ export interface DelegateResultContext<Env = unknown> {
89
+ readonly query: string;
90
+ readonly answer: string;
91
+ readonly durationMs: number;
92
+ readonly grounded: boolean;
93
+ readonly toolId?: string;
94
+ readonly toolName?: string;
95
+ readonly turnId: string;
96
+ readonly sessionId: string;
97
+ readonly connection: VoiceConnection;
98
+ /** The Worker env — bindings for logging/persistence (e.g. an R2 bucket). */
99
+ readonly env: Env;
100
+ }
101
+
47
102
  export interface WithVoiceOptions<Env> {
48
103
  /**
49
104
  * The connection wire protocol this host speaks. `"edge"` (default) is the Syrinx
@@ -63,6 +118,37 @@ export interface WithVoiceOptions<Env> {
63
118
  readonly reasoner?: (env: Env, ctx: VoicePipelineContext) => Reasoner | Promise<Reasoner>;
64
119
  /** Optional per-call recorder (e.g. an R2-backed `EdgeRecorder`). Applies to the `"edge"` transport. */
65
120
  readonly recorder?: (env: Env, ctx: VoicePipelineContext) => EdgeRecorder | undefined;
121
+ /**
122
+ * Fired when the front model starts a delegate tool call, before the reasoner runs — the seam
123
+ * for a deterministic latency-masking preamble / "thinking" earcon. Throwing here never affects
124
+ * the call. See {@link ToolCallStartContext}.
125
+ */
126
+ readonly onToolCallStart?: (ctx: ToolCallStartContext) => void | Promise<void>;
127
+ /**
128
+ * Delegate observability (G2): fired when the bridge hands a query to the Reasoner.
129
+ * Subscribe instead of wrapping the Reasoner to log. Throwing here never affects the call.
130
+ */
131
+ readonly onDelegateQuery?: (ctx: DelegateQueryContext<Env>) => void | Promise<void>;
132
+ /**
133
+ * Delegate observability (G2): fired when the Reasoner produced the turn's final answer —
134
+ * the hook for logging/persisting the grounded Q&A pair (query + answer + durationMs +
135
+ * grounded). Throwing here never affects the call.
136
+ */
137
+ readonly onDelegateResult?: (ctx: DelegateResultContext<Env>) => void | Promise<void>;
138
+ /**
139
+ * G4 durable session state (default on). Persists the reasoner conversation to the
140
+ * Agent's DO-SQLite so a session resumes with the same context after eviction/
141
+ * hibernation: cascaded pipelines re-seed the ReasoningBridge; realtime pipelines
142
+ * record the transcript, feed it to delegate turns as prior context, and expose it
143
+ * (plus any provider-native resume handle) to the `front()` factory via
144
+ * `ctx.resume`. Set false for the pre-G4 ephemeral behavior.
145
+ */
146
+ readonly durableHistory?: boolean;
147
+ /**
148
+ * G3: ms a pending tool call may run before the time-triggered `tool_call_delayed`
149
+ * ("still working") wire cue fires. 0 disables the delayed phase. Default: 2000.
150
+ */
151
+ readonly delayCueAfterMs?: number;
66
152
  readonly inputSampleRateHz?: number;
67
153
  readonly outputSampleRateHz?: number;
68
154
  readonly resumeWindowMs?: number;
@@ -156,9 +242,8 @@ export function withVoice<Env, TBase extends AgentLike>(
156
242
  // (workerd's WebSocket.send accepts string/ArrayBuffer/ArrayBufferView), but
157
243
  // its lib type is nominally stricter (ArrayBuffer- vs ArrayBufferLike-backed
158
244
  // views). Bridge that single boundary structurally.
159
- const { socket, controller } = connectionManagedSocket(
160
- connection as unknown as VoiceConnection,
161
- );
245
+ const voiceConnection = connection as unknown as VoiceConnection;
246
+ const { socket, controller } = connectionManagedSocket(voiceConnection);
162
247
  this.#controllers.set(connection.id, controller);
163
248
 
164
249
  // Release the keepAlive lease + drop the controller on ANY close — a client
@@ -186,8 +271,135 @@ export function withVoice<Env, TBase extends AgentLike>(
186
271
 
187
272
  // Both runners assemble the session the same way — pipeline + (resolved) reasoner.
188
273
  const createSession = async () => {
189
- const reasoner = await this.#resolveReasoner(env, sessionId);
190
- return buildVoiceSession(options.pipeline, env, reasoner, { sessionId });
274
+ // G4 durable session state: load prior context from DO-SQLite, expose it to the
275
+ // factories via ctx.resume, and wire persistence per pipeline kind.
276
+ const durable = options.durableHistory !== false ? this.#durableStore() : undefined;
277
+ const liveHistory: ReasonerMessage[] = durable ? [...durable.load(sessionId)] : [];
278
+ const providerHandle = durable?.loadResumeHandle(sessionId);
279
+ const ctx: VoicePipelineContext = {
280
+ sessionId,
281
+ ...(durable
282
+ ? {
283
+ resume: {
284
+ history: () =>
285
+ liveHistory
286
+ .filter((m): m is ReasonerMessage & { role: "user" | "assistant" } =>
287
+ m.role === "user" || m.role === "assistant")
288
+ .map((m) => ({ role: m.role, content: m.content })),
289
+ ...(providerHandle ? { providerHandle } : {}),
290
+ },
291
+ }
292
+ : {}),
293
+ };
294
+ const wiring: VoiceSessionWiring = {
295
+ ...(options.delayCueAfterMs !== undefined ? { delayCueAfterMs: options.delayCueAfterMs } : {}),
296
+ ...(durable
297
+ ? options.pipeline.kind === "realtime"
298
+ ? { contextProvider: () => liveHistory }
299
+ : { reasonerSessionStore: durable }
300
+ : {}),
301
+ };
302
+ const reasoner = await this.#resolveReasoner(env, ctx);
303
+ const session = buildVoiceSession(options.pipeline, env, reasoner, ctx, wiring);
304
+ // Realtime pipelines have no ReasoningBridge to own history — record the
305
+ // transcript from the bus and persist the bounded snapshot per turn. The
306
+ // cascaded path persists inside ReasoningBridge instead (heard-prefix aware).
307
+ if (durable && options.pipeline.kind === "realtime") {
308
+ const persist = (): void => {
309
+ if (liveHistory.length > MAX_DURABLE_HISTORY_MESSAGES) {
310
+ liveHistory.splice(0, liveHistory.length - MAX_DURABLE_HISTORY_MESSAGES);
311
+ }
312
+ try {
313
+ durable.save(sessionId, liveHistory);
314
+ } catch {
315
+ /* persistence must never fail the call */
316
+ }
317
+ };
318
+ // The realtime bridge pushes llm.done (assistant transcript) BEFORE
319
+ // eos.turn_complete (user transcript) for the same turn — buffer the
320
+ // assistant text and commit the pair in conversation order at turn end.
321
+ const pendingAssistant = new Map<string, string>();
322
+ session.bus.on("llm.done", (pkt) => {
323
+ const done = pkt as { contextId: string; text?: string };
324
+ if (done.text?.trim()) pendingAssistant.set(done.contextId, done.text);
325
+ });
326
+ session.bus.on("eos.turn_complete", (pkt) => {
327
+ const turn = pkt as { contextId: string; text?: string };
328
+ const assistantText = pendingAssistant.get(turn.contextId);
329
+ pendingAssistant.delete(turn.contextId);
330
+ if (turn.text?.trim()) liveHistory.push({ role: "user", content: turn.text });
331
+ if (assistantText) liveHistory.push({ role: "assistant", content: assistantText });
332
+ if (turn.text?.trim() || assistantText) persist();
333
+ });
334
+ session.bus.on("realtime.resumption_handle", (pkt) => {
335
+ const handle = (pkt as { handle?: string }).handle;
336
+ if (!handle) return;
337
+ try {
338
+ durable.saveResumeHandle(sessionId, handle);
339
+ } catch {
340
+ /* persistence must never fail the call */
341
+ }
342
+ });
343
+ }
344
+ // Surface the tool-call-start seam: VoiceAgentSession emits `agent_tool_call` the instant
345
+ // the front model invokes the delegate tool, before the reasoner runs. A throwing app
346
+ // callback must never break the call, so it is fully isolated.
347
+ if (options.onToolCallStart) {
348
+ session.on("agent_tool_call", (e) => {
349
+ try {
350
+ void Promise.resolve(
351
+ options.onToolCallStart!({ toolName: e.name, args: e.args, sessionId, connection: voiceConnection }),
352
+ ).catch(() => undefined);
353
+ } catch {
354
+ /* app hook threw synchronously — ignore */
355
+ }
356
+ });
357
+ }
358
+ // Delegate observability (G2): surface the bridge's delegate.query/delegate.result
359
+ // packets as app hooks. Same isolation contract as onToolCallStart — a throwing app
360
+ // callback must never break the call.
361
+ if (options.onDelegateQuery) {
362
+ session.on("delegate_query", (e) => {
363
+ try {
364
+ void Promise.resolve(
365
+ options.onDelegateQuery!({
366
+ query: e.query,
367
+ toolId: e.toolId,
368
+ toolName: e.toolName,
369
+ turnId: e.turnId,
370
+ sessionId,
371
+ connection: voiceConnection,
372
+ env,
373
+ }),
374
+ ).catch(() => undefined);
375
+ } catch {
376
+ /* app hook threw synchronously — ignore */
377
+ }
378
+ });
379
+ }
380
+ if (options.onDelegateResult) {
381
+ session.on("delegate_result", (e) => {
382
+ try {
383
+ void Promise.resolve(
384
+ options.onDelegateResult!({
385
+ query: e.query,
386
+ answer: e.answer,
387
+ durationMs: e.durationMs,
388
+ grounded: e.grounded,
389
+ toolId: e.toolId,
390
+ toolName: e.toolName,
391
+ turnId: e.turnId,
392
+ sessionId,
393
+ connection: voiceConnection,
394
+ env,
395
+ }),
396
+ ).catch(() => undefined);
397
+ } catch {
398
+ /* app hook threw synchronously — ignore */
399
+ }
400
+ });
401
+ }
402
+ return session;
191
403
  };
192
404
  // The runner reports startup failures to the client and disposes the socket
193
405
  // itself; nothing to do here beyond not crashing the isolate.
@@ -251,13 +463,25 @@ export function withVoice<Env, TBase extends AgentLike>(
251
463
  }
252
464
  }
253
465
 
254
- async #resolveReasoner(env: Env, sessionId: string): Promise<Reasoner | undefined> {
255
- if (options.reasoner) return options.reasoner(env, { sessionId });
466
+ async #resolveReasoner(env: Env, ctx: VoicePipelineContext): Promise<Reasoner | undefined> {
467
+ if (options.reasoner) return options.reasoner(env, ctx);
256
468
  const runtime = (this as unknown as VoiceHostSurface).runtime;
257
- if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId });
469
+ if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId: ctx.sessionId });
258
470
  return undefined;
259
471
  }
260
472
 
473
+ // One durable store per DO instance; tables are created idempotently on first use.
474
+ #durable: SqliteReasonerSessionStore | null = null;
475
+ #durableStore(): SqliteReasonerSessionStore {
476
+ if (!this.#durable) {
477
+ const host = this as unknown as { sql: SqlTag };
478
+ this.#durable = new SqliteReasonerSessionStore(
479
+ (strings, ...values) => host.sql(strings, ...values),
480
+ );
481
+ }
482
+ return this.#durable;
483
+ }
484
+
261
485
  // Default: the client-supplied `?sessionId=` (so a reconnecting client can
262
486
  // resume its session within the resume window), else a per-connection random
263
487
  // id. Crucially NOT the Agent name — two concurrent connections to one