@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 CHANGED
@@ -61,13 +61,42 @@ export class SupportVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<E
61
61
  }) {}
62
62
  ```
63
63
 
64
+ ## The Responder-Thinker primitive
65
+
66
+ `withVoice` packages Syrinx's bi-model **Responder-Thinker** shape turnkey (RFC
67
+ `docs/rfc-bimodel-delegate-seam.md`): wire a realtime front + a `Reasoner` and the delegate seam
68
+ comes with —
69
+
70
+ - **Structured result envelope (G1, default).** The reasoner's answer reaches the front model as
71
+ `{ response_text, require_repeat_verbatim: true, render? }` so it repeats facts faithfully
72
+ instead of paraphrasing. Configure per pipeline: `toolResultFormat: "envelope" | "string"`,
73
+ `renderDirective: "translate_faithfully"`.
74
+ - **Delegate observability (G2).** `onDelegateQuery` / `onDelegateResult` hooks fire around every
75
+ reasoner run with the query, answer, `durationMs`, and `grounded` — log or persist the Q&A pair
76
+ without wrapping the `Reasoner`.
77
+ - **Typed "thinking" cues (G3).** Clients automatically receive `tool_call_started` /
78
+ `tool_call_delayed` (after `delayCueAfterMs`) / `tool_call_complete` / `tool_call_failed` wire
79
+ messages around the reasoner-latency window — key earcons/indicators on these instead of
80
+ inventing an app message (`@kuralle-syrinx/browser-client` parses them).
81
+ - **Durable session + resume (G4, default on).** The conversation persists to the Agent's
82
+ DO-SQLite and survives eviction/hibernation: cascaded pipelines re-seed the `ReasoningBridge`;
83
+ realtime pipelines feed the durable transcript to delegate turns and expose `ctx.resume` to the
84
+ `front()` factory — `resumeHistory: ctx.resume.history` on replay providers (OpenAI),
85
+ `sessionResumptionHandle: ctx.resume.providerHandle` on native-resume providers (Gemini; never
86
+ replay on top of a handle).
87
+
64
88
  ## Options
65
89
 
66
90
  | Option | Description |
67
91
  | --- | --- |
68
- | `pipeline` | `{ kind: "realtime", front, delegateToolName? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner? }`. |
69
- | `reasoner` | `(env, { sessionId }) => Reasoner`. Defaults to `fromKuralleRuntime(this.runtime)` when the Agent exposes a kuralle `runtime`. Required for cascaded agents without one. |
70
- | `recorder` | `(env, { sessionId }) => EdgeRecorder \| undefined` optional per-call recorder (e.g. R2). |
92
+ | `transport` | `"edge"` (default Syrinx browser/edge protocol over `/ws`) or `"twilio"` (Twilio Media Streams, μ-law 8 kHz, for a PSTN leg). One transport per Agent class. |
93
+ | `pipeline` | `{ kind: "realtime", front, delegateToolName?, toolResultFormat?, renderDirective? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner?, sttForceFinalizeTimeoutMs? }`. |
94
+ | `reasoner` | `(env, ctx) => Reasoner` (ctx: `{ sessionId, resume? }`). Defaults to `fromKuralleRuntime(this.runtime)` when the Agent exposes a kuralle `runtime`. Required for cascaded agents without one. |
95
+ | `recorder` | `(env, { sessionId }) => EdgeRecorder \| undefined` — optional per-call recorder (e.g. the R2 recorder at `@kuralle-syrinx/cf-agents/r2-recorder`). Edge transport. |
96
+ | `onToolCallStart` | `(ctx: { toolName, args, sessionId, connection }) => void \| Promise<void>` — fired the instant the front model invokes the delegate tool, **before** the reasoner runs — for app-specific cues beyond the standard `tool_call_*` wire messages. A throwing callback never affects the call. |
97
+ | `onDelegateQuery` / `onDelegateResult` | G2 observability hooks around the reasoner run. `onDelegateResult` is self-contained (`{ query, answer, durationMs, grounded, toolId?, toolName?, turnId, sessionId, connection }`) — the one hook for logging/persisting grounded Q&A pairs. Throwing never affects the call. |
98
+ | `durableHistory` | G4 durable session state over the Agent's SQLite (default `true`). Set `false` for ephemeral pre-G4 behavior. |
99
+ | `delayCueAfterMs` | G3: ms before a pending tool call fires the `tool_call_delayed` ("still working") cue. 0 disables. Default 2000. |
71
100
  | `inputSampleRateHz` / `outputSampleRateHz` | Edge audio rates (default 16000). |
72
101
  | `resumeWindowMs` | How long a dropped connection can resume its session. |
73
102
  | `sessionId` | `(request, agentName) => string`. Defaults to the `?sessionId=` query param (so a reconnecting client can resume), else a per-connection random id. (Not the Agent name — concurrent connections to one instance must not share a session.) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/cf-agents",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,13 +12,13 @@
12
12
  "./r2-recorder": "./src/r2-recorder.ts"
13
13
  },
14
14
  "dependencies": {
15
- "@kuralle-syrinx/core": "3.0.0",
16
- "@kuralle-syrinx/realtime": "3.0.0",
17
- "@kuralle-syrinx/aisdk": "3.0.0",
18
- "@kuralle-syrinx/server-websocket": "3.0.0",
19
- "@kuralle-syrinx/ws": "3.0.0",
20
- "@kuralle-syrinx/recorder": "3.0.0",
21
- "@kuralle-syrinx/kuralle": "3.0.0"
15
+ "@kuralle-syrinx/core": "4.0.0",
16
+ "@kuralle-syrinx/aisdk": "4.0.0",
17
+ "@kuralle-syrinx/kuralle": "4.0.0",
18
+ "@kuralle-syrinx/server-websocket": "4.0.0",
19
+ "@kuralle-syrinx/ws": "4.0.0",
20
+ "@kuralle-syrinx/realtime": "4.0.0",
21
+ "@kuralle-syrinx/recorder": "4.0.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "agents": ">=0.14.0 <1.0.0"
@@ -3,6 +3,8 @@
3
3
  import {
4
4
  VoiceAgentSession,
5
5
  type Reasoner,
6
+ type ReasonerMessage,
7
+ type ReasonerSessionStore,
6
8
  type VoicePlugin,
7
9
  type PluginConfig,
8
10
  } from "@kuralle-syrinx/core";
@@ -12,6 +14,28 @@ import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
12
14
  /** Per-session context handed to every pipeline factory. */
13
15
  export interface VoicePipelineContext {
14
16
  readonly sessionId: string;
17
+ /**
18
+ * G4 resume state for the session, present when durable history is on. The
19
+ * `front()` factory wires it into the adapter: `resumeHistory: ctx.resume.history`
20
+ * on replay providers (OpenAI), `sessionResumptionHandle: ctx.resume.providerHandle`
21
+ * on native-resume providers (Gemini — do NOT also replay, R6).
22
+ */
23
+ readonly resume?: {
24
+ /** Live view of the durable transcript (call again on reconnect for the current state). */
25
+ readonly history: () => readonly { readonly role: "user" | "assistant"; readonly content: string }[];
26
+ /** Latest provider-native resume handle, when one was issued. */
27
+ readonly providerHandle?: string;
28
+ };
29
+ }
30
+
31
+ /** Host wiring (withVoice) threaded into the assembled session. */
32
+ export interface VoiceSessionWiring {
33
+ /** G4: durable store for the cascaded ReasoningBridge's conversation history. */
34
+ readonly reasonerSessionStore?: ReasonerSessionStore;
35
+ /** G4: prior-context provider for realtime delegate turns (live view of durable history). */
36
+ readonly contextProvider?: () => readonly ReasonerMessage[];
37
+ /** G3: ms before a pending tool call fires its "delayed" (still-working) cue. */
38
+ readonly delayCueAfterMs?: number;
15
39
  }
16
40
 
17
41
  /**
@@ -26,6 +50,14 @@ export interface RealtimePipeline<Env> {
26
50
  readonly front: (env: Env, ctx: VoicePipelineContext) => RealtimeAdapter;
27
51
  /** Name of the front-model tool routed to the reasoner. @default "consult_knowledge" */
28
52
  readonly delegateToolName?: string;
53
+ /**
54
+ * How the delegate answer reaches the front model (G1): `"envelope"` (default) wraps
55
+ * it as a `DelegateResultEnvelope` (`response_text` + `require_repeat_verbatim`);
56
+ * `"string"` injects the raw answer.
57
+ */
58
+ readonly toolResultFormat?: "envelope" | "string";
59
+ /** Optional `render` directive included in the envelope, e.g. `"translate_faithfully"`. */
60
+ readonly renderDirective?: string;
29
61
  }
30
62
 
31
63
  /** A cascaded-stage plugin plus its `VoiceAgentSession` plugin config. */
@@ -71,13 +103,19 @@ export function buildVoiceSession<Env>(
71
103
  env: Env,
72
104
  reasoner: Reasoner | undefined,
73
105
  ctx: VoicePipelineContext,
106
+ wiring: VoiceSessionWiring = {},
74
107
  ): VoiceAgentSession {
75
108
  if (pipeline.kind === "realtime") {
76
109
  const front = pipeline.front(env, ctx);
77
- const bridge = new RealtimeBridge(front, reasoner, pipeline.delegateToolName);
110
+ const bridge = new RealtimeBridge(front, reasoner, pipeline.delegateToolName, {
111
+ ...(pipeline.toolResultFormat !== undefined ? { toolResultFormat: pipeline.toolResultFormat } : {}),
112
+ ...(pipeline.renderDirective !== undefined ? { renderDirective: pipeline.renderDirective } : {}),
113
+ ...(wiring.contextProvider ? { contextProvider: wiring.contextProvider } : {}),
114
+ });
78
115
  const session = new VoiceAgentSession({
79
116
  plugins: { realtime: {} },
80
117
  endpointingOwner: "timer",
118
+ ...(wiring.delayCueAfterMs !== undefined ? { delayCueAfterMs: wiring.delayCueAfterMs } : {}),
81
119
  });
82
120
  session.registerPlugin("realtime", bridge);
83
121
  return session;
@@ -116,9 +154,17 @@ export function buildVoiceSession<Env>(
116
154
  ...(pipeline.sttForceFinalizeTimeoutMs !== undefined
117
155
  ? { sttForceFinalizeTimeoutMs: pipeline.sttForceFinalizeTimeoutMs }
118
156
  : {}),
157
+ ...(wiring.delayCueAfterMs !== undefined ? { delayCueAfterMs: wiring.delayCueAfterMs } : {}),
119
158
  });
120
159
  session.registerPlugin("stt", stt.plugin);
121
- session.registerPlugin("bridge", new ReasoningBridge(reasoner));
160
+ session.registerPlugin(
161
+ "bridge",
162
+ new ReasoningBridge(reasoner, {
163
+ ...(wiring.reasonerSessionStore
164
+ ? { sessionStore: wiring.reasonerSessionStore, sessionId: ctx.sessionId }
165
+ : {}),
166
+ }),
167
+ );
122
168
  session.registerPlugin("tts", tts.plugin);
123
169
  if (vad) session.registerPlugin("vad", vad.plugin);
124
170
  if (eos) session.registerPlugin("eos", eos.plugin);
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // G4 (RFC bimodel-delegate-seam) — durable reasoner session state over the
4
+ // Agent's DO-SQLite. Survives Durable Object eviction/hibernation, replacing
5
+ // consumer-side module-global memory stores (the SLIIT "F8" finding).
6
+
7
+ import type { ReasonerMessage, ReasonerSessionStore } from "@kuralle-syrinx/core";
8
+
9
+ /** The agents-SDK `Agent.sql` tagged-template surface (synchronous in the DO). */
10
+ export type SqlTag = <T = Record<string, unknown>>(
11
+ strings: TemplateStringsArray,
12
+ ...values: (string | number | boolean | null)[]
13
+ ) => T[];
14
+
15
+ interface HistoryRow {
16
+ role: string;
17
+ content: string;
18
+ tool_call_id: string | null;
19
+ }
20
+
21
+ interface HandleRow {
22
+ handle: string;
23
+ }
24
+
25
+ /**
26
+ * `ReasonerSessionStore` backed by the Agent's SQLite. Also persists the latest
27
+ * provider-native resume handle (Gemini `sessionResumption`) per session.
28
+ */
29
+ export class SqliteReasonerSessionStore implements ReasonerSessionStore {
30
+ constructor(private readonly sql: SqlTag) {
31
+ this.sql`CREATE TABLE IF NOT EXISTS syrinx_reasoner_history (
32
+ session_id TEXT NOT NULL,
33
+ seq INTEGER NOT NULL,
34
+ role TEXT NOT NULL,
35
+ content TEXT NOT NULL,
36
+ tool_call_id TEXT,
37
+ PRIMARY KEY (session_id, seq)
38
+ )`;
39
+ this.sql`CREATE TABLE IF NOT EXISTS syrinx_resume_handle (
40
+ session_id TEXT PRIMARY KEY,
41
+ handle TEXT NOT NULL
42
+ )`;
43
+ }
44
+
45
+ load(sessionId: string): readonly ReasonerMessage[] {
46
+ const rows = this.sql<HistoryRow>`
47
+ SELECT role, content, tool_call_id FROM syrinx_reasoner_history
48
+ WHERE session_id = ${sessionId} ORDER BY seq ASC`;
49
+ return rows.map((row) => ({
50
+ role: row.role as ReasonerMessage["role"],
51
+ content: row.content,
52
+ ...(row.tool_call_id ? { toolCallId: row.tool_call_id } : {}),
53
+ }));
54
+ }
55
+
56
+ save(sessionId: string, messages: readonly ReasonerMessage[]): void {
57
+ this.sql`DELETE FROM syrinx_reasoner_history WHERE session_id = ${sessionId}`;
58
+ messages.forEach((message, seq) => {
59
+ this.sql`INSERT INTO syrinx_reasoner_history (session_id, seq, role, content, tool_call_id)
60
+ VALUES (${sessionId}, ${seq}, ${message.role}, ${message.content}, ${message.toolCallId ?? null})`;
61
+ });
62
+ }
63
+
64
+ clear(sessionId: string): void {
65
+ this.sql`DELETE FROM syrinx_reasoner_history WHERE session_id = ${sessionId}`;
66
+ this.sql`DELETE FROM syrinx_resume_handle WHERE session_id = ${sessionId}`;
67
+ }
68
+
69
+ loadResumeHandle(sessionId: string): string | undefined {
70
+ const rows = this.sql<HandleRow>`
71
+ SELECT handle FROM syrinx_resume_handle WHERE session_id = ${sessionId}`;
72
+ return rows[0]?.handle;
73
+ }
74
+
75
+ saveResumeHandle(sessionId: string, handle: string): void {
76
+ this.sql`DELETE FROM syrinx_resume_handle WHERE session_id = ${sessionId}`;
77
+ this.sql`INSERT INTO syrinx_resume_handle (session_id, handle) VALUES (${sessionId}, ${handle})`;
78
+ }
79
+ }
package/src/index.ts CHANGED
@@ -3,13 +3,22 @@
3
3
  // @kuralle-syrinx/cf-agents — add a Syrinx voice pipeline (realtime or cascaded) to
4
4
  // a Cloudflare `agents` SDK Agent via the `withVoice(Agent, options)` mixin.
5
5
 
6
- export { withVoice, type WithVoiceOptions, type WithVoiceMembers } from "./with-voice.js";
6
+ export {
7
+ withVoice,
8
+ type WithVoiceOptions,
9
+ type WithVoiceMembers,
10
+ type ToolCallStartContext,
11
+ type DelegateQueryContext,
12
+ type DelegateResultContext,
13
+ } from "./with-voice.js";
7
14
  export type {
8
15
  VoicePipeline,
9
16
  RealtimePipeline,
10
17
  CascadedPipeline,
11
18
  CascadedStage,
12
19
  VoicePipelineContext,
20
+ VoiceSessionWiring,
13
21
  } from "./build-session.js";
22
+ export { SqliteReasonerSessionStore, type SqlTag } from "./durable-history.js";
14
23
  export { connectionManagedSocket } from "./connection-socket.js";
15
24
  export type { VoiceConnection, ConnectionSocketController } from "./connection-socket.js";
@@ -8,15 +8,54 @@ interface PutCall {
8
8
  body: Uint8Array | string;
9
9
  }
10
10
 
11
+ interface MpuRec {
12
+ key: string;
13
+ parts: { partNumber: number; size: number }[];
14
+ first?: Uint8Array; // retained bytes of part 1 (the header-bearing part)
15
+ completed: boolean;
16
+ }
17
+
11
18
  function fakeBucket() {
12
19
  const puts: PutCall[] = [];
20
+ const mpus = new Map<string, MpuRec>();
21
+ let seq = 0;
22
+
23
+ function handle(uploadId: string) {
24
+ const rec = mpus.get(uploadId)!;
25
+ return {
26
+ key: rec.key,
27
+ uploadId,
28
+ async uploadPart(partNumber: number, value: Uint8Array) {
29
+ rec.parts.push({ partNumber, size: value.byteLength });
30
+ if (partNumber === 1) rec.first = value.slice();
31
+ return { partNumber, etag: `etag-${partNumber}` };
32
+ },
33
+ async complete(_parts: unknown) {
34
+ rec.completed = true;
35
+ return { key: rec.key } as unknown;
36
+ },
37
+ async abort() {
38
+ /* no-op */
39
+ },
40
+ };
41
+ }
42
+
13
43
  const bucket = {
14
44
  async put(key: string, body: Uint8Array | string) {
15
- puts.push({ key, body });
45
+ puts.push({ key, body: typeof body === "string" ? body : body.slice() });
16
46
  return {} as unknown;
17
47
  },
48
+ async createMultipartUpload(key: string) {
49
+ const uploadId = `u${++seq}`;
50
+ mpus.set(uploadId, { key, parts: [], completed: false });
51
+ return handle(uploadId);
52
+ },
53
+ resumeMultipartUpload(_key: string, uploadId: string) {
54
+ return handle(uploadId);
55
+ },
18
56
  } as unknown as R2Bucket;
19
- return { bucket, puts };
57
+
58
+ return { bucket, puts, mpus };
20
59
  }
21
60
 
22
61
  function asString(body: Uint8Array | string): string {
@@ -51,9 +90,10 @@ describe("R2EdgeRecorder", () => {
51
90
  expect(wavChannels(puts.find((p) => p.key.endsWith("user.wav"))!.body)).toBe(1);
52
91
 
53
92
  const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
54
- conversation: { channels: number };
93
+ conversation: { channels: number; omitted: boolean };
55
94
  };
56
95
  expect(manifest.conversation.channels).toBe(2);
96
+ expect(manifest.conversation.omitted).toBe(false);
57
97
  });
58
98
 
59
99
  it("time-aligns the assistant after the user instead of stacking at 0", async () => {
@@ -97,4 +137,49 @@ describe("R2EdgeRecorder", () => {
97
137
  expect(manifest.user.byteLength).toBe(800);
98
138
  expect(manifest.user.truncated).toBe(true);
99
139
  });
140
+
141
+ it("streams a long, mostly-silent call to R2 in bounded parts instead of one giant buffer", async () => {
142
+ const { bucket, puts, mpus } = fakeBucket();
143
+ let now = 0;
144
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "long", startedAtMs: 0, now: () => now });
145
+
146
+ // A 20-minute call: a blip of audio, a 20-minute wall-clock gap, then another blip.
147
+ // The old recorder would gap-fill the full ~38 MB wall-clock length in one allocation
148
+ // at finalize (OOMing the 128 MB DO). The stream must instead flush it as R2 parts.
149
+ now = 0;
150
+ rec.onUserAudio("c", new Uint8Array(320), 16000);
151
+ now = 20 * 60 * 1000; // +20 min
152
+ rec.onUserAudio("c", new Uint8Array(320), 16000);
153
+ await rec.finalize({ sessionId: "long", closedAtMs: now + 100 });
154
+
155
+ const expectedDataBytes = 20 * 60 * 16000 * 2 + 320; // wall-clock timeline + the final blip
156
+
157
+ // (1) The user stem streamed out incrementally: multiple parts, not one buffer.
158
+ const userMpu = [...mpus.values()].find((m) => m.key === "recordings/long/0/user.wav")!;
159
+ expect(userMpu).toBeDefined();
160
+ expect(userMpu.parts.length).toBeGreaterThan(1);
161
+ expect(userMpu.completed).toBe(true);
162
+ // Every part except the last carries at least R2's 5 MiB minimum (part 1 also holds the header).
163
+ const byNumber = [...userMpu.parts].sort((a, b) => a.partNumber - b.partNumber);
164
+ for (const p of byNumber.slice(0, -1)) expect(p.size).toBeGreaterThanOrEqual(5 * 1024 * 1024);
165
+
166
+ // (2) The stem decodes to the right duration: part 1's WAV header declares the full
167
+ // data length, and the parts sum to header(44) + that length.
168
+ const header = new DataView(userMpu.first!.buffer, userMpu.first!.byteOffset, userMpu.first!.byteLength);
169
+ expect(header.getUint32(40, true)).toBe(expectedDataBytes); // WAV data-chunk size field
170
+ const uploaded = userMpu.parts.reduce((n, p) => n + p.size, 0);
171
+ expect(uploaded).toBe(44 + expectedDataBytes);
172
+
173
+ // (3) finalize still writes the manifest; the stem length/duration match, and the
174
+ // stereo mix is flagged omitted (best-effort) once a stem has streamed out.
175
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
176
+ user: { byteLength: number; durationMs: number; truncated: boolean };
177
+ conversation: { omitted: boolean };
178
+ };
179
+ expect(manifest.user.byteLength).toBe(expectedDataBytes);
180
+ expect(manifest.user.durationMs).toBe(Math.round((expectedDataBytes / 2 / 16000) * 1000));
181
+ expect(manifest.user.truncated).toBe(false);
182
+ expect(manifest.conversation.omitted).toBe(true);
183
+ expect(puts.some((p) => p.key.endsWith("conversation.wav"))).toBe(false);
184
+ });
100
185
  });