@kuralle-syrinx/kuralle 2.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.
- package/LICENSE +22 -0
- package/README.md +35 -0
- package/package.json +24 -0
- package/src/from-kuralle.test.ts +297 -0
- package/src/from-kuralle.ts +323 -0
- package/src/index.ts +17 -0
- package/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @kuralle-syrinx/kuralle
|
|
2
|
+
|
|
3
|
+
The Kuralle adapter for the Syrinx [`Reasoner`](../core/README.md#the-reasoner-seam) seam: **`fromKuralleRuntime(runtime, { sessionId }) → Reasoner`**. Drop it into [`ReasoningBridge`](../aisdk/README.md) to drive the voice pipeline with a Kuralle `Runtime` instead of a raw AI SDK backend — no pipeline change.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createRuntime, defineAgent } from "@kuralle-agents/core";
|
|
9
|
+
import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
|
|
10
|
+
import { fromKuralleRuntime } from "@kuralle-syrinx/kuralle";
|
|
11
|
+
|
|
12
|
+
const runtime = createRuntime({ agents: [defineAgent({ id: "support", /* ... */ })], /* ... */ });
|
|
13
|
+
session.registerPlugin("bridge", new ReasoningBridge(fromKuralleRuntime(runtime, { sessionId })));
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## How it maps
|
|
17
|
+
|
|
18
|
+
`fromKuralleRuntime` calls `runtime.run({ input: turn.userText, sessionId, ... })` and iterates `events` (an `AsyncIterable<KuralleStreamPart>`), mapping each part → `ReasoningPart`. **`turn.messages` is ignored** — Kuralle owns conversation history via `sessionId`.
|
|
19
|
+
|
|
20
|
+
| Kuralle part | → |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `text-delta` (`delta`) | `text-delta` |
|
|
23
|
+
| `tool-call` (`toolCallId`, `toolName`, `args`) | `tool-call` |
|
|
24
|
+
| `tool-result` (`toolCallId`, `toolName`, `result`) | `tool-result` |
|
|
25
|
+
| `paused` / `interactive` | terminal `suspended` |
|
|
26
|
+
| `done` | `finish` (`reason: "stop"`, accumulated text) |
|
|
27
|
+
| `error` (`error`) | terminal `error` |
|
|
28
|
+
| anything else | dropped |
|
|
29
|
+
|
|
30
|
+
`KuralleRuntimeLike` is the minimal structural type the adapter needs (`run` → `events`) — the concrete `@kuralle-agents/core` `Runtime` satisfies it.
|
|
31
|
+
|
|
32
|
+
## Gotchas
|
|
33
|
+
|
|
34
|
+
- **`@kuralle-agents/core` is a `peerDependency`** — the consumer instantiates and owns the `Runtime`. The adapter only touches `run`/`events`.
|
|
35
|
+
- **History stays Kuralle-owned** via `sessionId`: the bridge passes only `turn.userText` per turn; do not expect Syrinx message history to reach Kuralle.
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/kuralle",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@kuralle-syrinx/core": "2.1.0"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@kuralle-agents/core": ">=0.8.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@kuralle-agents/core": "^0.8.5",
|
|
17
|
+
"typescript": "^5.7.0",
|
|
18
|
+
"vitest": "^2.1.0"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "vitest run"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
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 KuralleRuntimeLike,
|
|
12
|
+
type KuralleSessionStoreLike,
|
|
13
|
+
type KuralleStoredSession,
|
|
14
|
+
type KuralleStreamPart,
|
|
15
|
+
} from "./from-kuralle.js";
|
|
16
|
+
|
|
17
|
+
function baseTurn(): ReasonerTurn {
|
|
18
|
+
return {
|
|
19
|
+
userText: "Hi",
|
|
20
|
+
messages: [{ role: "system", content: "test" }],
|
|
21
|
+
signal: new AbortController().signal,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textDelta(delta: string): KuralleStreamPart {
|
|
26
|
+
return { type: "text-delta", delta };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toolCall(toolCallId: string, toolName: string, args: Record<string, unknown>): KuralleStreamPart {
|
|
30
|
+
return { type: "tool-call", toolCallId, toolName, args };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolResult(toolCallId: string, toolName: string, result: unknown): KuralleStreamPart {
|
|
34
|
+
return { type: "tool-result", toolCallId, toolName, result };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorPart(error: string): KuralleStreamPart {
|
|
38
|
+
return { type: "error", error };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function done(sessionId?: string): KuralleStreamPart {
|
|
42
|
+
return { type: "done", sessionId };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function* partsToEvents(parts: KuralleStreamPart[]): AsyncIterable<KuralleStreamPart> {
|
|
46
|
+
for (const p of parts) yield p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fakeRuntime(
|
|
50
|
+
parts: KuralleStreamPart[],
|
|
51
|
+
spy?: {
|
|
52
|
+
runOpts?: {
|
|
53
|
+
input?: string;
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
userId?: string;
|
|
56
|
+
agentId?: string;
|
|
57
|
+
abortSignal?: AbortSignal;
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
): KuralleRuntimeLike {
|
|
61
|
+
return {
|
|
62
|
+
run(opts) {
|
|
63
|
+
if (spy) spy.runOpts = opts;
|
|
64
|
+
return { events: partsToEvents(parts) };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
|
|
70
|
+
const collected: ReasoningPart[] = [];
|
|
71
|
+
for await (const part of reasoner.stream(turn)) {
|
|
72
|
+
collected.push(part);
|
|
73
|
+
}
|
|
74
|
+
return collected;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("fromKuralleRuntime", () => {
|
|
78
|
+
it("maps happy path: text deltas and done to finish:stop", async () => {
|
|
79
|
+
const reasoner = fromKuralleRuntime(
|
|
80
|
+
fakeRuntime([textDelta("Hi"), textDelta(" there"), done("sess-1")]),
|
|
81
|
+
{ sessionId: "sess-1" },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
85
|
+
|
|
86
|
+
expect(parts).toEqual([
|
|
87
|
+
{ type: "text-delta", text: "Hi" },
|
|
88
|
+
{ type: "text-delta", text: " there" },
|
|
89
|
+
{ type: "finish", reason: "stop", text: "Hi there" },
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps tool-call and tool-result with toolId from toolCallId", async () => {
|
|
94
|
+
const reasoner = fromKuralleRuntime(
|
|
95
|
+
fakeRuntime([
|
|
96
|
+
toolCall("tc-1", "lookup", { id: "123" }),
|
|
97
|
+
toolResult("tc-1", "lookup", { found: true }),
|
|
98
|
+
done(),
|
|
99
|
+
]),
|
|
100
|
+
{ sessionId: "sess-1" },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
104
|
+
|
|
105
|
+
expect(parts).toEqual([
|
|
106
|
+
{
|
|
107
|
+
type: "tool-call",
|
|
108
|
+
toolId: "tc-1",
|
|
109
|
+
toolName: "lookup",
|
|
110
|
+
args: { id: "123" },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: "tool-result",
|
|
114
|
+
toolId: "tc-1",
|
|
115
|
+
toolName: "lookup",
|
|
116
|
+
result: JSON.stringify({ found: true }),
|
|
117
|
+
},
|
|
118
|
+
{ type: "finish", reason: "stop", text: "" },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("maps error part to terminal error", async () => {
|
|
123
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([errorPart("boom")]), { sessionId: "sess-1" });
|
|
124
|
+
|
|
125
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
126
|
+
|
|
127
|
+
expect(parts).toHaveLength(1);
|
|
128
|
+
expect(parts[0]?.type).toBe("error");
|
|
129
|
+
if (parts[0]?.type === "error") {
|
|
130
|
+
expect(parts[0].cause.message).toBe("boom");
|
|
131
|
+
expect(parts[0].recoverable).toBe(false);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("yields terminal error when stream ends without done", async () => {
|
|
136
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([textDelta("partial")]), { sessionId: "sess-1" });
|
|
137
|
+
|
|
138
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
139
|
+
|
|
140
|
+
expect(parts).toHaveLength(2);
|
|
141
|
+
expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
|
|
142
|
+
expect(parts[1]?.type).toBe("error");
|
|
143
|
+
if (parts[1]?.type === "error") {
|
|
144
|
+
expect(parts[1].cause.message).toBe("Kuralle stream ended without a done part");
|
|
145
|
+
expect(parts[1].recoverable).toBe(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns without yielding when turn.signal is already aborted", async () => {
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
controller.abort();
|
|
152
|
+
const turn: ReasonerTurn = { ...baseTurn(), signal: controller.signal };
|
|
153
|
+
|
|
154
|
+
const reasoner = fromKuralleRuntime(
|
|
155
|
+
fakeRuntime([textDelta("should not appear"), done()]),
|
|
156
|
+
{ sessionId: "sess-1" },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const parts = await collectParts(reasoner, turn);
|
|
160
|
+
|
|
161
|
+
expect(parts).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("passes only userText to run, not turn.messages", async () => {
|
|
165
|
+
const spy: { runOpts?: { input?: string; sessionId?: string } } = {};
|
|
166
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([done()], spy), { sessionId: "sess-42" });
|
|
167
|
+
|
|
168
|
+
const turn: ReasonerTurn = {
|
|
169
|
+
userText: "What is my name?",
|
|
170
|
+
messages: [
|
|
171
|
+
{ role: "system", content: "ignored" },
|
|
172
|
+
{ role: "user", content: "also ignored" },
|
|
173
|
+
],
|
|
174
|
+
signal: new AbortController().signal,
|
|
175
|
+
};
|
|
176
|
+
await collectParts(reasoner, turn);
|
|
177
|
+
|
|
178
|
+
expect(spy.runOpts?.input).toBe("What is my name?");
|
|
179
|
+
expect(spy.runOpts?.sessionId).toBe("sess-42");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("flow resume pre-appends user message and omits input", async () => {
|
|
183
|
+
const sessionId = "sess-flow";
|
|
184
|
+
const store = createMockSessionStore(sessionId, [{ role: "assistant", content: "What is your name?" }]);
|
|
185
|
+
const session = (await store.get(sessionId))!;
|
|
186
|
+
session.durableRuns = {
|
|
187
|
+
[sessionId]: {
|
|
188
|
+
runState: {
|
|
189
|
+
activeFlow: "book-advisor-appointment",
|
|
190
|
+
messages: [{ role: "assistant", content: "What is your name?" }],
|
|
191
|
+
},
|
|
192
|
+
steps: [],
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
await store.save(session);
|
|
196
|
+
|
|
197
|
+
const runtime: KuralleRuntimeLike = {
|
|
198
|
+
run: () => ({ events: partsToEvents([done()]) }),
|
|
199
|
+
getSession: (id) => store.get(id),
|
|
200
|
+
getSessionStore: () => store,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const opts = await buildKuralleTurnRunOptions(runtime, {
|
|
204
|
+
sessionId,
|
|
205
|
+
userText: "Priya, CS masters, Friday",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(opts.input).toBeUndefined();
|
|
209
|
+
expect(opts.historyDelta).toBeUndefined();
|
|
210
|
+
const saved = await store.get(sessionId);
|
|
211
|
+
expect(saved?.messages.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
|
|
212
|
+
const runMessages = (
|
|
213
|
+
saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>
|
|
214
|
+
)[sessionId]?.runState.messages;
|
|
215
|
+
expect(runMessages?.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rewriteLastAssistant truncates only when spoken prefix is shorter", () => {
|
|
219
|
+
const messages: KuralleMessageLike[] = [
|
|
220
|
+
{ role: "user", content: "Hi" },
|
|
221
|
+
{ role: "assistant", content: "Hello there friend" },
|
|
222
|
+
];
|
|
223
|
+
expect(rewriteLastAssistant(messages, "Hello")).toEqual([
|
|
224
|
+
{ role: "user", content: "Hi" },
|
|
225
|
+
{ role: "assistant", content: "Hello" },
|
|
226
|
+
]);
|
|
227
|
+
expect(rewriteLastAssistant(messages, "Hello there friend")).toStrictEqual(messages);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("after barge-in abort, reconciles persisted assistant message to spoken prefix", async () => {
|
|
231
|
+
const fullText = "Hello there friend";
|
|
232
|
+
const spokenPrefix = "Hello";
|
|
233
|
+
const sessionId = "sess-barge";
|
|
234
|
+
const store = createMockSessionStore(sessionId, []);
|
|
235
|
+
|
|
236
|
+
await reconcileSpokenPrefix(
|
|
237
|
+
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
238
|
+
sessionId,
|
|
239
|
+
spokenPrefix,
|
|
240
|
+
);
|
|
241
|
+
let session = await store.get(sessionId);
|
|
242
|
+
expect(session?.messages).toEqual([]);
|
|
243
|
+
|
|
244
|
+
session = (await store.get(sessionId)) ?? { id: sessionId, messages: [] };
|
|
245
|
+
session.messages = [{ role: "assistant", content: fullText }];
|
|
246
|
+
await store.save(session);
|
|
247
|
+
|
|
248
|
+
await reconcileSpokenPrefix(
|
|
249
|
+
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
250
|
+
sessionId,
|
|
251
|
+
spokenPrefix,
|
|
252
|
+
);
|
|
253
|
+
session = await store.get(sessionId);
|
|
254
|
+
expect(session?.messages.at(-1)).toEqual({ role: "assistant", content: spokenPrefix });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("reconcileSpokenPrefix rewrites durable run messages", async () => {
|
|
258
|
+
const sessionId = "sess-durable";
|
|
259
|
+
const store = createMockSessionStore(sessionId, []);
|
|
260
|
+
const session = (await store.get(sessionId))!;
|
|
261
|
+
session.messages = [{ role: "assistant", content: "full reply text" }];
|
|
262
|
+
session.durableRuns = {
|
|
263
|
+
[sessionId]: {
|
|
264
|
+
runState: { messages: [{ role: "assistant", content: "full reply text" }] },
|
|
265
|
+
steps: [],
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
await store.save(session);
|
|
269
|
+
|
|
270
|
+
await reconcileSpokenPrefix(
|
|
271
|
+
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
272
|
+
sessionId,
|
|
273
|
+
"full",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const saved = await store.get(sessionId);
|
|
277
|
+
expect(saved?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
|
|
278
|
+
const runState = (saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>)[sessionId]
|
|
279
|
+
?.runState;
|
|
280
|
+
expect(runState?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
function createMockSessionStore(sessionId: string, messages: KuralleMessageLike[]): KuralleSessionStoreLike {
|
|
285
|
+
const sessions = new Map<string, KuralleStoredSession>([
|
|
286
|
+
[sessionId, { id: sessionId, messages: [...messages] }],
|
|
287
|
+
]);
|
|
288
|
+
return {
|
|
289
|
+
async get(id) {
|
|
290
|
+
const session = sessions.get(id);
|
|
291
|
+
return session ? structuredClone(session) : null;
|
|
292
|
+
},
|
|
293
|
+
async save(session) {
|
|
294
|
+
sessions.set(session.id, structuredClone(session));
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
|
|
4
|
+
import { categorizeLlmError, isRecoverable } from "@kuralle-syrinx/core";
|
|
5
|
+
|
|
6
|
+
export interface KuralleStreamPart {
|
|
7
|
+
readonly type: string;
|
|
8
|
+
readonly delta?: string;
|
|
9
|
+
readonly toolName?: string;
|
|
10
|
+
readonly args?: unknown;
|
|
11
|
+
readonly toolCallId?: string;
|
|
12
|
+
readonly result?: unknown;
|
|
13
|
+
readonly error?: string;
|
|
14
|
+
readonly waitingFor?: string;
|
|
15
|
+
readonly nodeId?: string;
|
|
16
|
+
readonly options?: unknown;
|
|
17
|
+
readonly prompt?: string;
|
|
18
|
+
readonly sessionId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KuralleMessageLike {
|
|
22
|
+
readonly role: string;
|
|
23
|
+
content: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface KuralleStoredSession {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
messages: KuralleMessageLike[];
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface KuralleSessionStoreLike {
|
|
33
|
+
get(id: string): Promise<KuralleStoredSession | null>;
|
|
34
|
+
save(session: KuralleStoredSession): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KuralleTurnHandle {
|
|
38
|
+
readonly events: AsyncIterable<KuralleStreamPart>;
|
|
39
|
+
then?: PromiseLike<unknown>["then"];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KuralleRunOptions {
|
|
43
|
+
readonly input?: string;
|
|
44
|
+
readonly sessionId?: string;
|
|
45
|
+
readonly userId?: string;
|
|
46
|
+
readonly agentId?: string;
|
|
47
|
+
readonly abortSignal?: AbortSignal;
|
|
48
|
+
readonly historyDelta?: ReadonlyArray<{ readonly role: string; readonly content: string }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface KuralleRuntimeLike {
|
|
52
|
+
run(opts: KuralleRunOptions): KuralleTurnHandle;
|
|
53
|
+
getSession?(sessionId: string): Promise<KuralleStoredSession | null>;
|
|
54
|
+
getSessionStore?(): KuralleSessionStoreLike;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FromKuralleRuntimeOptions {
|
|
58
|
+
readonly sessionId: string;
|
|
59
|
+
readonly userId?: string;
|
|
60
|
+
readonly agentId?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DURABLE_RUNS_KEY = "durableRuns";
|
|
64
|
+
|
|
65
|
+
export function fromKuralleRuntime(runtime: KuralleRuntimeLike, opts: FromKuralleRuntimeOptions): Reasoner {
|
|
66
|
+
return { stream: (turn) => streamFromKuralle(runtime, turn, opts) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function buildKuralleRunOptions(
|
|
70
|
+
runtime: KuralleRuntimeLike,
|
|
71
|
+
turn: ReasonerTurn,
|
|
72
|
+
opts: FromKuralleRuntimeOptions,
|
|
73
|
+
): Promise<KuralleRunOptions> {
|
|
74
|
+
const base: KuralleRunOptions = {
|
|
75
|
+
sessionId: opts.sessionId,
|
|
76
|
+
userId: opts.userId,
|
|
77
|
+
agentId: opts.agentId,
|
|
78
|
+
abortSignal: turn.signal,
|
|
79
|
+
};
|
|
80
|
+
if (!turn.userText) return base;
|
|
81
|
+
if (!runtime.getSession) {
|
|
82
|
+
return { ...base, input: turn.userText };
|
|
83
|
+
}
|
|
84
|
+
const session = await runtime.getSession(opts.sessionId);
|
|
85
|
+
const runState = readActiveRunState(session, opts.sessionId);
|
|
86
|
+
if (runState?.activeFlow) {
|
|
87
|
+
const appended = await appendFlowResumeUserMessage(runtime, opts.sessionId, turn.userText);
|
|
88
|
+
if (appended) return base;
|
|
89
|
+
}
|
|
90
|
+
return { ...base, input: turn.userText };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function buildKuralleTurnRunOptions(
|
|
94
|
+
runtime: KuralleRuntimeLike,
|
|
95
|
+
params: FromKuralleRuntimeOptions & { readonly userText: string; readonly signal?: AbortSignal },
|
|
96
|
+
): Promise<KuralleRunOptions> {
|
|
97
|
+
const turn: ReasonerTurn = {
|
|
98
|
+
userText: params.userText,
|
|
99
|
+
messages: [],
|
|
100
|
+
signal: params.signal ?? new AbortController().signal,
|
|
101
|
+
};
|
|
102
|
+
return buildKuralleRunOptions(runtime, turn, params);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function runKuralleTurn(
|
|
106
|
+
runtime: KuralleRuntimeLike,
|
|
107
|
+
runOpts: KuralleRunOptions,
|
|
108
|
+
): KuralleTurnHandle {
|
|
109
|
+
return runtime.run(runOpts);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function awaitKuralleTurn(handle: KuralleTurnHandle): Promise<void> {
|
|
113
|
+
await new Promise<void>((resolve, reject) => {
|
|
114
|
+
if (typeof handle.then === "function") {
|
|
115
|
+
handle.then(() => resolve(), reject);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
resolve();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readActiveRunState(
|
|
123
|
+
session: KuralleStoredSession | null,
|
|
124
|
+
sessionId: string,
|
|
125
|
+
): { activeFlow?: string } | undefined {
|
|
126
|
+
if (!session) return undefined;
|
|
127
|
+
const runs = session[DURABLE_RUNS_KEY];
|
|
128
|
+
if (!runs || typeof runs !== "object" || Array.isArray(runs)) return undefined;
|
|
129
|
+
const persisted = (runs as Record<string, unknown>)[sessionId];
|
|
130
|
+
if (!persisted || typeof persisted !== "object") return undefined;
|
|
131
|
+
const runState = (persisted as { runState?: { activeFlow?: string } }).runState;
|
|
132
|
+
return runState;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function appendFlowResumeUserMessage(
|
|
136
|
+
runtime: KuralleRuntimeLike,
|
|
137
|
+
sessionId: string,
|
|
138
|
+
userText: string,
|
|
139
|
+
): Promise<boolean> {
|
|
140
|
+
const store = runtime.getSessionStore?.();
|
|
141
|
+
if (!store) return false;
|
|
142
|
+
|
|
143
|
+
const session = await store.get(sessionId);
|
|
144
|
+
if (!session) return false;
|
|
145
|
+
|
|
146
|
+
const userMessage: KuralleMessageLike = { role: "user", content: userText };
|
|
147
|
+
session.messages = [...session.messages, userMessage];
|
|
148
|
+
|
|
149
|
+
const wm = session.workingMemory;
|
|
150
|
+
if (wm && typeof wm === "object" && !Array.isArray(wm)) {
|
|
151
|
+
delete (wm as Record<string, unknown>)["__v2_pendingUserInput"];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const runs = session[DURABLE_RUNS_KEY];
|
|
155
|
+
if (runs && typeof runs === "object" && !Array.isArray(runs)) {
|
|
156
|
+
const persisted = (runs as Record<string, unknown>)[sessionId];
|
|
157
|
+
if (persisted && typeof persisted === "object") {
|
|
158
|
+
const runState = (persisted as { runState?: { messages?: KuralleMessageLike[] } }).runState;
|
|
159
|
+
if (runState) {
|
|
160
|
+
runState.messages = [...(runState.messages ?? []), userMessage];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await store.save(session);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function* streamFromKuralle(
|
|
170
|
+
runtime: KuralleRuntimeLike,
|
|
171
|
+
turn: ReasonerTurn,
|
|
172
|
+
opts: FromKuralleRuntimeOptions,
|
|
173
|
+
): AsyncGenerator<ReasoningPart> {
|
|
174
|
+
const runOpts = await buildKuralleRunOptions(runtime, turn, opts);
|
|
175
|
+
const handle = runtime.run(runOpts);
|
|
176
|
+
|
|
177
|
+
let acc = "";
|
|
178
|
+
let aborted = false;
|
|
179
|
+
try {
|
|
180
|
+
for await (const part of handle.events) {
|
|
181
|
+
if (turn.signal.aborted) {
|
|
182
|
+
aborted = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
switch (part.type) {
|
|
186
|
+
case "text-delta": {
|
|
187
|
+
const t = String(part.delta ?? "");
|
|
188
|
+
acc += t;
|
|
189
|
+
yield { type: "text-delta", text: t };
|
|
190
|
+
if (turn.signal.aborted) {
|
|
191
|
+
aborted = true;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "tool-call":
|
|
196
|
+
yield {
|
|
197
|
+
type: "tool-call",
|
|
198
|
+
toolId: String(part.toolCallId ?? ""),
|
|
199
|
+
toolName: String(part.toolName ?? ""),
|
|
200
|
+
args: toRecord(part.args),
|
|
201
|
+
};
|
|
202
|
+
break;
|
|
203
|
+
case "tool-result":
|
|
204
|
+
yield {
|
|
205
|
+
type: "tool-result",
|
|
206
|
+
toolId: String(part.toolCallId ?? ""),
|
|
207
|
+
toolName: String(part.toolName ?? ""),
|
|
208
|
+
result: stringifyResult(part.result),
|
|
209
|
+
};
|
|
210
|
+
break;
|
|
211
|
+
case "error":
|
|
212
|
+
yield toErrorPart(new Error(String(part.error ?? "Kuralle error")));
|
|
213
|
+
return;
|
|
214
|
+
case "paused":
|
|
215
|
+
yield {
|
|
216
|
+
type: "suspended",
|
|
217
|
+
runId: opts.sessionId,
|
|
218
|
+
prompt: typeof part.waitingFor === "string" ? part.waitingFor : undefined,
|
|
219
|
+
payload: part,
|
|
220
|
+
};
|
|
221
|
+
return;
|
|
222
|
+
case "interactive":
|
|
223
|
+
yield {
|
|
224
|
+
type: "suspended",
|
|
225
|
+
runId: opts.sessionId,
|
|
226
|
+
prompt: typeof part.prompt === "string" ? part.prompt : undefined,
|
|
227
|
+
payload: part,
|
|
228
|
+
};
|
|
229
|
+
return;
|
|
230
|
+
case "done":
|
|
231
|
+
yield { type: "finish", reason: "stop", text: acc };
|
|
232
|
+
return;
|
|
233
|
+
default:
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (aborted) break;
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
await awaitKuralleTurn(handle).catch(() => undefined);
|
|
240
|
+
if (aborted && acc.length > 0) {
|
|
241
|
+
await reconcileSpokenPrefix(runtime, opts.sessionId, acc);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (aborted) return;
|
|
246
|
+
|
|
247
|
+
yield toErrorPart(new Error("Kuralle stream ended without a done part"));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function reconcileSpokenPrefix(
|
|
251
|
+
runtime: KuralleRuntimeLike,
|
|
252
|
+
sessionId: string,
|
|
253
|
+
spokenPrefix: string,
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
const store = runtime.getSessionStore?.();
|
|
256
|
+
if (!store) return;
|
|
257
|
+
|
|
258
|
+
const session = await store.get(sessionId);
|
|
259
|
+
if (!session) return;
|
|
260
|
+
|
|
261
|
+
let changed = false;
|
|
262
|
+
const topLevel = rewriteLastAssistant(session.messages, spokenPrefix);
|
|
263
|
+
if (topLevel !== session.messages) {
|
|
264
|
+
session.messages = topLevel;
|
|
265
|
+
changed = true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const runs = session[DURABLE_RUNS_KEY];
|
|
269
|
+
if (runs && typeof runs === "object" && !Array.isArray(runs)) {
|
|
270
|
+
for (const persisted of Object.values(runs as Record<string, unknown>)) {
|
|
271
|
+
if (!persisted || typeof persisted !== "object") continue;
|
|
272
|
+
const runState = (persisted as { runState?: { messages?: KuralleMessageLike[] } }).runState;
|
|
273
|
+
if (!runState?.messages) continue;
|
|
274
|
+
const rewritten = rewriteLastAssistant(runState.messages, spokenPrefix);
|
|
275
|
+
if (rewritten !== runState.messages) {
|
|
276
|
+
runState.messages = rewritten;
|
|
277
|
+
changed = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (changed) await store.save(session);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function rewriteLastAssistant(
|
|
286
|
+
messages: ReadonlyArray<KuralleMessageLike>,
|
|
287
|
+
spokenPrefix: string,
|
|
288
|
+
): KuralleMessageLike[] {
|
|
289
|
+
if (messages.length === 0) return [...messages];
|
|
290
|
+
const last = messages[messages.length - 1];
|
|
291
|
+
if (!last || last.role !== "assistant") return [...messages];
|
|
292
|
+
const full = assistantText(last);
|
|
293
|
+
if (spokenPrefix.length >= full.length) return [...messages];
|
|
294
|
+
return [...messages.slice(0, -1), { role: "assistant", content: spokenPrefix }];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function assistantText(message: KuralleMessageLike): string {
|
|
298
|
+
if (typeof message.content === "string") return message.content;
|
|
299
|
+
if (Array.isArray(message.content)) {
|
|
300
|
+
return message.content
|
|
301
|
+
.map((part) => {
|
|
302
|
+
if (typeof part === "string") return part;
|
|
303
|
+
if (part && typeof part === "object" && "text" in part) return String(part.text);
|
|
304
|
+
return "";
|
|
305
|
+
})
|
|
306
|
+
.join("");
|
|
307
|
+
}
|
|
308
|
+
return String(message.content ?? "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function toErrorPart(error: unknown): ReasoningPart {
|
|
312
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
313
|
+
return { type: "error", cause, recoverable: isRecoverable(categorizeLlmError(cause)) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function stringifyResult(r: unknown): string {
|
|
317
|
+
return typeof r === "string" ? r : JSON.stringify(r);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
321
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
322
|
+
return value as Record<string, unknown>;
|
|
323
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
export {
|
|
3
|
+
fromKuralleRuntime,
|
|
4
|
+
streamFromKuralle,
|
|
5
|
+
runKuralleTurn,
|
|
6
|
+
awaitKuralleTurn,
|
|
7
|
+
buildKuralleTurnRunOptions,
|
|
8
|
+
reconcileSpokenPrefix,
|
|
9
|
+
rewriteLastAssistant,
|
|
10
|
+
type KuralleRuntimeLike,
|
|
11
|
+
type KuralleRunOptions,
|
|
12
|
+
type KuralleStreamPart,
|
|
13
|
+
type KuralleSessionStoreLike,
|
|
14
|
+
type KuralleStoredSession,
|
|
15
|
+
type KuralleMessageLike,
|
|
16
|
+
type FromKuralleRuntimeOptions,
|
|
17
|
+
} from "./from-kuralle.js";
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
}
|