@kuralle-syrinx/cf-agents 3.1.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 +30 -3
- package/package.json +8 -8
- package/src/build-session.ts +48 -2
- package/src/durable-history.ts +79 -0
- package/src/index.ts +4 -0
- package/src/r2-recorder.test.ts +88 -3
- package/src/r2-recorder.ts +256 -76
- package/src/with-voice.test.ts +326 -5
- package/src/with-voice.ts +195 -6
package/src/with-voice.test.ts
CHANGED
|
@@ -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 {
|
|
13
|
-
|
|
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
|
-
|
|
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;
|
|
79
|
-
|
|
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
|
@@ -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,11 +34,16 @@ 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">
|
|
@@ -59,6 +64,41 @@ export interface ToolCallStartContext {
|
|
|
59
64
|
readonly connection: VoiceConnection;
|
|
60
65
|
}
|
|
61
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
|
+
|
|
62
102
|
export interface WithVoiceOptions<Env> {
|
|
63
103
|
/**
|
|
64
104
|
* The connection wire protocol this host speaks. `"edge"` (default) is the Syrinx
|
|
@@ -84,6 +124,31 @@ export interface WithVoiceOptions<Env> {
|
|
|
84
124
|
* the call. See {@link ToolCallStartContext}.
|
|
85
125
|
*/
|
|
86
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;
|
|
87
152
|
readonly inputSampleRateHz?: number;
|
|
88
153
|
readonly outputSampleRateHz?: number;
|
|
89
154
|
readonly resumeWindowMs?: number;
|
|
@@ -206,8 +271,76 @@ export function withVoice<Env, TBase extends AgentLike>(
|
|
|
206
271
|
|
|
207
272
|
// Both runners assemble the session the same way — pipeline + (resolved) reasoner.
|
|
208
273
|
const createSession = async () => {
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
}
|
|
211
344
|
// Surface the tool-call-start seam: VoiceAgentSession emits `agent_tool_call` the instant
|
|
212
345
|
// the front model invokes the delegate tool, before the reasoner runs. A throwing app
|
|
213
346
|
// callback must never break the call, so it is fully isolated.
|
|
@@ -222,6 +355,50 @@ export function withVoice<Env, TBase extends AgentLike>(
|
|
|
222
355
|
}
|
|
223
356
|
});
|
|
224
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
|
+
}
|
|
225
402
|
return session;
|
|
226
403
|
};
|
|
227
404
|
// The runner reports startup failures to the client and disposes the socket
|
|
@@ -286,13 +463,25 @@ export function withVoice<Env, TBase extends AgentLike>(
|
|
|
286
463
|
}
|
|
287
464
|
}
|
|
288
465
|
|
|
289
|
-
async #resolveReasoner(env: Env,
|
|
290
|
-
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);
|
|
291
468
|
const runtime = (this as unknown as VoiceHostSurface).runtime;
|
|
292
|
-
if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId });
|
|
469
|
+
if (isKuralleRuntime(runtime)) return fromKuralleRuntime(runtime, { sessionId: ctx.sessionId });
|
|
293
470
|
return undefined;
|
|
294
471
|
}
|
|
295
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
|
+
|
|
296
485
|
// Default: the client-supplied `?sessionId=` (so a reconnecting client can
|
|
297
486
|
// resume its session within the resume window), else a per-connection random
|
|
298
487
|
// id. Crucially NOT the Agent name — two concurrent connections to one
|