@kuralle-syrinx/cf-agents 3.1.0 → 4.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.
@@ -9,8 +9,59 @@ 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
11
  import type { RealtimeAdapter, RealtimeEvent } from "@kuralle-syrinx/realtime";
12
- import { withVoice, type ToolCallStartContext } from "./with-voice.js";
13
- import type { VoicePipeline } from "./build-session.js";
12
+ import {
13
+ withVoice,
14
+ type DelegateQueryContext,
15
+ type DelegateResultContext,
16
+ type ToolCallStartContext,
17
+ } from "./with-voice.js";
18
+ import type { VoicePipeline, VoicePipelineContext } from "./build-session.js";
19
+
20
+ /**
21
+ * In-memory emulation of the DO-SQLite statements SqliteReasonerSessionStore issues,
22
+ * shared across agent instances to simulate one Durable Object across evictions.
23
+ */
24
+ function sqliteFake() {
25
+ const history = new Map<string, Array<{ seq: number; role: string; content: string; tool_call_id: string | null }>>();
26
+ const handles = new Map<string, string>();
27
+ const sql = (strings: TemplateStringsArray, ...values: unknown[]): unknown[] => {
28
+ const query = strings.join("?").replace(/\s+/g, " ").trim();
29
+ if (query.startsWith("CREATE TABLE")) return [];
30
+ if (query.includes("SELECT role, content, tool_call_id FROM syrinx_reasoner_history")) {
31
+ return [...(history.get(String(values[0])) ?? [])].sort((a, b) => a.seq - b.seq);
32
+ }
33
+ if (query.includes("DELETE FROM syrinx_reasoner_history")) {
34
+ history.delete(String(values[0]));
35
+ return [];
36
+ }
37
+ if (query.includes("INSERT INTO syrinx_reasoner_history")) {
38
+ const [sid, seq, role, content, toolCallId] = values;
39
+ const rows = history.get(String(sid)) ?? [];
40
+ rows.push({
41
+ seq: Number(seq),
42
+ role: String(role),
43
+ content: String(content),
44
+ tool_call_id: toolCallId === null ? null : String(toolCallId),
45
+ });
46
+ history.set(String(sid), rows);
47
+ return [];
48
+ }
49
+ if (query.includes("SELECT handle FROM syrinx_resume_handle")) {
50
+ const handle = handles.get(String(values[0]));
51
+ return handle ? [{ handle }] : [];
52
+ }
53
+ if (query.includes("DELETE FROM syrinx_resume_handle")) {
54
+ handles.delete(String(values[0]));
55
+ return [];
56
+ }
57
+ if (query.includes("INSERT INTO syrinx_resume_handle")) {
58
+ handles.set(String(values[0]), String(values[1]));
59
+ return [];
60
+ }
61
+ return [];
62
+ };
63
+ return { sql, history, handles };
64
+ }
14
65
 
15
66
  /** Controllable fake realtime front — `emit()` pushes provider events into the bridge. */
16
67
  class FakeFront implements RealtimeAdapter {
@@ -37,7 +88,10 @@ class FakeFront implements RealtimeAdapter {
37
88
  async open(): Promise<void> {}
38
89
  sendAudio(): void {}
39
90
  cancelResponse(): void {}
40
- injectToolResult(): void {}
91
+ readonly injected: Array<{ toolId: string; text: string }> = [];
92
+ injectToolResult(toolId: string, text: string): void {
93
+ this.injected.push({ toolId, text });
94
+ }
41
95
  async close(): Promise<void> {
42
96
  this.#closed = true;
43
97
  for (const w of this.#waiters.splice(0)) w(null);
@@ -75,8 +129,9 @@ class FakeAgentBase {
75
129
  return [];
76
130
  }
77
131
 
78
- // Tagged-template stand-in; unused by the voice path.
79
- sql(): unknown[] {
132
+ // Tagged-template stand-in; the durable-history path issues real statements
133
+ // against it (subclasses can back it with an in-memory emulation).
134
+ sql(_strings?: TemplateStringsArray, ..._values: unknown[]): unknown[] {
80
135
  return [];
81
136
  }
82
137
 
@@ -335,6 +390,119 @@ describe("withVoice(Agent)", () => {
335
390
  expect(calls[0]!.connection).toBe(conn);
336
391
  });
337
392
 
393
+ it("G2/WBS-1: fires onDelegateQuery/onDelegateResult around the reasoner run (SLIIT log-wrapper replacement)", async () => {
394
+ const front = new FakeFront();
395
+ const answeringReasoner = (): Reasoner => ({
396
+ stream: async function* () {
397
+ yield { type: "text-delta", text: "March 31." } as const;
398
+ yield { type: "finish", reason: "stop", text: "March 31." } as const;
399
+ },
400
+ });
401
+ const queries: DelegateQueryContext[] = [];
402
+ const results: DelegateResultContext[] = [];
403
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
404
+ asBase(FakeAgentBase),
405
+ {
406
+ pipeline: { kind: "realtime", front: () => front, delegateToolName: "consult_knowledge" },
407
+ reasoner: () => answeringReasoner(),
408
+ onDelegateQuery: (c) => { queries.push(c); },
409
+ onDelegateResult: (c) => { results.push(c); },
410
+ },
411
+ );
412
+ const agent = new VoiceAgent({});
413
+ const conn = fakeConnection();
414
+
415
+ agent.onConnect(conn, ctx());
416
+ await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
417
+
418
+ front.emit({ type: "response_started" });
419
+ front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "exam deadline" } });
420
+
421
+ await vi.waitFor(() => expect(results.length).toBeGreaterThan(0));
422
+ expect(queries[0]).toMatchObject({
423
+ query: "exam deadline",
424
+ toolId: "t1",
425
+ toolName: "consult_knowledge",
426
+ sessionId: "test-session",
427
+ });
428
+ expect(queries[0]!.connection).toBe(conn);
429
+ expect(results[0]).toMatchObject({
430
+ query: "exam deadline",
431
+ answer: "March 31.",
432
+ grounded: false,
433
+ toolId: "t1",
434
+ toolName: "consult_knowledge",
435
+ sessionId: "test-session",
436
+ });
437
+ expect(results[0]!.durationMs).toBeGreaterThanOrEqual(0);
438
+ expect(results[0]!.connection).toBe(conn);
439
+ });
440
+
441
+ it("G2/WBS-1: throwing onDelegateQuery/onDelegateResult never break the call", async () => {
442
+ const front = new FakeFront();
443
+ const answered: string[] = [];
444
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
445
+ asBase(FakeAgentBase),
446
+ {
447
+ pipeline: { kind: "realtime", front: () => front, delegateToolName: "consult_knowledge" },
448
+ reasoner: () => ({
449
+ stream: async function* () {
450
+ yield { type: "finish", reason: "stop", text: "ok" } as const;
451
+ },
452
+ }),
453
+ onDelegateQuery: () => { throw new Error("query hook blew up"); },
454
+ onDelegateResult: (c) => { answered.push(c.answer); throw new Error("result hook blew up"); },
455
+ },
456
+ );
457
+ const agent = new VoiceAgent({});
458
+ const conn = fakeConnection();
459
+
460
+ agent.onConnect(conn, ctx());
461
+ await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
462
+
463
+ front.emit({ type: "response_started" });
464
+ front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "x" } });
465
+
466
+ await vi.waitFor(() => expect(answered.length).toBeGreaterThan(0));
467
+ // The connection stays usable after both hooks threw.
468
+ expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
469
+ });
470
+
471
+ it("G1/WBS-2: realtime pipeline wraps the delegate answer in the envelope (default) with the configured render directive", async () => {
472
+ const front = new FakeFront();
473
+ const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
474
+ asBase(FakeAgentBase),
475
+ {
476
+ pipeline: {
477
+ kind: "realtime",
478
+ front: () => front,
479
+ delegateToolName: "consult_knowledge",
480
+ renderDirective: "translate_faithfully",
481
+ },
482
+ reasoner: () => ({
483
+ stream: async function* () {
484
+ yield { type: "finish", reason: "stop", text: "The fee is 5000 rupees." } as const;
485
+ },
486
+ }),
487
+ },
488
+ );
489
+ const agent = new VoiceAgent({});
490
+ const conn = fakeConnection();
491
+
492
+ agent.onConnect(conn, ctx());
493
+ await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
494
+
495
+ front.emit({ type: "response_started" });
496
+ front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "fees" } });
497
+
498
+ await vi.waitFor(() => expect(front.injected.length).toBeGreaterThan(0));
499
+ expect(JSON.parse(front.injected[0]!.text)).toEqual({
500
+ response_text: "The fee is 5000 rupees.",
501
+ require_repeat_verbatim: true,
502
+ render: "translate_faithfully",
503
+ });
504
+ });
505
+
338
506
  it("a throwing onToolCallStart never breaks the call", async () => {
339
507
  const front = new FakeFront();
340
508
  const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
@@ -360,6 +528,159 @@ describe("withVoice(Agent)", () => {
360
528
  expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
361
529
  });
362
530
 
531
+ it("G4/WBS-4: realtime transcript survives a simulated eviction — the next instance resumes with prior context", async () => {
532
+ const db = sqliteFake();
533
+ class DurableBase extends FakeAgentBase {
534
+ override sql(strings?: TemplateStringsArray, ...values: unknown[]): unknown[] {
535
+ return db.sql(strings!, ...values);
536
+ }
537
+ }
538
+ const fronts: FakeFront[] = [];
539
+ const seenResume: Array<VoicePipelineContext["resume"]> = [];
540
+ const capturedMessages: Array<readonly unknown[]> = [];
541
+ const makeAgentClass = () =>
542
+ withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(asBase(DurableBase), {
543
+ pipeline: {
544
+ kind: "realtime",
545
+ front: (_env: unknown, pipelineCtx: VoicePipelineContext) => {
546
+ seenResume.push(pipelineCtx.resume);
547
+ const front = new FakeFront();
548
+ fronts.push(front);
549
+ return front;
550
+ },
551
+ },
552
+ reasoner: () => ({
553
+ stream: (turn) => {
554
+ capturedMessages.push([...turn.messages]);
555
+ return (async function* () {
556
+ yield { type: "finish", reason: "stop", text: "ok" } as const;
557
+ })();
558
+ },
559
+ }),
560
+ });
561
+
562
+ // First lifetime: one spoken exchange lands in the durable transcript.
563
+ const FirstAgent = makeAgentClass();
564
+ const first = new FirstAgent({});
565
+ const firstConn = fakeConnection("c1");
566
+ first.onConnect(firstConn, ctx());
567
+ await vi.waitFor(() => expect(jsonFrames(firstConn).some((f) => f["type"] === "ready")).toBe(true));
568
+ fronts[0]!.emit({ type: "response_started" });
569
+ fronts[0]!.emit({ type: "transcript", role: "user", text: "What are the fees?", final: true });
570
+ fronts[0]!.emit({ type: "transcript", role: "assistant", text: "Fees are 5000 rupees.", final: true });
571
+ fronts[0]!.emit({ type: "response_done" });
572
+ await vi.waitFor(() => expect(db.history.get("test-session")?.length).toBe(2));
573
+ first.onClose(firstConn, 1000, "evicted", true);
574
+
575
+ // Second lifetime (fresh class + instance = evicted DO, same SQLite).
576
+ const SecondAgent = makeAgentClass();
577
+ const second = new SecondAgent({});
578
+ const secondConn = fakeConnection("c2");
579
+ second.onConnect(secondConn, ctx());
580
+ await vi.waitFor(() => expect(jsonFrames(secondConn).some((f) => f["type"] === "ready")).toBe(true));
581
+
582
+ // The front factory sees the prior transcript via ctx.resume.
583
+ expect(seenResume[1]?.history()).toEqual([
584
+ { role: "user", content: "What are the fees?" },
585
+ { role: "assistant", content: "Fees are 5000 rupees." },
586
+ ]);
587
+
588
+ // A delegate turn hands the reasoner the same prior context (re-seeded, R6).
589
+ fronts[1]!.emit({ type: "response_started" });
590
+ fronts[1]!.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "deadlines?" } });
591
+ await vi.waitFor(() => expect(capturedMessages.length).toBeGreaterThan(0));
592
+ expect(capturedMessages[0]).toEqual([
593
+ { role: "user", content: "What are the fees?" },
594
+ { role: "assistant", content: "Fees are 5000 rupees." },
595
+ ]);
596
+ });
597
+
598
+ it("G4/WBS-4: persists the latest native resume handle and exposes it on the next instance (Gemini passthrough, no replay)", async () => {
599
+ const db = sqliteFake();
600
+ class DurableBase extends FakeAgentBase {
601
+ override sql(strings?: TemplateStringsArray, ...values: unknown[]): unknown[] {
602
+ return db.sql(strings!, ...values);
603
+ }
604
+ }
605
+ const fronts: FakeFront[] = [];
606
+ const seenResume: Array<VoicePipelineContext["resume"]> = [];
607
+ const makeAgentClass = () =>
608
+ withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(asBase(DurableBase), {
609
+ pipeline: {
610
+ kind: "realtime",
611
+ front: (_env: unknown, pipelineCtx: VoicePipelineContext) => {
612
+ seenResume.push(pipelineCtx.resume);
613
+ const front = new FakeFront();
614
+ fronts.push(front);
615
+ return front;
616
+ },
617
+ },
618
+ reasoner: () => stubReasoner(),
619
+ });
620
+
621
+ const FirstAgent = makeAgentClass();
622
+ const first = new FirstAgent({});
623
+ const firstConn = fakeConnection("c1");
624
+ first.onConnect(firstConn, ctx());
625
+ await vi.waitFor(() => expect(jsonFrames(firstConn).some((f) => f["type"] === "ready")).toBe(true));
626
+ expect(seenResume[0]?.providerHandle).toBeUndefined();
627
+ fronts[0]!.emit({ type: "resumption_handle", handle: "handle-1" });
628
+ fronts[0]!.emit({ type: "resumption_handle", handle: "handle-2" });
629
+ await vi.waitFor(() => expect(db.handles.get("test-session")).toBe("handle-2"));
630
+ first.onClose(firstConn, 1000, "evicted", true);
631
+
632
+ const SecondAgent = makeAgentClass();
633
+ const second = new SecondAgent({});
634
+ const secondConn = fakeConnection("c2");
635
+ second.onConnect(secondConn, ctx());
636
+ await vi.waitFor(() => expect(jsonFrames(secondConn).some((f) => f["type"] === "ready")).toBe(true));
637
+ expect(seenResume[1]?.providerHandle).toBe("handle-2");
638
+ });
639
+
640
+ it("G4/WBS-4: cascaded pipeline re-seeds the ReasoningBridge from durable history after eviction", async () => {
641
+ const db = sqliteFake();
642
+ class DurableBase extends FakeAgentBase {
643
+ override sql(strings?: TemplateStringsArray, ...values: unknown[]): unknown[] {
644
+ return db.sql(strings!, ...values);
645
+ }
646
+ }
647
+ const capturedMessages: Array<readonly unknown[]> = [];
648
+ const makeAgentClass = (reply: string) =>
649
+ withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(asBase(DurableBase), {
650
+ pipeline: cascadedPipeline(),
651
+ reasoner: () => ({
652
+ stream: (turn) => {
653
+ capturedMessages.push([...turn.messages]);
654
+ return (async function* () {
655
+ yield { type: "text-delta", text: reply } as const;
656
+ yield { type: "finish", reason: "stop", text: reply } as const;
657
+ })();
658
+ },
659
+ }),
660
+ });
661
+
662
+ const FirstAgent = makeAgentClass("Answer one.");
663
+ const first = new FirstAgent({});
664
+ const firstConn = fakeConnection("c1");
665
+ first.onConnect(firstConn, ctx());
666
+ await vi.waitFor(() => expect(jsonFrames(firstConn).some((f) => f["type"] === "ready")).toBe(true));
667
+ first.onMessage(firstConn, JSON.stringify({ type: "text", text: "First question", contextId: "turn-1" }));
668
+ await vi.waitFor(() => expect(db.history.get("test-session")?.length).toBe(2));
669
+ first.onClose(firstConn, 1000, "evicted", true);
670
+
671
+ const SecondAgent = makeAgentClass("Answer two.");
672
+ const second = new SecondAgent({});
673
+ const secondConn = fakeConnection("c2");
674
+ second.onConnect(secondConn, ctx());
675
+ await vi.waitFor(() => expect(jsonFrames(secondConn).some((f) => f["type"] === "ready")).toBe(true));
676
+ second.onMessage(secondConn, JSON.stringify({ type: "text", text: "Second question", contextId: "turn-2" }));
677
+ await vi.waitFor(() => expect(capturedMessages.length).toBe(2));
678
+ expect(capturedMessages[1]).toEqual([
679
+ { role: "user", content: "First question" },
680
+ { role: "assistant", content: "Answer one." },
681
+ ]);
682
+ });
683
+
363
684
  it("forceEndVoice closes the connection", () => {
364
685
  const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
365
686
  asBase(FakeAgentBase),
package/src/with-voice.ts CHANGED
@@ -19,11 +19,12 @@
19
19
  import type { Agent, Connection, ConnectionContext, WSMessage } from "agents";
20
20
  import {
21
21
  runVoiceEdgeWebSocketConnection,
22
+ type BackgroundAudioConfig,
22
23
  type EdgeRecorder,
23
24
  } from "@kuralle-syrinx/server-websocket/edge";
24
25
  import { runTwilioEdgeWebSocketConnection } from "@kuralle-syrinx/server-websocket/edge-twilio";
25
26
  import { InMemorySessionStore } from "@kuralle-syrinx/server-websocket/session-store";
26
- import type { Reasoner } from "@kuralle-syrinx/core";
27
+ import type { Reasoner, ReasonerMessage } from "@kuralle-syrinx/core";
27
28
  import { fromKuralleRuntime, type KuralleRuntimeLike } from "@kuralle-syrinx/kuralle";
28
29
  import {
29
30
  connectionManagedSocket,
@@ -34,11 +35,16 @@ import {
34
35
  buildVoiceSession,
35
36
  type VoicePipeline,
36
37
  type VoicePipelineContext,
38
+ type VoiceSessionWiring,
37
39
  } from "./build-session.js";
40
+ import { SqliteReasonerSessionStore, type SqlTag } from "./durable-history.js";
38
41
 
39
42
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require `any[]` rest params (TS2545)
40
43
  type Constructor<T = object> = new (...args: any[]) => T;
41
44
 
45
+ /** Bound on the durable realtime transcript (12 turns), mirroring ReasoningBridge's default window. */
46
+ const MAX_DURABLE_HISTORY_MESSAGES = 24;
47
+
42
48
  /** The Agent surface the voice mixin relies on. (`env`/`name`/`runtime` are read via VoiceHostSurface.) */
43
49
  type AgentLike = Constructor<
44
50
  Pick<Agent<Record<string, unknown>>, "sql" | "getConnections" | "keepAlive">
@@ -59,6 +65,41 @@ export interface ToolCallStartContext {
59
65
  readonly connection: VoiceConnection;
60
66
  }
61
67
 
68
+ /**
69
+ * Delegate (Responder-Thinker) observability — fired when the bridge hands a query to
70
+ * the Reasoner (G2, RFC bimodel-delegate-seam). Replaces the consumer-side pattern of
71
+ * wrapping the Reasoner just to log the query. `toolId`/`toolName` are present on
72
+ * realtime delegate turns, absent on cascade turns.
73
+ */
74
+ export interface DelegateQueryContext<Env = unknown> {
75
+ readonly query: string;
76
+ readonly toolId?: string;
77
+ readonly toolName?: string;
78
+ readonly turnId: string;
79
+ readonly sessionId: string;
80
+ readonly connection: VoiceConnection;
81
+ /** The Worker env — bindings for logging/persistence (e.g. an R2 bucket). */
82
+ readonly env: Env;
83
+ }
84
+
85
+ /**
86
+ * Fired when the Reasoner produced the turn's final answer. Self-contained (carries the
87
+ * query again) so a consumer can log/persist the grounded Q&A pair from this one hook.
88
+ */
89
+ export interface DelegateResultContext<Env = unknown> {
90
+ readonly query: string;
91
+ readonly answer: string;
92
+ readonly durationMs: number;
93
+ readonly grounded: boolean;
94
+ readonly toolId?: string;
95
+ readonly toolName?: string;
96
+ readonly turnId: string;
97
+ readonly sessionId: string;
98
+ readonly connection: VoiceConnection;
99
+ /** The Worker env — bindings for logging/persistence (e.g. an R2 bucket). */
100
+ readonly env: Env;
101
+ }
102
+
62
103
  export interface WithVoiceOptions<Env> {
63
104
  /**
64
105
  * The connection wire protocol this host speaks. `"edge"` (default) is the Syrinx
@@ -78,12 +119,45 @@ export interface WithVoiceOptions<Env> {
78
119
  readonly reasoner?: (env: Env, ctx: VoicePipelineContext) => Reasoner | Promise<Reasoner>;
79
120
  /** Optional per-call recorder (e.g. an R2-backed `EdgeRecorder`). Applies to the `"edge"` transport. */
80
121
  readonly recorder?: (env: Env, ctx: VoicePipelineContext) => EdgeRecorder | undefined;
122
+ /**
123
+ * Ambient/thinking bed mixed (ducked) under assistant speech on both transports.
124
+ * On the `"twilio"` transport the bed also fills the gaps between turns as
125
+ * comfort noise — pure digital silence on a phone line reads as "the call died".
126
+ * The thinking loop follows the G3 tool-call cues. Sources are raw mono PCM16
127
+ * (e.g. `ffmpeg -i office.mp3 -ac 1 -ar 16000 -f s16le office.pcm`).
128
+ */
129
+ readonly backgroundAudio?: BackgroundAudioConfig;
81
130
  /**
82
131
  * Fired when the front model starts a delegate tool call, before the reasoner runs — the seam
83
132
  * for a deterministic latency-masking preamble / "thinking" earcon. Throwing here never affects
84
133
  * the call. See {@link ToolCallStartContext}.
85
134
  */
86
135
  readonly onToolCallStart?: (ctx: ToolCallStartContext) => void | Promise<void>;
136
+ /**
137
+ * Delegate observability (G2): fired when the bridge hands a query to the Reasoner.
138
+ * Subscribe instead of wrapping the Reasoner to log. Throwing here never affects the call.
139
+ */
140
+ readonly onDelegateQuery?: (ctx: DelegateQueryContext<Env>) => void | Promise<void>;
141
+ /**
142
+ * Delegate observability (G2): fired when the Reasoner produced the turn's final answer —
143
+ * the hook for logging/persisting the grounded Q&A pair (query + answer + durationMs +
144
+ * grounded). Throwing here never affects the call.
145
+ */
146
+ readonly onDelegateResult?: (ctx: DelegateResultContext<Env>) => void | Promise<void>;
147
+ /**
148
+ * G4 durable session state (default on). Persists the reasoner conversation to the
149
+ * Agent's DO-SQLite so a session resumes with the same context after eviction/
150
+ * hibernation: cascaded pipelines re-seed the ReasoningBridge; realtime pipelines
151
+ * record the transcript, feed it to delegate turns as prior context, and expose it
152
+ * (plus any provider-native resume handle) to the `front()` factory via
153
+ * `ctx.resume`. Set false for the pre-G4 ephemeral behavior.
154
+ */
155
+ readonly durableHistory?: boolean;
156
+ /**
157
+ * G3: ms a pending tool call may run before the time-triggered `tool_call_delayed`
158
+ * ("still working") wire cue fires. 0 disables the delayed phase. Default: 2000.
159
+ */
160
+ readonly delayCueAfterMs?: number;
87
161
  readonly inputSampleRateHz?: number;
88
162
  readonly outputSampleRateHz?: number;
89
163
  readonly resumeWindowMs?: number;
@@ -206,8 +280,76 @@ export function withVoice<Env, TBase extends AgentLike>(
206
280
 
207
281
  // Both runners assemble the session the same way — pipeline + (resolved) reasoner.
208
282
  const createSession = async () => {
209
- const reasoner = await this.#resolveReasoner(env, sessionId);
210
- const session = buildVoiceSession(options.pipeline, env, reasoner, { sessionId });
283
+ // G4 durable session state: load prior context from DO-SQLite, expose it to the
284
+ // factories via ctx.resume, and wire persistence per pipeline kind.
285
+ const durable = options.durableHistory !== false ? this.#durableStore() : undefined;
286
+ const liveHistory: ReasonerMessage[] = durable ? [...durable.load(sessionId)] : [];
287
+ const providerHandle = durable?.loadResumeHandle(sessionId);
288
+ const ctx: VoicePipelineContext = {
289
+ sessionId,
290
+ ...(durable
291
+ ? {
292
+ resume: {
293
+ history: () =>
294
+ liveHistory
295
+ .filter((m): m is ReasonerMessage & { role: "user" | "assistant" } =>
296
+ m.role === "user" || m.role === "assistant")
297
+ .map((m) => ({ role: m.role, content: m.content })),
298
+ ...(providerHandle ? { providerHandle } : {}),
299
+ },
300
+ }
301
+ : {}),
302
+ };
303
+ const wiring: VoiceSessionWiring = {
304
+ ...(options.delayCueAfterMs !== undefined ? { delayCueAfterMs: options.delayCueAfterMs } : {}),
305
+ ...(durable
306
+ ? options.pipeline.kind === "realtime"
307
+ ? { contextProvider: () => liveHistory }
308
+ : { reasonerSessionStore: durable }
309
+ : {}),
310
+ };
311
+ const reasoner = await this.#resolveReasoner(env, ctx);
312
+ const session = buildVoiceSession(options.pipeline, env, reasoner, ctx, wiring);
313
+ // Realtime pipelines have no ReasoningBridge to own history — record the
314
+ // transcript from the bus and persist the bounded snapshot per turn. The
315
+ // cascaded path persists inside ReasoningBridge instead (heard-prefix aware).
316
+ if (durable && options.pipeline.kind === "realtime") {
317
+ const persist = (): void => {
318
+ if (liveHistory.length > MAX_DURABLE_HISTORY_MESSAGES) {
319
+ liveHistory.splice(0, liveHistory.length - MAX_DURABLE_HISTORY_MESSAGES);
320
+ }
321
+ try {
322
+ durable.save(sessionId, liveHistory);
323
+ } catch {
324
+ /* persistence must never fail the call */
325
+ }
326
+ };
327
+ // The realtime bridge pushes llm.done (assistant transcript) BEFORE
328
+ // eos.turn_complete (user transcript) for the same turn — buffer the
329
+ // assistant text and commit the pair in conversation order at turn end.
330
+ const pendingAssistant = new Map<string, string>();
331
+ session.bus.on("llm.done", (pkt) => {
332
+ const done = pkt as { contextId: string; text?: string };
333
+ if (done.text?.trim()) pendingAssistant.set(done.contextId, done.text);
334
+ });
335
+ session.bus.on("eos.turn_complete", (pkt) => {
336
+ const turn = pkt as { contextId: string; text?: string };
337
+ const assistantText = pendingAssistant.get(turn.contextId);
338
+ pendingAssistant.delete(turn.contextId);
339
+ if (turn.text?.trim()) liveHistory.push({ role: "user", content: turn.text });
340
+ if (assistantText) liveHistory.push({ role: "assistant", content: assistantText });
341
+ if (turn.text?.trim() || assistantText) persist();
342
+ });
343
+ session.bus.on("realtime.resumption_handle", (pkt) => {
344
+ const handle = (pkt as { handle?: string }).handle;
345
+ if (!handle) return;
346
+ try {
347
+ durable.saveResumeHandle(sessionId, handle);
348
+ } catch {
349
+ /* persistence must never fail the call */
350
+ }
351
+ });
352
+ }
211
353
  // Surface the tool-call-start seam: VoiceAgentSession emits `agent_tool_call` the instant
212
354
  // the front model invokes the delegate tool, before the reasoner runs. A throwing app
213
355
  // callback must never break the call, so it is fully isolated.
@@ -222,6 +364,50 @@ export function withVoice<Env, TBase extends AgentLike>(
222
364
  }
223
365
  });
224
366
  }
367
+ // Delegate observability (G2): surface the bridge's delegate.query/delegate.result
368
+ // packets as app hooks. Same isolation contract as onToolCallStart — a throwing app
369
+ // callback must never break the call.
370
+ if (options.onDelegateQuery) {
371
+ session.on("delegate_query", (e) => {
372
+ try {
373
+ void Promise.resolve(
374
+ options.onDelegateQuery!({
375
+ query: e.query,
376
+ toolId: e.toolId,
377
+ toolName: e.toolName,
378
+ turnId: e.turnId,
379
+ sessionId,
380
+ connection: voiceConnection,
381
+ env,
382
+ }),
383
+ ).catch(() => undefined);
384
+ } catch {
385
+ /* app hook threw synchronously — ignore */
386
+ }
387
+ });
388
+ }
389
+ if (options.onDelegateResult) {
390
+ session.on("delegate_result", (e) => {
391
+ try {
392
+ void Promise.resolve(
393
+ options.onDelegateResult!({
394
+ query: e.query,
395
+ answer: e.answer,
396
+ durationMs: e.durationMs,
397
+ grounded: e.grounded,
398
+ toolId: e.toolId,
399
+ toolName: e.toolName,
400
+ turnId: e.turnId,
401
+ sessionId,
402
+ connection: voiceConnection,
403
+ env,
404
+ }),
405
+ ).catch(() => undefined);
406
+ } catch {
407
+ /* app hook threw synchronously — ignore */
408
+ }
409
+ });
410
+ }
225
411
  return session;
226
412
  };
227
413
  // The runner reports startup failures to the client and disposes the socket
@@ -239,6 +425,7 @@ export function withVoice<Env, TBase extends AgentLike>(
239
425
  ? { engineSampleRateHz: options.inputSampleRateHz }
240
426
  : {}),
241
427
  ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
428
+ ...(options.backgroundAudio ? { backgroundAudio: options.backgroundAudio } : {}),
242
429
  }).catch(onRunnerSettled);
243
430
  return;
244
431
  }
@@ -264,6 +451,7 @@ export function withVoice<Env, TBase extends AgentLike>(
264
451
  ? { outputSampleRateHz: options.outputSampleRateHz }
265
452
  : {}),
266
453
  ...(options.resumeWindowMs !== undefined ? { resumeWindowMs: options.resumeWindowMs } : {}),
454
+ ...(options.backgroundAudio ? { backgroundAudio: options.backgroundAudio } : {}),
267
455
  createSession,
268
456
  }).catch(onRunnerSettled);
269
457
  }
@@ -286,13 +474,25 @@ export function withVoice<Env, TBase extends AgentLike>(
286
474
  }
287
475
  }
288
476
 
289
- async #resolveReasoner(env: Env, sessionId: string): Promise<Reasoner | undefined> {
290
- if (options.reasoner) return options.reasoner(env, { sessionId });
477
+ async #resolveReasoner(env: Env, ctx: VoicePipelineContext): Promise<Reasoner | undefined> {
478
+ if (options.reasoner) return options.reasoner(env, ctx);
291
479
  const runtime = (this as unknown as VoiceHostSurface).runtime;
292
- if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId });
480
+ if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId: ctx.sessionId });
293
481
  return undefined;
294
482
  }
295
483
 
484
+ // One durable store per DO instance; tables are created idempotently on first use.
485
+ #durable: SqliteReasonerSessionStore | null = null;
486
+ #durableStore(): SqliteReasonerSessionStore {
487
+ if (!this.#durable) {
488
+ const host = this as unknown as { sql: SqlTag };
489
+ this.#durable = new SqliteReasonerSessionStore(
490
+ (strings, ...values) => host.sql(strings, ...values),
491
+ );
492
+ }
493
+ return this.#durable;
494
+ }
495
+
296
496
  // Default: the client-supplied `?sessionId=` (so a reconnecting client can
297
497
  // resume its session within the resume window), else a per-connection random
298
498
  // id. Crucially NOT the Agent name — two concurrent connections to one