@kuralle-syrinx/kuralle 4.1.0 → 4.3.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/package.json CHANGED
@@ -1,21 +1,38 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/kuralle",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "kuralle-agents bridge for Syrinx — run a kuralle runtime as the voice pipeline's reasoner",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "kuralle"
12
+ ],
6
13
  "license": "MIT",
14
+ "homepage": "https://github.com/kuralle/syrinx#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/kuralle/syrinx.git",
18
+ "directory": "packages/kuralle"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/kuralle/syrinx/issues"
22
+ },
23
+ "type": "module",
7
24
  "main": "./src/index.ts",
8
25
  "types": "./src/index.ts",
9
26
  "dependencies": {
10
- "@kuralle-syrinx/core": "4.1.0"
27
+ "@kuralle-syrinx/core": "4.3.0"
11
28
  },
12
29
  "peerDependencies": {
13
- "@kuralle-agents/core": ">=0.8.0"
30
+ "@kuralle-agents/core": ">=0.13.0 <1"
14
31
  },
15
32
  "devDependencies": {
16
- "@kuralle-agents/core": "^0.8.5",
33
+ "@kuralle-agents/core": "^0.13.0",
17
34
  "typescript": "^5.7.0",
18
- "vitest": "^2.1.0"
35
+ "vitest": "^3.2.6"
19
36
  },
20
37
  "scripts": {
21
38
  "typecheck": "tsc --noEmit",
@@ -4,6 +4,7 @@ import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core
4
4
  import { categorizeLlmError, isRecoverable } from "@kuralle-syrinx/core";
5
5
 
6
6
  export interface KuralleStreamPart {
7
+ readonly [key: string]: unknown;
7
8
  readonly type: string;
8
9
  readonly delta?: string;
9
10
  readonly toolName?: string;
@@ -16,6 +17,8 @@ export interface KuralleStreamPart {
16
17
  readonly options?: unknown;
17
18
  readonly prompt?: string;
18
19
  readonly sessionId?: string;
20
+ readonly userFacingMessage?: string;
21
+ readonly payload?: unknown;
19
22
  }
20
23
 
21
24
  export interface KuralleMessageLike {
@@ -26,7 +29,18 @@ export interface KuralleMessageLike {
26
29
  export interface KuralleStoredSession {
27
30
  readonly id: string;
28
31
  messages: KuralleMessageLike[];
29
- [key: string]: unknown;
32
+ /**
33
+ * Durable-run bookkeeping the bridge reads for flow-resume. Optional and unknown-typed
34
+ * because kuralle owns its shape.
35
+ *
36
+ * This was a `[key: string]: unknown` catch-all, which made the interface unsatisfiable
37
+ * by any concrete type: a real `Session` has no index signature, so it could never be
38
+ * assigned here. Nothing caught that, because nothing type-checked the bridge against
39
+ * the real Runtime — see `real-runtime.compile-check.ts`. Only this one key is read.
40
+ */
41
+ durableRuns?: unknown;
42
+ /** Working-memory blob the bridge inspects on resume. Kuralle owns its shape. */
43
+ workingMemory?: unknown;
30
44
  }
31
45
 
32
46
  export interface KuralleSessionStoreLike {
@@ -45,9 +59,32 @@ export interface KuralleRunOptions {
45
59
  readonly userId?: string;
46
60
  readonly agentId?: string;
47
61
  readonly abortSignal?: AbortSignal;
48
- readonly historyDelta?: ReadonlyArray<{ readonly role: string; readonly content: string }>;
62
+ /**
63
+ * Prior turns seeded into an empty kuralle session (G4 resume-by-seed).
64
+ *
65
+ * Deliberately narrower than it looks: the real `Runtime.run` takes `ModelMessage[]`,
66
+ * whose `role` is a union, so a `string` role here would be too wide to assign — and a
67
+ * mutable array, so `ReadonlyArray` would not assign either. The producer
68
+ * (`buildHistoryDeltaSeed`) already filters to user/assistant, so this only makes the
69
+ * declaration honest about what the code was doing.
70
+ *
71
+ * `real-runtime.compile-check.ts` pins this against the actual Runtime type.
72
+ */
73
+ historyDelta?: Array<{ role: "user" | "assistant"; content: string }>;
49
74
  }
50
75
 
76
+ /**
77
+ * The slice of kuralle's `Runtime` this bridge calls, as a loose structural shape.
78
+ *
79
+ * Deliberately hand-written rather than `Pick<Runtime, ...>`. Deriving was tried and is
80
+ * strictly worse here: it drags in `TurnHandle`, `HarnessStreamPart`, `Session` and
81
+ * `RunOptions` wholesale, so every test fake and adapter would have to construct
82
+ * full-fidelity kuralle objects to call a bridge whose whole point is loose coupling.
83
+ *
84
+ * The drift that freedom costs is contained by `real-runtime.compile-check.ts`, which
85
+ * asserts the REAL `Runtime` still satisfies this shape. That check is what was missing —
86
+ * not stricter types. It is the same pattern `cf-agents` uses for the agents SDK.
87
+ */
51
88
  export interface KuralleRuntimeLike {
52
89
  run(opts: KuralleRunOptions): KuralleTurnHandle;
53
90
  getSession?(sessionId: string): Promise<KuralleStoredSession | null>;
@@ -101,7 +138,10 @@ function seedHistoryDelta(
101
138
  ): Pick<KuralleRunOptions, "historyDelta"> | Record<string, never> {
102
139
  if (session && session.messages.length > 0) return {};
103
140
  const delta = messages
104
- .filter((message) => message.role === "user" || message.role === "assistant")
141
+ .filter(
142
+ (message): message is typeof message & { role: "user" | "assistant" } =>
143
+ message.role === "user" || message.role === "assistant",
144
+ )
105
145
  .map((message) => ({ role: message.role, content: message.content }));
106
146
  return delta.length > 0 ? { historyDelta: delta } : {};
107
147
  }
@@ -243,10 +283,18 @@ export async function* streamFromKuralle(
243
283
  payload: part,
244
284
  };
245
285
  return;
286
+ case "safety-blocked":
287
+ yield {
288
+ type: "blocked",
289
+ userFacingMessage: String(part.userFacingMessage ?? "This request cannot be completed."),
290
+ payload: part,
291
+ };
292
+ return;
246
293
  case "done":
247
294
  yield { type: "finish", reason: "stop", text: acc };
248
295
  return;
249
296
  default:
297
+ yield { type: "control", name: part.type, payload: part };
250
298
  break;
251
299
  }
252
300
  if (aborted) break;
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Compile-only proof that the REAL `@kuralle-agents/core` Runtime satisfies the
4
+ // structural `KuralleRuntimeLike` this bridge consumes. Type-checked by
5
+ // `tsc --noEmit`; never executed.
6
+ //
7
+ // Why this file exists: the bridge deliberately types against a structural interface
8
+ // rather than importing kuralle's types, so a consumer can supply any runtime-shaped
9
+ // object. The cost of that freedom is that nothing was verifying the real Runtime
10
+ // still fits — the package pinned `^0.8.5` in devDeps while npm shipped 0.13.0, five
11
+ // minors and a package-folder rename later, and no check would have failed. If kuralle
12
+ // changes `run` / `getSession` / `getSessionStore`, this file stops compiling.
13
+ //
14
+ // Mirrors the same pattern as `packages/cf-agents/src/real-agent.compile-check.ts`.
15
+
16
+ import type { HarnessStreamPart, Runtime } from "@kuralle-agents/core";
17
+ import { fromKuralleRuntime, type KuralleRuntimeLike } from "./from-kuralle.js";
18
+
19
+ // The three surfaces the bridge actually calls. If the real Runtime drifts from any
20
+ // of them, this assignment fails to type-check.
21
+ declare const realRuntime: Runtime;
22
+ const asBridgeRuntime: KuralleRuntimeLike = realRuntime;
23
+
24
+ // And the real thing composes into the seam the voice pipeline consumes.
25
+ export const reasonerFromRealRuntime = fromKuralleRuntime(asBridgeRuntime, {
26
+ sessionId: "compile-check-session",
27
+ });
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Part-type literals
31
+ // ---------------------------------------------------------------------------
32
+ //
33
+ // `KuralleStreamPart.type` is `string`, so `streamFromKuralle`'s switch is matching
34
+ // bare string literals with nothing pinning them to reality. The shape check above
35
+ // would stay green if kuralle renamed `"done"` to `"finish"` — and the bridge treats a
36
+ // missing `done` as "stream ended without a done part", i.e. EVERY turn would fail with
37
+ // a terminal error while the type system reported no problem.
38
+ //
39
+ // kuralle exports `HarnessStreamPart` as a discriminated union of literal types, so the
40
+ // switch cases can be pinned at compile time. Keep this list in sync with the `case`
41
+ // arms in `streamFromKuralle`; if upstream renames or drops one, this stops compiling.
42
+
43
+ /** Exactly the part types handled by explicit `streamFromKuralle` case arms. */
44
+ type BridgedPartType =
45
+ | "text-delta"
46
+ | "tool-call"
47
+ | "tool-result"
48
+ | "error"
49
+ | "paused"
50
+ | "interactive"
51
+ | "safety-blocked"
52
+ | "done";
53
+
54
+ type _AssertTrue<T extends true> = T;
55
+ type _BridgedPartTypesStillExistUpstream = _AssertTrue<
56
+ BridgedPartType extends HarnessStreamPart["type"] ? true : false
57
+ >;
@@ -1,350 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
5
- import {
6
- buildKuralleTurnRunOptions,
7
- fromKuralleRuntime,
8
- reconcileSpokenPrefix,
9
- rewriteLastAssistant,
10
- type KuralleMessageLike,
11
- type KuralleRunOptions,
12
- type KuralleRuntimeLike,
13
- type KuralleSessionStoreLike,
14
- type KuralleStoredSession,
15
- type KuralleStreamPart,
16
- } from "./from-kuralle.js";
17
-
18
- function baseTurn(): ReasonerTurn {
19
- return {
20
- userText: "Hi",
21
- messages: [{ role: "system", content: "test" }],
22
- signal: new AbortController().signal,
23
- };
24
- }
25
-
26
- function textDelta(delta: string): KuralleStreamPart {
27
- return { type: "text-delta", delta };
28
- }
29
-
30
- function toolCall(toolCallId: string, toolName: string, args: Record<string, unknown>): KuralleStreamPart {
31
- return { type: "tool-call", toolCallId, toolName, args };
32
- }
33
-
34
- function toolResult(toolCallId: string, toolName: string, result: unknown): KuralleStreamPart {
35
- return { type: "tool-result", toolCallId, toolName, result };
36
- }
37
-
38
- function errorPart(error: string): KuralleStreamPart {
39
- return { type: "error", error };
40
- }
41
-
42
- function done(sessionId?: string): KuralleStreamPart {
43
- return { type: "done", sessionId };
44
- }
45
-
46
- async function* partsToEvents(parts: KuralleStreamPart[]): AsyncIterable<KuralleStreamPart> {
47
- for (const p of parts) yield p;
48
- }
49
-
50
- function fakeRuntime(
51
- parts: KuralleStreamPart[],
52
- spy?: {
53
- runOpts?: {
54
- input?: string;
55
- sessionId?: string;
56
- userId?: string;
57
- agentId?: string;
58
- abortSignal?: AbortSignal;
59
- };
60
- },
61
- ): KuralleRuntimeLike {
62
- return {
63
- run(opts) {
64
- if (spy) spy.runOpts = opts;
65
- return { events: partsToEvents(parts) };
66
- },
67
- };
68
- }
69
-
70
- async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
71
- const collected: ReasoningPart[] = [];
72
- for await (const part of reasoner.stream(turn)) {
73
- collected.push(part);
74
- }
75
- return collected;
76
- }
77
-
78
- describe("fromKuralleRuntime G4 resume-by-seed (historyDelta)", () => {
79
- const turnWithContext = (): ReasonerTurn => ({
80
- userText: "Second question",
81
- messages: [
82
- { role: "system", content: "sys" },
83
- { role: "user", content: "First question" },
84
- { role: "assistant", content: "First answer" },
85
- ],
86
- signal: new AbortController().signal,
87
- });
88
-
89
- it("seeds prior turn.messages into an EMPTY kuralle session (fresh isolate resume)", async () => {
90
- const spy: { runOpts?: KuralleRunOptions } = {};
91
- const runtime: KuralleRuntimeLike = {
92
- run(opts) {
93
- spy.runOpts = opts;
94
- return { events: partsToEvents([textDelta("ok"), done()]) };
95
- },
96
- getSession: async () => null,
97
- };
98
- const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
99
-
100
- await collectParts(reasoner, turnWithContext());
101
-
102
- expect(spy.runOpts?.input).toBe("Second question");
103
- expect(spy.runOpts?.historyDelta).toEqual([
104
- { role: "user", content: "First question" },
105
- { role: "assistant", content: "First answer" },
106
- ]);
107
- });
108
-
109
- it("does NOT seed into a non-empty session (no double-applied history, R6)", async () => {
110
- const spy: { runOpts?: KuralleRunOptions } = {};
111
- const runtime: KuralleRuntimeLike = {
112
- run(opts) {
113
- spy.runOpts = opts;
114
- return { events: partsToEvents([textDelta("ok"), done()]) };
115
- },
116
- getSession: async () => ({
117
- id: "sess-1",
118
- messages: [{ role: "user", content: "already there" }],
119
- }),
120
- };
121
- const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
122
-
123
- await collectParts(reasoner, turnWithContext());
124
-
125
- expect(spy.runOpts?.input).toBe("Second question");
126
- expect(spy.runOpts?.historyDelta).toBeUndefined();
127
- });
128
- });
129
-
130
- describe("fromKuralleRuntime", () => {
131
- it("maps happy path: text deltas and done to finish:stop", async () => {
132
- const reasoner = fromKuralleRuntime(
133
- fakeRuntime([textDelta("Hi"), textDelta(" there"), done("sess-1")]),
134
- { sessionId: "sess-1" },
135
- );
136
-
137
- const parts = await collectParts(reasoner, baseTurn());
138
-
139
- expect(parts).toEqual([
140
- { type: "text-delta", text: "Hi" },
141
- { type: "text-delta", text: " there" },
142
- { type: "finish", reason: "stop", text: "Hi there" },
143
- ]);
144
- });
145
-
146
- it("maps tool-call and tool-result with toolId from toolCallId", async () => {
147
- const reasoner = fromKuralleRuntime(
148
- fakeRuntime([
149
- toolCall("tc-1", "lookup", { id: "123" }),
150
- toolResult("tc-1", "lookup", { found: true }),
151
- done(),
152
- ]),
153
- { sessionId: "sess-1" },
154
- );
155
-
156
- const parts = await collectParts(reasoner, baseTurn());
157
-
158
- expect(parts).toEqual([
159
- {
160
- type: "tool-call",
161
- toolId: "tc-1",
162
- toolName: "lookup",
163
- args: { id: "123" },
164
- },
165
- {
166
- type: "tool-result",
167
- toolId: "tc-1",
168
- toolName: "lookup",
169
- result: JSON.stringify({ found: true }),
170
- },
171
- { type: "finish", reason: "stop", text: "" },
172
- ]);
173
- });
174
-
175
- it("maps error part to terminal error", async () => {
176
- const reasoner = fromKuralleRuntime(fakeRuntime([errorPart("boom")]), { sessionId: "sess-1" });
177
-
178
- const parts = await collectParts(reasoner, baseTurn());
179
-
180
- expect(parts).toHaveLength(1);
181
- expect(parts[0]?.type).toBe("error");
182
- if (parts[0]?.type === "error") {
183
- expect(parts[0].cause.message).toBe("boom");
184
- expect(parts[0].recoverable).toBe(false);
185
- }
186
- });
187
-
188
- it("yields terminal error when stream ends without done", async () => {
189
- const reasoner = fromKuralleRuntime(fakeRuntime([textDelta("partial")]), { sessionId: "sess-1" });
190
-
191
- const parts = await collectParts(reasoner, baseTurn());
192
-
193
- expect(parts).toHaveLength(2);
194
- expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
195
- expect(parts[1]?.type).toBe("error");
196
- if (parts[1]?.type === "error") {
197
- expect(parts[1].cause.message).toBe("Kuralle stream ended without a done part");
198
- expect(parts[1].recoverable).toBe(false);
199
- }
200
- });
201
-
202
- it("returns without yielding when turn.signal is already aborted", async () => {
203
- const controller = new AbortController();
204
- controller.abort();
205
- const turn: ReasonerTurn = { ...baseTurn(), signal: controller.signal };
206
-
207
- const reasoner = fromKuralleRuntime(
208
- fakeRuntime([textDelta("should not appear"), done()]),
209
- { sessionId: "sess-1" },
210
- );
211
-
212
- const parts = await collectParts(reasoner, turn);
213
-
214
- expect(parts).toEqual([]);
215
- });
216
-
217
- it("passes only userText to run, not turn.messages", async () => {
218
- const spy: { runOpts?: { input?: string; sessionId?: string } } = {};
219
- const reasoner = fromKuralleRuntime(fakeRuntime([done()], spy), { sessionId: "sess-42" });
220
-
221
- const turn: ReasonerTurn = {
222
- userText: "What is my name?",
223
- messages: [
224
- { role: "system", content: "ignored" },
225
- { role: "user", content: "also ignored" },
226
- ],
227
- signal: new AbortController().signal,
228
- };
229
- await collectParts(reasoner, turn);
230
-
231
- expect(spy.runOpts?.input).toBe("What is my name?");
232
- expect(spy.runOpts?.sessionId).toBe("sess-42");
233
- });
234
-
235
- it("flow resume pre-appends user message and omits input", async () => {
236
- const sessionId = "sess-flow";
237
- const store = createMockSessionStore(sessionId, [{ role: "assistant", content: "What is your name?" }]);
238
- const session = (await store.get(sessionId))!;
239
- session.durableRuns = {
240
- [sessionId]: {
241
- runState: {
242
- activeFlow: "book-advisor-appointment",
243
- messages: [{ role: "assistant", content: "What is your name?" }],
244
- },
245
- steps: [],
246
- },
247
- };
248
- await store.save(session);
249
-
250
- const runtime: KuralleRuntimeLike = {
251
- run: () => ({ events: partsToEvents([done()]) }),
252
- getSession: (id) => store.get(id),
253
- getSessionStore: () => store,
254
- };
255
-
256
- const opts = await buildKuralleTurnRunOptions(runtime, {
257
- sessionId,
258
- userText: "Priya, CS masters, Friday",
259
- });
260
-
261
- expect(opts.input).toBeUndefined();
262
- expect(opts.historyDelta).toBeUndefined();
263
- const saved = await store.get(sessionId);
264
- expect(saved?.messages.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
265
- const runMessages = (
266
- saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>
267
- )[sessionId]?.runState.messages;
268
- expect(runMessages?.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
269
- });
270
-
271
- it("rewriteLastAssistant truncates only when spoken prefix is shorter", () => {
272
- const messages: KuralleMessageLike[] = [
273
- { role: "user", content: "Hi" },
274
- { role: "assistant", content: "Hello there friend" },
275
- ];
276
- expect(rewriteLastAssistant(messages, "Hello")).toEqual([
277
- { role: "user", content: "Hi" },
278
- { role: "assistant", content: "Hello" },
279
- ]);
280
- expect(rewriteLastAssistant(messages, "Hello there friend")).toStrictEqual(messages);
281
- });
282
-
283
- it("after barge-in abort, reconciles persisted assistant message to spoken prefix", async () => {
284
- const fullText = "Hello there friend";
285
- const spokenPrefix = "Hello";
286
- const sessionId = "sess-barge";
287
- const store = createMockSessionStore(sessionId, []);
288
-
289
- await reconcileSpokenPrefix(
290
- { run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
291
- sessionId,
292
- spokenPrefix,
293
- );
294
- let session = await store.get(sessionId);
295
- expect(session?.messages).toEqual([]);
296
-
297
- session = (await store.get(sessionId)) ?? { id: sessionId, messages: [] };
298
- session.messages = [{ role: "assistant", content: fullText }];
299
- await store.save(session);
300
-
301
- await reconcileSpokenPrefix(
302
- { run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
303
- sessionId,
304
- spokenPrefix,
305
- );
306
- session = await store.get(sessionId);
307
- expect(session?.messages.at(-1)).toEqual({ role: "assistant", content: spokenPrefix });
308
- });
309
-
310
- it("reconcileSpokenPrefix rewrites durable run messages", async () => {
311
- const sessionId = "sess-durable";
312
- const store = createMockSessionStore(sessionId, []);
313
- const session = (await store.get(sessionId))!;
314
- session.messages = [{ role: "assistant", content: "full reply text" }];
315
- session.durableRuns = {
316
- [sessionId]: {
317
- runState: { messages: [{ role: "assistant", content: "full reply text" }] },
318
- steps: [],
319
- },
320
- };
321
- await store.save(session);
322
-
323
- await reconcileSpokenPrefix(
324
- { run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
325
- sessionId,
326
- "full",
327
- );
328
-
329
- const saved = await store.get(sessionId);
330
- expect(saved?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
331
- const runState = (saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>)[sessionId]
332
- ?.runState;
333
- expect(runState?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
334
- });
335
- });
336
-
337
- function createMockSessionStore(sessionId: string, messages: KuralleMessageLike[]): KuralleSessionStoreLike {
338
- const sessions = new Map<string, KuralleStoredSession>([
339
- [sessionId, { id: sessionId, messages: [...messages] }],
340
- ]);
341
- return {
342
- async get(id) {
343
- const session = sessions.get(id);
344
- return session ? structuredClone(session) : null;
345
- },
346
- async save(session) {
347
- sessions.set(session.id, structuredClone(session));
348
- },
349
- };
350
- }
package/tsconfig.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "types": ["vitest/globals"],
10
- "noEmit": true
11
- },
12
- "include": ["src/**/*.ts"]
13
- }