@kuralle-syrinx/kuralle 4.4.0 → 4.5.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/dist/from-kuralle.d.ts +100 -0
- package/dist/from-kuralle.js +252 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/real-runtime.compile-check.d.ts +1 -0
- package/dist/real-runtime.compile-check.js +20 -0
- package/package.json +12 -5
- package/src/from-kuralle.test.ts +421 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
|
|
2
|
+
export interface KuralleStreamPart {
|
|
3
|
+
readonly [key: string]: unknown;
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly delta?: string;
|
|
6
|
+
readonly toolName?: string;
|
|
7
|
+
readonly args?: unknown;
|
|
8
|
+
readonly toolCallId?: string;
|
|
9
|
+
readonly result?: unknown;
|
|
10
|
+
readonly error?: string;
|
|
11
|
+
readonly waitingFor?: string;
|
|
12
|
+
readonly nodeId?: string;
|
|
13
|
+
readonly options?: unknown;
|
|
14
|
+
readonly prompt?: string;
|
|
15
|
+
readonly sessionId?: string;
|
|
16
|
+
readonly userFacingMessage?: string;
|
|
17
|
+
readonly payload?: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface KuralleMessageLike {
|
|
20
|
+
readonly role: string;
|
|
21
|
+
content: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface KuralleStoredSession {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
messages: KuralleMessageLike[];
|
|
26
|
+
/**
|
|
27
|
+
* Durable-run bookkeeping the bridge reads for flow-resume. Optional and unknown-typed
|
|
28
|
+
* because kuralle owns its shape.
|
|
29
|
+
*
|
|
30
|
+
* This was a `[key: string]: unknown` catch-all, which made the interface unsatisfiable
|
|
31
|
+
* by any concrete type: a real `Session` has no index signature, so it could never be
|
|
32
|
+
* assigned here. Nothing caught that, because nothing type-checked the bridge against
|
|
33
|
+
* the real Runtime — see `real-runtime.compile-check.ts`. Only this one key is read.
|
|
34
|
+
*/
|
|
35
|
+
durableRuns?: unknown;
|
|
36
|
+
/** Working-memory blob the bridge inspects on resume. Kuralle owns its shape. */
|
|
37
|
+
workingMemory?: unknown;
|
|
38
|
+
}
|
|
39
|
+
export interface KuralleSessionStoreLike {
|
|
40
|
+
get(id: string): Promise<KuralleStoredSession | null>;
|
|
41
|
+
save(session: KuralleStoredSession): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export interface KuralleTurnHandle {
|
|
44
|
+
readonly events: AsyncIterable<KuralleStreamPart>;
|
|
45
|
+
then?: PromiseLike<unknown>["then"];
|
|
46
|
+
}
|
|
47
|
+
export interface KuralleRunOptions {
|
|
48
|
+
readonly input?: string;
|
|
49
|
+
readonly sessionId?: string;
|
|
50
|
+
readonly userId?: string;
|
|
51
|
+
readonly agentId?: string;
|
|
52
|
+
readonly abortSignal?: AbortSignal;
|
|
53
|
+
/**
|
|
54
|
+
* Prior turns seeded into an empty kuralle session (G4 resume-by-seed).
|
|
55
|
+
*
|
|
56
|
+
* Deliberately narrower than it looks: the real `Runtime.run` takes `ModelMessage[]`,
|
|
57
|
+
* whose `role` is a union, so a `string` role here would be too wide to assign — and a
|
|
58
|
+
* mutable array, so `ReadonlyArray` would not assign either. The producer
|
|
59
|
+
* (`buildHistoryDeltaSeed`) already filters to user/assistant, so this only makes the
|
|
60
|
+
* declaration honest about what the code was doing.
|
|
61
|
+
*
|
|
62
|
+
* `real-runtime.compile-check.ts` pins this against the actual Runtime type.
|
|
63
|
+
*/
|
|
64
|
+
historyDelta?: Array<{
|
|
65
|
+
role: "user" | "assistant";
|
|
66
|
+
content: string;
|
|
67
|
+
}>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The slice of kuralle's `Runtime` this bridge calls, as a loose structural shape.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately hand-written rather than `Pick<Runtime, ...>`. Deriving was tried and is
|
|
73
|
+
* strictly worse here: it drags in `TurnHandle`, `HarnessStreamPart`, `Session` and
|
|
74
|
+
* `RunOptions` wholesale, so every test fake and adapter would have to construct
|
|
75
|
+
* full-fidelity kuralle objects to call a bridge whose whole point is loose coupling.
|
|
76
|
+
*
|
|
77
|
+
* The drift that freedom costs is contained by `real-runtime.compile-check.ts`, which
|
|
78
|
+
* asserts the REAL `Runtime` still satisfies this shape. That check is what was missing —
|
|
79
|
+
* not stricter types. It is the same pattern `cf-agents` uses for the agents SDK.
|
|
80
|
+
*/
|
|
81
|
+
export interface KuralleRuntimeLike {
|
|
82
|
+
run(opts: KuralleRunOptions): KuralleTurnHandle;
|
|
83
|
+
getSession?(sessionId: string): Promise<KuralleStoredSession | null>;
|
|
84
|
+
getSessionStore?(): KuralleSessionStoreLike;
|
|
85
|
+
}
|
|
86
|
+
export interface FromKuralleRuntimeOptions {
|
|
87
|
+
readonly sessionId: string;
|
|
88
|
+
readonly userId?: string;
|
|
89
|
+
readonly agentId?: string;
|
|
90
|
+
}
|
|
91
|
+
export declare function fromKuralleRuntime(runtime: KuralleRuntimeLike, opts: FromKuralleRuntimeOptions): Reasoner;
|
|
92
|
+
export declare function buildKuralleTurnRunOptions(runtime: KuralleRuntimeLike, params: FromKuralleRuntimeOptions & {
|
|
93
|
+
readonly userText: string;
|
|
94
|
+
readonly signal?: AbortSignal;
|
|
95
|
+
}): Promise<KuralleRunOptions>;
|
|
96
|
+
export declare function runKuralleTurn(runtime: KuralleRuntimeLike, runOpts: KuralleRunOptions): KuralleTurnHandle;
|
|
97
|
+
export declare function awaitKuralleTurn(handle: KuralleTurnHandle): Promise<void>;
|
|
98
|
+
export declare function streamFromKuralle(runtime: KuralleRuntimeLike, turn: ReasonerTurn, opts: FromKuralleRuntimeOptions): AsyncGenerator<ReasoningPart>;
|
|
99
|
+
export declare function reconcileSpokenPrefix(runtime: KuralleRuntimeLike, sessionId: string, spokenPrefix: string): Promise<void>;
|
|
100
|
+
export declare function rewriteLastAssistant(messages: ReadonlyArray<KuralleMessageLike>, spokenPrefix: string): KuralleMessageLike[];
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import { categorizeLlmError, isRecoverable } from "@kuralle-syrinx/core";
|
|
3
|
+
const DURABLE_RUNS_KEY = "durableRuns";
|
|
4
|
+
export function fromKuralleRuntime(runtime, opts) {
|
|
5
|
+
return { stream: (turn) => streamFromKuralle(runtime, turn, opts) };
|
|
6
|
+
}
|
|
7
|
+
async function buildKuralleRunOptions(runtime, turn, opts) {
|
|
8
|
+
const base = {
|
|
9
|
+
sessionId: opts.sessionId,
|
|
10
|
+
userId: opts.userId,
|
|
11
|
+
agentId: opts.agentId,
|
|
12
|
+
abortSignal: turn.signal,
|
|
13
|
+
};
|
|
14
|
+
if (!turn.userText)
|
|
15
|
+
return base;
|
|
16
|
+
if (!runtime.getSession) {
|
|
17
|
+
return { ...base, input: turn.userText };
|
|
18
|
+
}
|
|
19
|
+
const session = await runtime.getSession(opts.sessionId);
|
|
20
|
+
const runState = readActiveRunState(session, opts.sessionId);
|
|
21
|
+
if (runState?.activeFlow) {
|
|
22
|
+
const appended = await appendFlowResumeUserMessage(runtime, opts.sessionId, turn.userText);
|
|
23
|
+
if (appended)
|
|
24
|
+
return base;
|
|
25
|
+
}
|
|
26
|
+
// G4 resume-by-seed: the BRIDGE owns history (reasoner seam §4.5). When the kuralle
|
|
27
|
+
// session is empty (fresh isolate / post-eviction) and the turn carries prior context,
|
|
28
|
+
// seed it once via historyDelta — kuralle appends it to the (empty) session, so the
|
|
29
|
+
// runtime resumes with the same context instead of restarting amnesiac. Never seeded
|
|
30
|
+
// into a non-empty session (that would double-apply history).
|
|
31
|
+
return { ...base, input: turn.userText, ...seedHistoryDelta(session, turn.messages) };
|
|
32
|
+
}
|
|
33
|
+
function seedHistoryDelta(session, messages) {
|
|
34
|
+
if (session && session.messages.length > 0)
|
|
35
|
+
return {};
|
|
36
|
+
const delta = messages
|
|
37
|
+
.filter((message) => message.role === "user" || message.role === "assistant")
|
|
38
|
+
.map((message) => ({ role: message.role, content: message.content }));
|
|
39
|
+
return delta.length > 0 ? { historyDelta: delta } : {};
|
|
40
|
+
}
|
|
41
|
+
export async function buildKuralleTurnRunOptions(runtime, params) {
|
|
42
|
+
const turn = {
|
|
43
|
+
userText: params.userText,
|
|
44
|
+
messages: [],
|
|
45
|
+
signal: params.signal ?? new AbortController().signal,
|
|
46
|
+
};
|
|
47
|
+
return buildKuralleRunOptions(runtime, turn, params);
|
|
48
|
+
}
|
|
49
|
+
export function runKuralleTurn(runtime, runOpts) {
|
|
50
|
+
return runtime.run(runOpts);
|
|
51
|
+
}
|
|
52
|
+
export async function awaitKuralleTurn(handle) {
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
if (typeof handle.then === "function") {
|
|
55
|
+
handle.then(() => resolve(), reject);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function readActiveRunState(session, sessionId) {
|
|
62
|
+
if (!session)
|
|
63
|
+
return undefined;
|
|
64
|
+
const runs = session[DURABLE_RUNS_KEY];
|
|
65
|
+
if (!runs || typeof runs !== "object" || Array.isArray(runs))
|
|
66
|
+
return undefined;
|
|
67
|
+
const persisted = runs[sessionId];
|
|
68
|
+
if (!persisted || typeof persisted !== "object")
|
|
69
|
+
return undefined;
|
|
70
|
+
const runState = persisted.runState;
|
|
71
|
+
return runState;
|
|
72
|
+
}
|
|
73
|
+
async function appendFlowResumeUserMessage(runtime, sessionId, userText) {
|
|
74
|
+
const store = runtime.getSessionStore?.();
|
|
75
|
+
if (!store)
|
|
76
|
+
return false;
|
|
77
|
+
const session = await store.get(sessionId);
|
|
78
|
+
if (!session)
|
|
79
|
+
return false;
|
|
80
|
+
const userMessage = { role: "user", content: userText };
|
|
81
|
+
session.messages = [...session.messages, userMessage];
|
|
82
|
+
const wm = session.workingMemory;
|
|
83
|
+
if (wm && typeof wm === "object" && !Array.isArray(wm)) {
|
|
84
|
+
delete wm["__v2_pendingUserInput"];
|
|
85
|
+
}
|
|
86
|
+
const runs = session[DURABLE_RUNS_KEY];
|
|
87
|
+
if (runs && typeof runs === "object" && !Array.isArray(runs)) {
|
|
88
|
+
const persisted = runs[sessionId];
|
|
89
|
+
if (persisted && typeof persisted === "object") {
|
|
90
|
+
const runState = persisted.runState;
|
|
91
|
+
if (runState) {
|
|
92
|
+
runState.messages = [...(runState.messages ?? []), userMessage];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await store.save(session);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
export async function* streamFromKuralle(runtime, turn, opts) {
|
|
100
|
+
const runOpts = await buildKuralleRunOptions(runtime, turn, opts);
|
|
101
|
+
const handle = runtime.run(runOpts);
|
|
102
|
+
let acc = "";
|
|
103
|
+
let aborted = false;
|
|
104
|
+
try {
|
|
105
|
+
for await (const part of handle.events) {
|
|
106
|
+
if (turn.signal.aborted) {
|
|
107
|
+
aborted = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
switch (part.type) {
|
|
111
|
+
case "text-delta": {
|
|
112
|
+
const t = String(part.delta ?? "");
|
|
113
|
+
acc += t;
|
|
114
|
+
yield { type: "text-delta", text: t };
|
|
115
|
+
if (turn.signal.aborted) {
|
|
116
|
+
aborted = true;
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "tool-call":
|
|
121
|
+
yield {
|
|
122
|
+
type: "tool-call",
|
|
123
|
+
toolId: String(part.toolCallId ?? ""),
|
|
124
|
+
toolName: String(part.toolName ?? ""),
|
|
125
|
+
args: toRecord(part.args),
|
|
126
|
+
};
|
|
127
|
+
break;
|
|
128
|
+
case "tool-result":
|
|
129
|
+
yield {
|
|
130
|
+
type: "tool-result",
|
|
131
|
+
toolId: String(part.toolCallId ?? ""),
|
|
132
|
+
toolName: String(part.toolName ?? ""),
|
|
133
|
+
result: stringifyResult(part.result),
|
|
134
|
+
};
|
|
135
|
+
break;
|
|
136
|
+
case "error":
|
|
137
|
+
yield toErrorPart(new Error(String(part.error ?? "Kuralle error")));
|
|
138
|
+
return;
|
|
139
|
+
case "paused":
|
|
140
|
+
yield {
|
|
141
|
+
type: "suspended",
|
|
142
|
+
runId: opts.sessionId,
|
|
143
|
+
prompt: typeof part.waitingFor === "string" ? part.waitingFor : undefined,
|
|
144
|
+
payload: part,
|
|
145
|
+
};
|
|
146
|
+
return;
|
|
147
|
+
case "interactive":
|
|
148
|
+
yield {
|
|
149
|
+
type: "suspended",
|
|
150
|
+
runId: opts.sessionId,
|
|
151
|
+
prompt: typeof part.prompt === "string" ? part.prompt : undefined,
|
|
152
|
+
payload: part,
|
|
153
|
+
};
|
|
154
|
+
return;
|
|
155
|
+
case "safety-blocked":
|
|
156
|
+
yield {
|
|
157
|
+
type: "blocked",
|
|
158
|
+
userFacingMessage: String(part.userFacingMessage ?? "This request cannot be completed."),
|
|
159
|
+
payload: part,
|
|
160
|
+
};
|
|
161
|
+
return;
|
|
162
|
+
case "done":
|
|
163
|
+
yield { type: "finish", reason: "stop", text: acc };
|
|
164
|
+
return;
|
|
165
|
+
default:
|
|
166
|
+
yield { type: "control", name: part.type, payload: part };
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (aborted)
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await awaitKuralleTurn(handle).catch(() => undefined);
|
|
175
|
+
if (aborted && acc.length > 0) {
|
|
176
|
+
await reconcileSpokenPrefix(runtime, opts.sessionId, acc);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (aborted)
|
|
180
|
+
return;
|
|
181
|
+
yield toErrorPart(new Error("Kuralle stream ended without a done part"));
|
|
182
|
+
}
|
|
183
|
+
export async function reconcileSpokenPrefix(runtime, sessionId, spokenPrefix) {
|
|
184
|
+
const store = runtime.getSessionStore?.();
|
|
185
|
+
if (!store)
|
|
186
|
+
return;
|
|
187
|
+
const session = await store.get(sessionId);
|
|
188
|
+
if (!session)
|
|
189
|
+
return;
|
|
190
|
+
let changed = false;
|
|
191
|
+
const topLevel = rewriteLastAssistant(session.messages, spokenPrefix);
|
|
192
|
+
if (topLevel !== session.messages) {
|
|
193
|
+
session.messages = topLevel;
|
|
194
|
+
changed = true;
|
|
195
|
+
}
|
|
196
|
+
const runs = session[DURABLE_RUNS_KEY];
|
|
197
|
+
if (runs && typeof runs === "object" && !Array.isArray(runs)) {
|
|
198
|
+
for (const persisted of Object.values(runs)) {
|
|
199
|
+
if (!persisted || typeof persisted !== "object")
|
|
200
|
+
continue;
|
|
201
|
+
const runState = persisted.runState;
|
|
202
|
+
if (!runState?.messages)
|
|
203
|
+
continue;
|
|
204
|
+
const rewritten = rewriteLastAssistant(runState.messages, spokenPrefix);
|
|
205
|
+
if (rewritten !== runState.messages) {
|
|
206
|
+
runState.messages = rewritten;
|
|
207
|
+
changed = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (changed)
|
|
212
|
+
await store.save(session);
|
|
213
|
+
}
|
|
214
|
+
export function rewriteLastAssistant(messages, spokenPrefix) {
|
|
215
|
+
if (messages.length === 0)
|
|
216
|
+
return [...messages];
|
|
217
|
+
const last = messages[messages.length - 1];
|
|
218
|
+
if (!last || last.role !== "assistant")
|
|
219
|
+
return [...messages];
|
|
220
|
+
const full = assistantText(last);
|
|
221
|
+
if (spokenPrefix.length >= full.length)
|
|
222
|
+
return [...messages];
|
|
223
|
+
return [...messages.slice(0, -1), { role: "assistant", content: spokenPrefix }];
|
|
224
|
+
}
|
|
225
|
+
function assistantText(message) {
|
|
226
|
+
if (typeof message.content === "string")
|
|
227
|
+
return message.content;
|
|
228
|
+
if (Array.isArray(message.content)) {
|
|
229
|
+
return message.content
|
|
230
|
+
.map((part) => {
|
|
231
|
+
if (typeof part === "string")
|
|
232
|
+
return part;
|
|
233
|
+
if (part && typeof part === "object" && "text" in part)
|
|
234
|
+
return String(part.text);
|
|
235
|
+
return "";
|
|
236
|
+
})
|
|
237
|
+
.join("");
|
|
238
|
+
}
|
|
239
|
+
return String(message.content ?? "");
|
|
240
|
+
}
|
|
241
|
+
function toErrorPart(error) {
|
|
242
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
243
|
+
return { type: "error", cause, recoverable: isRecoverable(categorizeLlmError(cause)) };
|
|
244
|
+
}
|
|
245
|
+
function stringifyResult(r) {
|
|
246
|
+
return typeof r === "string" ? r : JSON.stringify(r);
|
|
247
|
+
}
|
|
248
|
+
function toRecord(value) {
|
|
249
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
250
|
+
return {};
|
|
251
|
+
return value;
|
|
252
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { fromKuralleRuntime, runKuralleTurn, awaitKuralleTurn, buildKuralleTurnRunOptions, reconcileSpokenPrefix, rewriteLastAssistant, type KuralleRuntimeLike, type KuralleRunOptions, type KuralleStreamPart, type KuralleSessionStoreLike, type KuralleStoredSession, type KuralleMessageLike, type FromKuralleRuntimeOptions, } from "./from-kuralle.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const reasonerFromRealRuntime: import("@kuralle-syrinx/core").Reasoner;
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
import { fromKuralleRuntime } from "./from-kuralle.js";
|
|
16
|
+
const asBridgeRuntime = realRuntime;
|
|
17
|
+
// And the real thing composes into the seam the voice pipeline consumes.
|
|
18
|
+
export const reasonerFromRealRuntime = fromKuralleRuntime(asBridgeRuntime, {
|
|
19
|
+
sessionId: "compile-check-session",
|
|
20
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/kuralle",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "kuralle-agents bridge for Syrinx — run a kuralle runtime as the voice pipeline's reasoner",
|
|
6
6
|
"keywords": [
|
|
@@ -21,21 +21,28 @@
|
|
|
21
21
|
"url": "https://github.com/kuralle/syrinx/issues"
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
|
-
"main": "./
|
|
25
|
-
"types": "./
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@kuralle-syrinx/core": "4.
|
|
27
|
+
"@kuralle-syrinx/core": "4.5.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@kuralle-agents/core": ">=0.13.0 <1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@kuralle-agents/core": "^0.13.0",
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
34
35
|
"typescript": "^5.7.0",
|
|
35
36
|
"vitest": "^3.2.6"
|
|
36
37
|
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
37
43
|
"scripts": {
|
|
38
44
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "vitest run"
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"build": "tsc -p tsconfig.build.json"
|
|
40
47
|
}
|
|
41
48
|
}
|
|
@@ -0,0 +1,421 @@
|
|
|
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
|
+
function safetyBlocked(userFacingMessage: string): KuralleStreamPart {
|
|
47
|
+
return { type: "safety-blocked", userFacingMessage };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function* partsToEvents(parts: KuralleStreamPart[]): AsyncIterable<KuralleStreamPart> {
|
|
51
|
+
for (const p of parts) yield p;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fakeRuntime(
|
|
55
|
+
parts: KuralleStreamPart[],
|
|
56
|
+
spy?: {
|
|
57
|
+
runOpts?: {
|
|
58
|
+
input?: string;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
userId?: string;
|
|
61
|
+
agentId?: string;
|
|
62
|
+
abortSignal?: AbortSignal;
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
): KuralleRuntimeLike {
|
|
66
|
+
return {
|
|
67
|
+
run(opts) {
|
|
68
|
+
if (spy) spy.runOpts = opts;
|
|
69
|
+
return { events: partsToEvents(parts) };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
|
|
75
|
+
const collected: ReasoningPart[] = [];
|
|
76
|
+
for await (const part of reasoner.stream(turn)) {
|
|
77
|
+
collected.push(part);
|
|
78
|
+
}
|
|
79
|
+
return collected;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("fromKuralleRuntime G4 resume-by-seed (historyDelta)", () => {
|
|
83
|
+
const turnWithContext = (): ReasonerTurn => ({
|
|
84
|
+
userText: "Second question",
|
|
85
|
+
messages: [
|
|
86
|
+
{ role: "system", content: "sys" },
|
|
87
|
+
{ role: "user", content: "First question" },
|
|
88
|
+
{ role: "assistant", content: "First answer" },
|
|
89
|
+
],
|
|
90
|
+
signal: new AbortController().signal,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("seeds prior turn.messages into an EMPTY kuralle session (fresh isolate resume)", async () => {
|
|
94
|
+
const spy: { runOpts?: KuralleRunOptions } = {};
|
|
95
|
+
const runtime: KuralleRuntimeLike = {
|
|
96
|
+
run(opts) {
|
|
97
|
+
spy.runOpts = opts;
|
|
98
|
+
return { events: partsToEvents([textDelta("ok"), done()]) };
|
|
99
|
+
},
|
|
100
|
+
getSession: async () => null,
|
|
101
|
+
};
|
|
102
|
+
const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
|
|
103
|
+
|
|
104
|
+
await collectParts(reasoner, turnWithContext());
|
|
105
|
+
|
|
106
|
+
expect(spy.runOpts?.input).toBe("Second question");
|
|
107
|
+
expect(spy.runOpts?.historyDelta).toEqual([
|
|
108
|
+
{ role: "user", content: "First question" },
|
|
109
|
+
{ role: "assistant", content: "First answer" },
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does NOT seed into a non-empty session (no double-applied history, R6)", async () => {
|
|
114
|
+
const spy: { runOpts?: KuralleRunOptions } = {};
|
|
115
|
+
const runtime: KuralleRuntimeLike = {
|
|
116
|
+
run(opts) {
|
|
117
|
+
spy.runOpts = opts;
|
|
118
|
+
return { events: partsToEvents([textDelta("ok"), done()]) };
|
|
119
|
+
},
|
|
120
|
+
getSession: async () => ({
|
|
121
|
+
id: "sess-1",
|
|
122
|
+
messages: [{ role: "user", content: "already there" }],
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
|
|
126
|
+
|
|
127
|
+
await collectParts(reasoner, turnWithContext());
|
|
128
|
+
|
|
129
|
+
expect(spy.runOpts?.input).toBe("Second question");
|
|
130
|
+
expect(spy.runOpts?.historyDelta).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("fromKuralleRuntime", () => {
|
|
135
|
+
it("maps happy path: text deltas and done to finish:stop", async () => {
|
|
136
|
+
const reasoner = fromKuralleRuntime(
|
|
137
|
+
fakeRuntime([textDelta("Hi"), textDelta(" there"), done("sess-1")]),
|
|
138
|
+
{ sessionId: "sess-1" },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
142
|
+
|
|
143
|
+
expect(parts).toEqual([
|
|
144
|
+
{ type: "text-delta", text: "Hi" },
|
|
145
|
+
{ type: "text-delta", text: " there" },
|
|
146
|
+
{ type: "finish", reason: "stop", text: "Hi there" },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("speaks a kuralle per-tool interim: it arrives as text-delta before the tool result", async () => {
|
|
151
|
+
// Kuralle's `Tool.interim` / `interimAfterMs` is the tool-level equivalent of a spoken
|
|
152
|
+
// preamble — "Let me pull up your record" while a slow tool runs. It is NOT a distinct
|
|
153
|
+
// stream part: `ToolExecutor` fires an `onInterim` callback, and kuralle's `Runtime`
|
|
154
|
+
// converts it to `text-start` / `text-delta` / `text-end` on the run stream
|
|
155
|
+
// (Runtime.js — `onInterim: (message) => { emit({type:'text-delta', delta: message}) }`).
|
|
156
|
+
//
|
|
157
|
+
// So it reaches Syrinx as an ordinary text-delta and flows llm.delta -> bufferTtsText ->
|
|
158
|
+
// tts.text -> spoken audio, with no bridge change needed. This test pins that path so a
|
|
159
|
+
// future refactor of the text-delta case cannot silently mute every tool interim.
|
|
160
|
+
const reasoner = fromKuralleRuntime(
|
|
161
|
+
fakeRuntime([
|
|
162
|
+
toolCall("call-1", "studentRelationsLookup", { requestType: "late_add" }),
|
|
163
|
+
// The interim, as the runtime actually emits it — bare text-delta, mid-tool.
|
|
164
|
+
textDelta("Let me pull up your registration record."),
|
|
165
|
+
toolResult("call-1", "studentRelationsLookup", { addDeadline: "2027-02-05" }),
|
|
166
|
+
textDelta("Your late add deadline was February 5th."),
|
|
167
|
+
done("sess-interim"),
|
|
168
|
+
]),
|
|
169
|
+
{ sessionId: "sess-interim" },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
173
|
+
|
|
174
|
+
const firstSpoken = parts.find((p) => p.type === "text-delta");
|
|
175
|
+
expect(firstSpoken).toEqual({
|
|
176
|
+
type: "text-delta",
|
|
177
|
+
text: "Let me pull up your registration record.",
|
|
178
|
+
});
|
|
179
|
+
// And it precedes the tool result, so the user hears it while the tool is still running.
|
|
180
|
+
const interimIdx = parts.findIndex((p) => p.type === "text-delta");
|
|
181
|
+
const resultIdx = parts.findIndex((p) => p.type === "tool-result");
|
|
182
|
+
expect(interimIdx).toBeLessThan(resultIdx);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("maps tool-call and tool-result with toolId from toolCallId", async () => {
|
|
186
|
+
const reasoner = fromKuralleRuntime(
|
|
187
|
+
fakeRuntime([
|
|
188
|
+
toolCall("tc-1", "lookup", { id: "123" }),
|
|
189
|
+
toolResult("tc-1", "lookup", { found: true }),
|
|
190
|
+
done(),
|
|
191
|
+
]),
|
|
192
|
+
{ sessionId: "sess-1" },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
196
|
+
|
|
197
|
+
expect(parts).toEqual([
|
|
198
|
+
{
|
|
199
|
+
type: "tool-call",
|
|
200
|
+
toolId: "tc-1",
|
|
201
|
+
toolName: "lookup",
|
|
202
|
+
args: { id: "123" },
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
type: "tool-result",
|
|
206
|
+
toolId: "tc-1",
|
|
207
|
+
toolName: "lookup",
|
|
208
|
+
result: JSON.stringify({ found: true }),
|
|
209
|
+
},
|
|
210
|
+
{ type: "finish", reason: "stop", text: "" },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("maps error part to terminal error", async () => {
|
|
215
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([errorPart("boom")]), { sessionId: "sess-1" });
|
|
216
|
+
|
|
217
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
218
|
+
|
|
219
|
+
expect(parts).toHaveLength(1);
|
|
220
|
+
expect(parts[0]?.type).toBe("error");
|
|
221
|
+
if (parts[0]?.type === "error") {
|
|
222
|
+
expect(parts[0].cause.message).toBe("boom");
|
|
223
|
+
expect(parts[0].recoverable).toBe(false);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("maps safety-blocked to a terminal blocked part without the missing-done error", async () => {
|
|
228
|
+
const reasoner = fromKuralleRuntime(
|
|
229
|
+
fakeRuntime([safetyBlocked("I cannot help with that request.")]),
|
|
230
|
+
{ sessionId: "sess-safety" },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
234
|
+
|
|
235
|
+
expect(parts).toEqual([
|
|
236
|
+
{
|
|
237
|
+
type: "blocked",
|
|
238
|
+
userFacingMessage: "I cannot help with that request.",
|
|
239
|
+
payload: { type: "safety-blocked", userFacingMessage: "I cannot help with that request." },
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
expect(parts.some((part) => part.type === "error")).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("preserves orchestration parts as opaque control parts", async () => {
|
|
246
|
+
const handoff = { type: "handoff", targetAgent: "billing", reason: "account question" } satisfies KuralleStreamPart;
|
|
247
|
+
const outcome = { type: "conversation-outcome", outcome: { taskSuccess: true, satisfaction: false } } satisfies KuralleStreamPart;
|
|
248
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([handoff, outcome, done()]), { sessionId: "sess-control" });
|
|
249
|
+
|
|
250
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
251
|
+
|
|
252
|
+
expect(parts).toEqual([
|
|
253
|
+
{ type: "control", name: "handoff", payload: handoff },
|
|
254
|
+
{ type: "control", name: "conversation-outcome", payload: outcome },
|
|
255
|
+
{ type: "finish", reason: "stop", text: "" },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("yields terminal error when stream ends without done", async () => {
|
|
260
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([textDelta("partial")]), { sessionId: "sess-1" });
|
|
261
|
+
|
|
262
|
+
const parts = await collectParts(reasoner, baseTurn());
|
|
263
|
+
|
|
264
|
+
expect(parts).toHaveLength(2);
|
|
265
|
+
expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
|
|
266
|
+
expect(parts[1]?.type).toBe("error");
|
|
267
|
+
if (parts[1]?.type === "error") {
|
|
268
|
+
expect(parts[1].cause.message).toBe("Kuralle stream ended without a done part");
|
|
269
|
+
expect(parts[1].recoverable).toBe(false);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("returns without yielding when turn.signal is already aborted", async () => {
|
|
274
|
+
const controller = new AbortController();
|
|
275
|
+
controller.abort();
|
|
276
|
+
const turn: ReasonerTurn = { ...baseTurn(), signal: controller.signal };
|
|
277
|
+
|
|
278
|
+
const reasoner = fromKuralleRuntime(
|
|
279
|
+
fakeRuntime([textDelta("should not appear"), done()]),
|
|
280
|
+
{ sessionId: "sess-1" },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const parts = await collectParts(reasoner, turn);
|
|
284
|
+
|
|
285
|
+
expect(parts).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("passes only userText to run, not turn.messages", async () => {
|
|
289
|
+
const spy: { runOpts?: { input?: string; sessionId?: string } } = {};
|
|
290
|
+
const reasoner = fromKuralleRuntime(fakeRuntime([done()], spy), { sessionId: "sess-42" });
|
|
291
|
+
|
|
292
|
+
const turn: ReasonerTurn = {
|
|
293
|
+
userText: "What is my name?",
|
|
294
|
+
messages: [
|
|
295
|
+
{ role: "system", content: "ignored" },
|
|
296
|
+
{ role: "user", content: "also ignored" },
|
|
297
|
+
],
|
|
298
|
+
signal: new AbortController().signal,
|
|
299
|
+
};
|
|
300
|
+
await collectParts(reasoner, turn);
|
|
301
|
+
|
|
302
|
+
expect(spy.runOpts?.input).toBe("What is my name?");
|
|
303
|
+
expect(spy.runOpts?.sessionId).toBe("sess-42");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("flow resume pre-appends user message and omits input", async () => {
|
|
307
|
+
const sessionId = "sess-flow";
|
|
308
|
+
const store = createMockSessionStore(sessionId, [{ role: "assistant", content: "What is your name?" }]);
|
|
309
|
+
const session = (await store.get(sessionId))!;
|
|
310
|
+
session.durableRuns = {
|
|
311
|
+
[sessionId]: {
|
|
312
|
+
runState: {
|
|
313
|
+
activeFlow: "book-advisor-appointment",
|
|
314
|
+
messages: [{ role: "assistant", content: "What is your name?" }],
|
|
315
|
+
},
|
|
316
|
+
steps: [],
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
await store.save(session);
|
|
320
|
+
|
|
321
|
+
const runtime: KuralleRuntimeLike = {
|
|
322
|
+
run: () => ({ events: partsToEvents([done()]) }),
|
|
323
|
+
getSession: (id) => store.get(id),
|
|
324
|
+
getSessionStore: () => store,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const opts = await buildKuralleTurnRunOptions(runtime, {
|
|
328
|
+
sessionId,
|
|
329
|
+
userText: "Priya, CS masters, Friday",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(opts.input).toBeUndefined();
|
|
333
|
+
expect(opts.historyDelta).toBeUndefined();
|
|
334
|
+
const saved = await store.get(sessionId);
|
|
335
|
+
expect(saved?.messages.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
|
|
336
|
+
const runMessages = (
|
|
337
|
+
saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>
|
|
338
|
+
)[sessionId]?.runState.messages;
|
|
339
|
+
expect(runMessages?.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("rewriteLastAssistant truncates only when spoken prefix is shorter", () => {
|
|
343
|
+
const messages: KuralleMessageLike[] = [
|
|
344
|
+
{ role: "user", content: "Hi" },
|
|
345
|
+
{ role: "assistant", content: "Hello there friend" },
|
|
346
|
+
];
|
|
347
|
+
expect(rewriteLastAssistant(messages, "Hello")).toEqual([
|
|
348
|
+
{ role: "user", content: "Hi" },
|
|
349
|
+
{ role: "assistant", content: "Hello" },
|
|
350
|
+
]);
|
|
351
|
+
expect(rewriteLastAssistant(messages, "Hello there friend")).toStrictEqual(messages);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("after barge-in abort, reconciles persisted assistant message to spoken prefix", async () => {
|
|
355
|
+
const fullText = "Hello there friend";
|
|
356
|
+
const spokenPrefix = "Hello";
|
|
357
|
+
const sessionId = "sess-barge";
|
|
358
|
+
const store = createMockSessionStore(sessionId, []);
|
|
359
|
+
|
|
360
|
+
await reconcileSpokenPrefix(
|
|
361
|
+
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
362
|
+
sessionId,
|
|
363
|
+
spokenPrefix,
|
|
364
|
+
);
|
|
365
|
+
let session = await store.get(sessionId);
|
|
366
|
+
expect(session?.messages).toEqual([]);
|
|
367
|
+
|
|
368
|
+
session = (await store.get(sessionId)) ?? { id: sessionId, messages: [] };
|
|
369
|
+
session.messages = [{ role: "assistant", content: fullText }];
|
|
370
|
+
await store.save(session);
|
|
371
|
+
|
|
372
|
+
await reconcileSpokenPrefix(
|
|
373
|
+
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
374
|
+
sessionId,
|
|
375
|
+
spokenPrefix,
|
|
376
|
+
);
|
|
377
|
+
session = await store.get(sessionId);
|
|
378
|
+
expect(session?.messages.at(-1)).toEqual({ role: "assistant", content: spokenPrefix });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("reconcileSpokenPrefix rewrites durable run messages", async () => {
|
|
382
|
+
const sessionId = "sess-durable";
|
|
383
|
+
const store = createMockSessionStore(sessionId, []);
|
|
384
|
+
const session = (await store.get(sessionId))!;
|
|
385
|
+
session.messages = [{ role: "assistant", content: "full reply text" }];
|
|
386
|
+
session.durableRuns = {
|
|
387
|
+
[sessionId]: {
|
|
388
|
+
runState: { messages: [{ role: "assistant", content: "full reply text" }] },
|
|
389
|
+
steps: [],
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
await store.save(session);
|
|
393
|
+
|
|
394
|
+
await reconcileSpokenPrefix(
|
|
395
|
+
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
396
|
+
sessionId,
|
|
397
|
+
"full",
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const saved = await store.get(sessionId);
|
|
401
|
+
expect(saved?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
|
|
402
|
+
const runState = (saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>)[sessionId]
|
|
403
|
+
?.runState;
|
|
404
|
+
expect(runState?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
function createMockSessionStore(sessionId: string, messages: KuralleMessageLike[]): KuralleSessionStoreLike {
|
|
409
|
+
const sessions = new Map<string, KuralleStoredSession>([
|
|
410
|
+
[sessionId, { id: sessionId, messages: [...messages] }],
|
|
411
|
+
]);
|
|
412
|
+
return {
|
|
413
|
+
async get(id) {
|
|
414
|
+
const session = sessions.get(id);
|
|
415
|
+
return session ? structuredClone(session) : null;
|
|
416
|
+
},
|
|
417
|
+
async save(session) {
|
|
418
|
+
sessions.set(session.id, structuredClone(session));
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|