@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/README.md +32 -3
- package/package.json +8 -8
- package/src/build-session.ts +48 -2
- package/src/durable-history.ts +79 -0
- package/src/index.ts +10 -1
- package/src/r2-recorder.test.ts +88 -3
- package/src/r2-recorder.ts +256 -76
- package/src/with-voice.test.ts +422 -4
- package/src/with-voice.ts +233 -9
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
|
|
160
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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,
|
|
255
|
-
if (options.reasoner) return options.reasoner(env,
|
|
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
|