@jskit-ai/assistant-runtime 0.1.1

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.
Files changed (30) hide show
  1. package/package.descriptor.mjs +136 -0
  2. package/package.json +21 -0
  3. package/src/client/components/AssistantSettingsClientElement.vue +204 -0
  4. package/src/client/components/AssistantSurfaceClientElement.vue +19 -0
  5. package/src/client/composables/useAssistantRuntime.js +759 -0
  6. package/src/client/index.js +4 -0
  7. package/src/client/providers/AssistantClientProvider.js +16 -0
  8. package/src/server/AssistantProvider.js +152 -0
  9. package/src/server/actionIds.js +9 -0
  10. package/src/server/actions.js +151 -0
  11. package/src/server/inputValidators.js +41 -0
  12. package/src/server/registerRoutes.js +450 -0
  13. package/src/server/repositories/assistantConfigRepository.js +148 -0
  14. package/src/server/repositories/conversationsRepository.js +263 -0
  15. package/src/server/repositories/messagesRepository.js +166 -0
  16. package/src/server/services/assistantConfigService.js +132 -0
  17. package/src/server/services/chatService.js +1048 -0
  18. package/src/server/services/transcriptService.js +331 -0
  19. package/src/server/support/assistantServerConfig.js +106 -0
  20. package/src/server/support/createSurfaceAwareToolCatalog.js +64 -0
  21. package/src/shared/assistantRuntimeConfig.js +7 -0
  22. package/src/shared/assistantSurfaces.js +97 -0
  23. package/src/shared/index.js +7 -0
  24. package/templates/migrations/assistant_config_initial.cjs +27 -0
  25. package/templates/migrations/assistant_transcripts_initial.cjs +58 -0
  26. package/test/assistantServerConfig.test.js +72 -0
  27. package/test/assistantSurfaces.test.js +50 -0
  28. package/test/createSurfaceAwareToolCatalog.test.js +77 -0
  29. package/test/lazyAppConfig.test.js +248 -0
  30. package/test/packageDescriptor.test.js +34 -0
@@ -0,0 +1,1048 @@
1
+ import { AppError, parsePositiveInteger } from "@jskit-ai/kernel/server/runtime";
2
+ import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { resolveWorkspace } from "@jskit-ai/users-core/server/support/resolveWorkspace";
4
+ import { resolveWorkspaceSlug } from "@jskit-ai/assistant-core/server";
5
+ import {
6
+ ASSISTANT_STREAM_EVENT_TYPES
7
+ } from "@jskit-ai/assistant-core/shared";
8
+ import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
9
+
10
+ const MAX_HISTORY_MESSAGES = 20;
11
+ const MAX_INPUT_CHARS = 8000;
12
+ const MAX_TOOL_ROUNDS = 4;
13
+
14
+ function normalizeConversationId(value) {
15
+ const parsed = parsePositiveInteger(value);
16
+ return parsed > 0 ? parsed : null;
17
+ }
18
+
19
+ function normalizeHistory(history = []) {
20
+ const source = Array.isArray(history) ? history : [];
21
+ return source
22
+ .slice(0, MAX_HISTORY_MESSAGES)
23
+ .map((entry) => {
24
+ const item = normalizeObject(entry);
25
+ const role = normalizeText(item.role).toLowerCase();
26
+ if (role !== "user" && role !== "assistant") {
27
+ return null;
28
+ }
29
+
30
+ const content = normalizeText(item.content).slice(0, MAX_INPUT_CHARS);
31
+ if (!content) {
32
+ return null;
33
+ }
34
+
35
+ return {
36
+ role,
37
+ content
38
+ };
39
+ })
40
+ .filter(Boolean);
41
+ }
42
+
43
+ function normalizeStreamInput(payload = {}) {
44
+ const source = normalizeObject(payload);
45
+ const messageId = normalizeText(source.messageId);
46
+ const input = normalizeText(source.input).slice(0, MAX_INPUT_CHARS);
47
+ if (!messageId) {
48
+ throw new AppError(400, "Validation failed.", {
49
+ details: {
50
+ fieldErrors: {
51
+ messageId: "messageId is required."
52
+ }
53
+ }
54
+ });
55
+ }
56
+ if (!input) {
57
+ throw new AppError(400, "Validation failed.", {
58
+ details: {
59
+ fieldErrors: {
60
+ input: "input is required."
61
+ }
62
+ }
63
+ });
64
+ }
65
+
66
+ return {
67
+ messageId,
68
+ conversationId: normalizeConversationId(source.conversationId),
69
+ input,
70
+ history: normalizeHistory(source.history)
71
+ };
72
+ }
73
+
74
+ function hasStreamWriter(streamWriter) {
75
+ return Boolean(
76
+ streamWriter &&
77
+ typeof streamWriter.sendMeta === "function" &&
78
+ typeof streamWriter.sendAssistantDelta === "function" &&
79
+ typeof streamWriter.sendAssistantMessage === "function" &&
80
+ typeof streamWriter.sendToolCall === "function" &&
81
+ typeof streamWriter.sendToolResult === "function" &&
82
+ typeof streamWriter.sendError === "function" &&
83
+ typeof streamWriter.sendDone === "function"
84
+ );
85
+ }
86
+
87
+ function isAbortError(error) {
88
+ if (!error) {
89
+ return false;
90
+ }
91
+
92
+ return String(error.name || "").trim() === "AbortError";
93
+ }
94
+
95
+ function extractTextDelta(deltaContent) {
96
+ if (typeof deltaContent === "string") {
97
+ return deltaContent;
98
+ }
99
+
100
+ if (!Array.isArray(deltaContent)) {
101
+ return "";
102
+ }
103
+
104
+ return deltaContent
105
+ .map((entry) => {
106
+ if (typeof entry === "string") {
107
+ return entry;
108
+ }
109
+ if (!entry || typeof entry !== "object") {
110
+ return "";
111
+ }
112
+ return String(entry.text || "");
113
+ })
114
+ .join("");
115
+ }
116
+
117
+ function toCompactJson(value, fallback = "{}") {
118
+ try {
119
+ if (!value || typeof value !== "object") {
120
+ return fallback;
121
+ }
122
+ return JSON.stringify(value);
123
+ } catch {
124
+ return fallback;
125
+ }
126
+ }
127
+
128
+ function buildToolContractLine(toolDescriptor = {}) {
129
+ const name = normalizeText(toolDescriptor.name);
130
+ if (!name) {
131
+ return "";
132
+ }
133
+
134
+ const inputSchema = toolDescriptor.parameters;
135
+ const outputSchema = toolDescriptor.outputSchema;
136
+ return `${name}: input=${toCompactJson(inputSchema)} output=${toCompactJson(outputSchema, "null")}`;
137
+ }
138
+
139
+ function buildSystemPrompt({ targetSurfaceId = "", toolDescriptors = [], workspaceSlug = "", customSystemPrompt = "" } = {}) {
140
+ const toolSummary = toolDescriptors.length > 0
141
+ ? `Available tools: ${toolDescriptors.map((entry) => entry.name).join(", ")}.`
142
+ : "No tools are currently available for this user/session.";
143
+ const toolContracts = toolDescriptors.length > 0
144
+ ? `Tool contracts: ${toolDescriptors.map((entry) => buildToolContractLine(entry)).filter(Boolean).join(" | ")}.`
145
+ : "Tool contracts: none.";
146
+ const normalizedWorkspaceSlug = normalizeText(workspaceSlug).toLowerCase();
147
+ const workspaceLine = normalizedWorkspaceSlug
148
+ ? `Current workspace slug: ${normalizedWorkspaceSlug}.`
149
+ : "No workspace context is active for this assistant.";
150
+ const normalizedCustomSystemPrompt = String(customSystemPrompt || "").trim();
151
+
152
+ const promptSegments = [
153
+ `You are the assistant for the ${normalizeText(targetSurfaceId) || "assistant"} surface.`,
154
+ "Use tools when they are necessary and only when available.",
155
+ "Do not mention tools that are not available.",
156
+ "When answering schema questions, rely only on tool contracts and tool results.",
157
+ workspaceLine,
158
+ toolSummary,
159
+ toolContracts
160
+ ];
161
+ if (normalizedCustomSystemPrompt) {
162
+ promptSegments.push(`Additional instructions for this surface: ${normalizedCustomSystemPrompt}`);
163
+ }
164
+
165
+ return promptSegments.join(" ");
166
+ }
167
+
168
+ function buildRecoveryPrompt({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
169
+ const normalizedReason = normalizeText(reason).toLowerCase();
170
+ const failureSummary = (Array.isArray(toolFailures) ? toolFailures : [])
171
+ .slice(0, 3)
172
+ .map((entry) => {
173
+ const toolName = normalizeText(entry?.name) || "unknown_tool";
174
+ const errorCode = normalizeText(entry?.error?.code) || "tool_failed";
175
+ return `${toolName}:${errorCode}`;
176
+ })
177
+ .filter(Boolean)
178
+ .join(", ");
179
+ const successSummary = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
180
+ .slice(0, 3)
181
+ .map((entry) => normalizeText(entry?.name))
182
+ .filter(Boolean)
183
+ .join(", ");
184
+
185
+ const failureSuffix = failureSummary ? ` Recent tool failures: ${failureSummary}.` : "";
186
+ const successSuffix = successSummary ? ` Successful tools: ${successSummary}.` : "";
187
+ if (normalizedReason === "tool_failure") {
188
+ return `One or more tool calls may fail. Continue with available successful results. Do not output function-call markup. Do not mention failed operations unless explicitly asked.${failureSuffix}${successSuffix}`;
189
+ }
190
+
191
+ return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only.${failureSuffix}${successSuffix}`;
192
+ }
193
+
194
+ function buildRecoveryFallbackAnswer({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
195
+ const normalizedReason = normalizeText(reason).toLowerCase();
196
+ if (normalizedReason === "tool_failure") {
197
+ return buildToolOutcomeFallbackAnswer({
198
+ toolFailures,
199
+ toolSuccesses
200
+ });
201
+ }
202
+
203
+ return "I reached the tool-call limit for this request. Please narrow the request and I will continue.";
204
+ }
205
+
206
+ function toSafeToolResultText(value) {
207
+ if (value == null) {
208
+ return "null";
209
+ }
210
+ if (typeof value === "string") {
211
+ const normalized = normalizeText(value);
212
+ return normalized || "\"\"";
213
+ }
214
+ try {
215
+ return JSON.stringify(value, null, 2);
216
+ } catch {
217
+ return "\"<unserializable>\"";
218
+ }
219
+ }
220
+
221
+ function buildToolOutcomeFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
222
+ const successNames = [...new Set(
223
+ (Array.isArray(toolSuccesses) ? toolSuccesses : [])
224
+ .map((entry) => normalizeText(entry?.name))
225
+ .filter(Boolean)
226
+ )];
227
+ const hasFailures = Array.isArray(toolFailures) && toolFailures.length > 0;
228
+
229
+ if (successNames.length > 0) {
230
+ const summaryLines = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
231
+ .filter((entry) => normalizeText(entry?.name))
232
+ .map((entry) => {
233
+ const name = normalizeText(entry.name);
234
+ const payload = toSafeToolResultText(entry.result);
235
+ return `- ${name}:\n\`\`\`json\n${payload}\n\`\`\``;
236
+ });
237
+
238
+ return [
239
+ "I used the available successful results:",
240
+ ...summaryLines
241
+ ].join("\n");
242
+ }
243
+
244
+ if (hasFailures) {
245
+ return "I could not gather additional information from successful operations.";
246
+ }
247
+
248
+ return "I could not gather additional information from the available operations.";
249
+ }
250
+
251
+ function sanitizeAssistantMessageText(value) {
252
+ let source = String(value || "");
253
+ if (!source) {
254
+ return "";
255
+ }
256
+
257
+ const blockPatterns = [
258
+ /<[^>\n]*function_calls[^>\n]*>[\s\S]*?<\/[^>\n]*function_calls>/gi,
259
+ /<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?<\/[^>\n]*tool_calls?[^>\n]*>/gi,
260
+ /<[^>\n]*invoke\b[^>\n]*>[\s\S]*?<\/[^>\n]*invoke>/gi
261
+ ];
262
+ for (const pattern of blockPatterns) {
263
+ source = source.replace(pattern, " ");
264
+ }
265
+
266
+ const inlineTagPatterns = [
267
+ /<[^>\n]*invoke\b[^>]*\/>/gi,
268
+ /<\/?[^>\n]*invoke[^>\n]*>/gi,
269
+ /<\/?[^>\n]*function_calls[^>\n]*>/gi,
270
+ /<\/?[^>\n]*tool_calls?[^>\n]*>/gi,
271
+ /<\/?[^>\n]*DSML[^>\n]*>/gi
272
+ ];
273
+ for (const pattern of inlineTagPatterns) {
274
+ source = source.replace(pattern, " ");
275
+ }
276
+
277
+ return source
278
+ .split(/\r?\n/)
279
+ .map((line) => line.trim())
280
+ .filter(Boolean)
281
+ .join("\n");
282
+ }
283
+
284
+ function buildAssistantToolCallMessage({ assistantText = "", toolCalls = [] } = {}) {
285
+ return {
286
+ role: "assistant",
287
+ content: assistantText || "",
288
+ tool_calls: toolCalls.map((toolCall) => ({
289
+ id: toolCall.id,
290
+ type: "function",
291
+ function: {
292
+ name: toolCall.name,
293
+ arguments: toolCall.arguments
294
+ }
295
+ }))
296
+ };
297
+ }
298
+
299
+ function parseDsmlToolCallsFromText(value = "") {
300
+ const source = String(value || "");
301
+ if (!source) {
302
+ return [];
303
+ }
304
+
305
+ const functionCallsMatch = source.match(
306
+ /<[^>\n]*function_calls[^>\n]*>([\s\S]*?)<\/[^>\n]*function_calls>/i
307
+ );
308
+ if (!functionCallsMatch) {
309
+ return [];
310
+ }
311
+
312
+ const blockText = String(functionCallsMatch[1] || "");
313
+ if (!blockText) {
314
+ return [];
315
+ }
316
+
317
+ const calls = [];
318
+ const invokePattern = /<[^>\n]*invoke\b([^>]*)>([\s\S]*?)<\/[^>\n]*invoke>/gi;
319
+ let match = invokePattern.exec(blockText);
320
+ while (match) {
321
+ const attributes = String(match[1] || "");
322
+ const body = normalizeText(String(match[2] || ""));
323
+ const quotedNameMatch =
324
+ attributes.match(/\bname\s*=\s*"([^"]+)"/i) || attributes.match(/\bname\s*=\s*'([^']+)'/i);
325
+ const bareNameMatch = attributes.match(/\bname\s*=\s*([^\s"'/>]+)/i);
326
+ const name = normalizeText(quotedNameMatch?.[1] || bareNameMatch?.[1]);
327
+ if (name) {
328
+ calls.push({
329
+ id: `dsml_tool_call_${calls.length + 1}`,
330
+ name,
331
+ arguments: body && /^[\[{]/.test(body) ? body : "{}"
332
+ });
333
+ }
334
+
335
+ match = invokePattern.exec(blockText);
336
+ }
337
+
338
+ return calls;
339
+ }
340
+
341
+ function createDsmlDeltaSanitizer() {
342
+ let inTag = false;
343
+ let tagBuffer = "";
344
+ let suppressedDepth = 0;
345
+
346
+ function resolveTagType(rawTag = "") {
347
+ const normalizedTag = String(rawTag || "").toLowerCase();
348
+ if (normalizedTag.includes("function_calls")) {
349
+ return "function_calls";
350
+ }
351
+ if (normalizedTag.includes("tool_calls")) {
352
+ return "tool_calls";
353
+ }
354
+ if (normalizedTag.includes("invoke")) {
355
+ return "invoke";
356
+ }
357
+ return "";
358
+ }
359
+
360
+ function processTag(rawTag = "") {
361
+ const source = String(rawTag || "");
362
+ const inner = source.slice(1, -1).trim();
363
+ const isClosing = inner.startsWith("/");
364
+ const isSelfClosing = inner.endsWith("/");
365
+ const tagType = resolveTagType(inner);
366
+
367
+ if (suppressedDepth > 0) {
368
+ if (tagType && isClosing) {
369
+ suppressedDepth = Math.max(0, suppressedDepth - 1);
370
+ } else if (tagType && !isClosing && !isSelfClosing) {
371
+ suppressedDepth += 1;
372
+ }
373
+ return "";
374
+ }
375
+
376
+ if (!tagType) {
377
+ return source;
378
+ }
379
+
380
+ if (!isClosing && !isSelfClosing) {
381
+ suppressedDepth = 1;
382
+ }
383
+ return "";
384
+ }
385
+
386
+ function process(delta = "") {
387
+ const source = String(delta || "");
388
+ if (!source) {
389
+ return "";
390
+ }
391
+
392
+ let output = "";
393
+ for (const char of source) {
394
+ if (inTag) {
395
+ tagBuffer += char;
396
+ if (char === ">") {
397
+ inTag = false;
398
+ output += processTag(tagBuffer);
399
+ tagBuffer = "";
400
+ }
401
+ continue;
402
+ }
403
+
404
+ if (char === "<") {
405
+ inTag = true;
406
+ tagBuffer = "<";
407
+ continue;
408
+ }
409
+
410
+ if (suppressedDepth < 1) {
411
+ output += char;
412
+ }
413
+ }
414
+
415
+ return output;
416
+ }
417
+
418
+ function flush() {
419
+ return "";
420
+ }
421
+
422
+ return Object.freeze({
423
+ process,
424
+ flush
425
+ });
426
+ }
427
+
428
+ async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true, deltaSanitizer = null } = {}) {
429
+ let assistantText = "";
430
+ let streamedAssistantText = "";
431
+ const toolCallsByIndex = new Map();
432
+
433
+ for await (const chunk of stream) {
434
+ const choice = chunk?.choices?.[0] || {};
435
+ const delta = choice?.delta || {};
436
+
437
+ const textDelta = extractTextDelta(delta.content);
438
+ if (textDelta) {
439
+ assistantText += textDelta;
440
+ if (emitDeltas) {
441
+ const safeDelta =
442
+ deltaSanitizer && typeof deltaSanitizer.process === "function"
443
+ ? String(deltaSanitizer.process(textDelta) || "")
444
+ : textDelta;
445
+ if (safeDelta) {
446
+ streamedAssistantText += safeDelta;
447
+ streamWriter.sendAssistantDelta({
448
+ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA,
449
+ delta: safeDelta
450
+ });
451
+ }
452
+ }
453
+ }
454
+
455
+ const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
456
+ for (const partialToolCall of toolCalls) {
457
+ const index = Number(partialToolCall?.index || 0);
458
+ const existing =
459
+ toolCallsByIndex.get(index) ||
460
+ {
461
+ id: normalizeText(partialToolCall?.id) || `tool_call_${index + 1}`,
462
+ name: "",
463
+ arguments: ""
464
+ };
465
+
466
+ if (partialToolCall?.id) {
467
+ existing.id = normalizeText(partialToolCall.id) || existing.id;
468
+ }
469
+ if (partialToolCall?.function?.name) {
470
+ existing.name += String(partialToolCall.function.name || "");
471
+ }
472
+ if (partialToolCall?.function?.arguments) {
473
+ existing.arguments += String(partialToolCall.function.arguments || "");
474
+ }
475
+
476
+ toolCallsByIndex.set(index, existing);
477
+ }
478
+ }
479
+
480
+ let toolCalls = [...toolCallsByIndex.values()].map((toolCall, index) => ({
481
+ id: normalizeText(toolCall.id) || `tool_call_${index + 1}`,
482
+ name: normalizeText(toolCall.name),
483
+ arguments: String(toolCall.arguments || "")
484
+ }));
485
+
486
+ if (toolCalls.length < 1) {
487
+ const parsedDsmlCalls = parseDsmlToolCallsFromText(assistantText);
488
+ if (parsedDsmlCalls.length > 0) {
489
+ toolCalls = parsedDsmlCalls;
490
+ assistantText = normalizeText(sanitizeAssistantMessageText(assistantText));
491
+ }
492
+ }
493
+
494
+ if (emitDeltas && deltaSanitizer && typeof deltaSanitizer.flush === "function") {
495
+ const trailing = String(deltaSanitizer.flush() || "");
496
+ if (trailing) {
497
+ streamedAssistantText += trailing;
498
+ streamWriter.sendAssistantDelta({
499
+ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA,
500
+ delta: trailing
501
+ });
502
+ }
503
+ }
504
+
505
+ return {
506
+ assistantText,
507
+ streamedAssistantText,
508
+ toolCalls
509
+ };
510
+ }
511
+
512
+ function mergeAssistantMessageText(streamedText = "", completionText = "") {
513
+ const streamed = normalizeText(sanitizeAssistantMessageText(streamedText));
514
+ const completion = normalizeText(sanitizeAssistantMessageText(completionText));
515
+
516
+ if (!streamed) {
517
+ return completion;
518
+ }
519
+ if (!completion) {
520
+ return streamed;
521
+ }
522
+ if (streamed === completion) {
523
+ return streamed;
524
+ }
525
+ if (completion.startsWith(streamed) || completion.includes(streamed)) {
526
+ return completion;
527
+ }
528
+ if (streamed.startsWith(completion) || streamed.includes(completion)) {
529
+ return streamed;
530
+ }
531
+
532
+ return `${streamed}\n${completion}`;
533
+ }
534
+
535
+ function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
536
+ const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
537
+ if (assistantSurface) {
538
+ return assistantSurface;
539
+ }
540
+
541
+ throw new AppError(404, "Assistant not found.");
542
+ }
543
+
544
+ function buildAssistantActionContext(context = {}, assistantSurface = {}) {
545
+ return {
546
+ ...normalizeObject(context),
547
+ surface: assistantSurface.targetSurfaceId
548
+ };
549
+ }
550
+
551
+ function createChatService({
552
+ aiClientFactory,
553
+ transcriptService,
554
+ serviceToolCatalog,
555
+ assistantConfigService,
556
+ appConfig = {},
557
+ resolveAppConfig = null
558
+ } = {}) {
559
+ if (!aiClientFactory || typeof aiClientFactory.resolveClient !== "function" || !transcriptService || !serviceToolCatalog || !assistantConfigService) {
560
+ throw new Error(
561
+ "createChatService requires aiClientFactory.resolveClient(), transcriptService, serviceToolCatalog, and assistantConfigService."
562
+ );
563
+ }
564
+
565
+ const resolveCurrentAppConfig =
566
+ typeof resolveAppConfig === "function" ? resolveAppConfig : () => appConfig;
567
+
568
+ async function streamChat(payload = {}, options = {}) {
569
+ const assistantSurface = requireAssistantSurface(resolveCurrentAppConfig(), payload?.targetSurfaceId);
570
+ const aiClient = aiClientFactory.resolveClient(assistantSurface.targetSurfaceId);
571
+ if (!aiClient.enabled) {
572
+ throw new AppError(503, "Assistant provider is not configured.");
573
+ }
574
+
575
+ const context = normalizeObject(options.context);
576
+ const assistantContext = buildAssistantActionContext(context, assistantSurface);
577
+ const workspace = assistantSurface.runtimeSurfaceRequiresWorkspace
578
+ ? resolveWorkspace(assistantContext, payload)
579
+ : null;
580
+ const source = normalizeStreamInput(payload);
581
+ const streamWriter = options.streamWriter;
582
+ if (!hasStreamWriter(streamWriter)) {
583
+ throw new Error("assistant.chat.stream requires streamWriter methods.");
584
+ }
585
+
586
+ const actor = context.actor;
587
+
588
+ const conversationResult = await transcriptService.createConversationForTurn(
589
+ assistantSurface,
590
+ workspace,
591
+ actor,
592
+ {
593
+ conversationId: source.conversationId,
594
+ provider: aiClient.provider,
595
+ model: aiClient.defaultModel,
596
+ surfaceId: assistantSurface.targetSurfaceId,
597
+ messageId: source.messageId
598
+ },
599
+ {
600
+ context: assistantContext
601
+ }
602
+ );
603
+
604
+ const conversation = conversationResult.conversation;
605
+ const conversationId = conversation?.id;
606
+ if (!conversationId) {
607
+ throw new AppError(500, "Assistant failed to create conversation.");
608
+ }
609
+
610
+ await transcriptService.appendMessage(
611
+ assistantSurface,
612
+ conversationId,
613
+ {
614
+ role: "user",
615
+ kind: "chat",
616
+ clientMessageSid: source.messageId,
617
+ contentText: source.input,
618
+ metadata: {
619
+ surfaceId: assistantSurface.targetSurfaceId
620
+ }
621
+ },
622
+ {
623
+ context: assistantContext,
624
+ workspace
625
+ }
626
+ );
627
+
628
+ const toolSet = serviceToolCatalog.resolveToolSet(assistantContext);
629
+ const customSystemPrompt = await assistantConfigService.resolveSystemPrompt(
630
+ assistantSurface,
631
+ workspace,
632
+ {
633
+ surface: assistantSurface.targetSurfaceId
634
+ },
635
+ {
636
+ context: assistantContext,
637
+ input: payload
638
+ }
639
+ );
640
+ const systemPrompt = buildSystemPrompt({
641
+ targetSurfaceId: assistantSurface.targetSurfaceId,
642
+ toolDescriptors: toolSet.tools,
643
+ workspaceSlug: resolveWorkspaceSlug(assistantContext, payload),
644
+ customSystemPrompt
645
+ });
646
+
647
+ const messages = [
648
+ {
649
+ role: "system",
650
+ content: systemPrompt
651
+ },
652
+ ...source.history,
653
+ {
654
+ role: "user",
655
+ content: source.input
656
+ }
657
+ ];
658
+ let streamedAssistantText = "";
659
+
660
+ async function completeWithAssistantMessage(assistantMessageText, { metadata = {} } = {}) {
661
+ const normalizedAssistantMessageText = mergeAssistantMessageText(streamedAssistantText, assistantMessageText);
662
+ if (!normalizedAssistantMessageText) {
663
+ throw new AppError(502, "Assistant returned no output.");
664
+ }
665
+
666
+ await transcriptService.appendMessage(
667
+ assistantSurface,
668
+ conversationId,
669
+ {
670
+ role: "assistant",
671
+ kind: "chat",
672
+ contentText: normalizedAssistantMessageText
673
+ },
674
+ {
675
+ context: assistantContext,
676
+ workspace
677
+ }
678
+ );
679
+
680
+ await transcriptService.completeConversation(
681
+ assistantSurface,
682
+ conversationId,
683
+ {
684
+ status: "completed",
685
+ metadata
686
+ },
687
+ {
688
+ context: assistantContext,
689
+ workspace
690
+ }
691
+ );
692
+
693
+ streamWriter.sendAssistantMessage({
694
+ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE,
695
+ text: normalizedAssistantMessageText
696
+ });
697
+ streamWriter.sendDone({
698
+ type: ASSISTANT_STREAM_EVENT_TYPES.DONE,
699
+ messageId: source.messageId,
700
+ status: "completed"
701
+ });
702
+
703
+ return {
704
+ conversationId,
705
+ messageId: source.messageId,
706
+ status: "completed"
707
+ };
708
+ }
709
+
710
+ async function executeToolCalls(toolCalls = [], { toolFailures = [], toolSuccesses = [] } = {}) {
711
+ const roundFailures = [];
712
+
713
+ for (const toolCall of toolCalls) {
714
+ streamWriter.sendToolCall({
715
+ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
716
+ toolCallId: toolCall.id,
717
+ name: toolCall.name,
718
+ arguments: toolCall.arguments
719
+ });
720
+
721
+ await transcriptService.appendMessage(
722
+ assistantSurface,
723
+ conversationId,
724
+ {
725
+ role: "assistant",
726
+ kind: "tool_call",
727
+ contentText: toolCall.arguments,
728
+ metadata: {
729
+ toolCallId: toolCall.id,
730
+ tool: toolCall.name
731
+ }
732
+ },
733
+ {
734
+ context: assistantContext,
735
+ workspace
736
+ }
737
+ );
738
+
739
+ const toolResult = await serviceToolCatalog.executeToolCall({
740
+ toolName: toolCall.name,
741
+ argumentsText: toolCall.arguments,
742
+ context: assistantContext,
743
+ toolSet
744
+ });
745
+
746
+ await transcriptService.appendMessage(
747
+ assistantSurface,
748
+ conversationId,
749
+ {
750
+ role: "assistant",
751
+ kind: "tool_result",
752
+ contentText: JSON.stringify(toolResult),
753
+ metadata: {
754
+ toolCallId: toolCall.id,
755
+ tool: toolCall.name,
756
+ ok: toolResult.ok === true
757
+ }
758
+ },
759
+ {
760
+ context: assistantContext,
761
+ workspace
762
+ }
763
+ );
764
+
765
+ if (toolResult.ok) {
766
+ toolSuccesses.push({
767
+ name: toolCall.name,
768
+ result: toolResult.result
769
+ });
770
+ streamWriter.sendToolResult({
771
+ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
772
+ toolCallId: toolCall.id,
773
+ name: toolCall.name,
774
+ ok: true,
775
+ result: toolResult.result
776
+ });
777
+
778
+ messages.push({
779
+ role: "tool",
780
+ tool_call_id: toolCall.id,
781
+ content: JSON.stringify(toolResult.result ?? null)
782
+ });
783
+ continue;
784
+ }
785
+
786
+ const failure = {
787
+ name: toolCall.name,
788
+ error: toolResult.error
789
+ };
790
+ roundFailures.push(failure);
791
+ toolFailures.push(failure);
792
+
793
+ streamWriter.sendToolResult({
794
+ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
795
+ toolCallId: toolCall.id,
796
+ name: toolCall.name,
797
+ ok: false,
798
+ error: toolResult.error
799
+ });
800
+
801
+ messages.push({
802
+ role: "tool",
803
+ tool_call_id: toolCall.id,
804
+ content: JSON.stringify({
805
+ error: toolResult.error
806
+ })
807
+ });
808
+ }
809
+
810
+ return roundFailures;
811
+ }
812
+
813
+ async function recoverWithoutTools({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
814
+ const MAX_RECOVERY_PASSES = 3;
815
+ for (let pass = 0; pass < MAX_RECOVERY_PASSES; pass += 1) {
816
+ const recoveryMessages = [
817
+ ...messages,
818
+ {
819
+ role: "system",
820
+ content: buildRecoveryPrompt({
821
+ reason,
822
+ toolFailures,
823
+ toolSuccesses
824
+ })
825
+ }
826
+ ];
827
+
828
+ const completionStream = await aiClient.createChatCompletionStream({
829
+ messages: recoveryMessages,
830
+ tools: [],
831
+ signal: options.abortSignal
832
+ });
833
+ const completion = await consumeCompletionStream({
834
+ stream: completionStream,
835
+ streamWriter,
836
+ emitDeltas: true,
837
+ deltaSanitizer: createDsmlDeltaSanitizer()
838
+ });
839
+ streamedAssistantText += String(completion.streamedAssistantText || "");
840
+
841
+ const recoveryToolCalls = completion.toolCalls.filter((entry) => entry.name);
842
+ if (recoveryToolCalls.length > 0) {
843
+ messages.push(
844
+ buildAssistantToolCallMessage({
845
+ assistantText: completion.assistantText,
846
+ toolCalls: recoveryToolCalls
847
+ })
848
+ );
849
+ await executeToolCalls(recoveryToolCalls, {
850
+ toolFailures,
851
+ toolSuccesses
852
+ });
853
+ continue;
854
+ }
855
+
856
+ const assistantMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
857
+ if (assistantMessageText) {
858
+ return completeWithAssistantMessage(assistantMessageText, {
859
+ metadata: {
860
+ recoveryReason: reason || "unknown",
861
+ toolFailureCount: Array.isArray(toolFailures) ? toolFailures.length : 0
862
+ }
863
+ });
864
+ }
865
+ }
866
+
867
+ const fallbackText = buildRecoveryFallbackAnswer({
868
+ reason,
869
+ toolFailures,
870
+ toolSuccesses
871
+ });
872
+ return completeWithAssistantMessage(fallbackText, {
873
+ metadata: {
874
+ recoveryReason: reason || "unknown",
875
+ toolFailureCount: Array.isArray(toolFailures) ? toolFailures.length : 0
876
+ }
877
+ });
878
+ }
879
+
880
+ let streamed = false;
881
+
882
+ try {
883
+ streamWriter.sendMeta({
884
+ type: ASSISTANT_STREAM_EVENT_TYPES.META,
885
+ messageId: source.messageId,
886
+ conversationId,
887
+ provider: aiClient.provider,
888
+ model: aiClient.defaultModel
889
+ });
890
+ streamed = true;
891
+
892
+ const excludedToolNames = new Set();
893
+ const toolFailures = [];
894
+ const toolSuccesses = [];
895
+
896
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
897
+ const roundToolDescriptors = toolSet.tools.filter(
898
+ (tool) => !excludedToolNames.has(normalizeText(tool.name))
899
+ );
900
+ const roundToolSchemas = roundToolDescriptors.map((tool) => serviceToolCatalog.toOpenAiToolSchema(tool));
901
+
902
+ const completionStream = await aiClient.createChatCompletionStream({
903
+ messages,
904
+ tools: roundToolSchemas,
905
+ signal: options.abortSignal
906
+ });
907
+
908
+ const completion = await consumeCompletionStream({
909
+ stream: completionStream,
910
+ streamWriter,
911
+ emitDeltas: true,
912
+ deltaSanitizer: createDsmlDeltaSanitizer()
913
+ });
914
+ streamedAssistantText += String(completion.streamedAssistantText || "");
915
+
916
+ const toolCalls = completion.toolCalls.filter((entry) => entry.name);
917
+ if (toolCalls.length < 1) {
918
+ const finalMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
919
+ if (finalMessageText) {
920
+ return completeWithAssistantMessage(finalMessageText, {
921
+ metadata: toolFailures.length > 0
922
+ ? {
923
+ recoveryReason: "tool_failure",
924
+ toolFailureCount: toolFailures.length
925
+ }
926
+ : {}
927
+ });
928
+ }
929
+
930
+ if (toolFailures.length > 0) {
931
+ return recoverWithoutTools({
932
+ reason: "tool_failure",
933
+ toolFailures,
934
+ toolSuccesses
935
+ });
936
+ }
937
+
938
+ return completeWithAssistantMessage(completion.assistantText);
939
+ }
940
+
941
+ messages.push(
942
+ buildAssistantToolCallMessage({
943
+ assistantText: completion.assistantText,
944
+ toolCalls
945
+ })
946
+ );
947
+
948
+ const roundFailures = await executeToolCalls(toolCalls, {
949
+ toolFailures,
950
+ toolSuccesses
951
+ });
952
+
953
+ if (roundFailures.length > 0) {
954
+ for (const failure of roundFailures) {
955
+ const toolName = normalizeText(failure?.name);
956
+ if (toolName) {
957
+ excludedToolNames.add(toolName);
958
+ }
959
+ }
960
+ }
961
+ }
962
+
963
+ return recoverWithoutTools({
964
+ reason: toolFailures.length > 0 ? "tool_failure" : "max_tool_rounds",
965
+ toolFailures,
966
+ toolSuccesses
967
+ });
968
+ } catch (error) {
969
+ const aborted = isAbortError(error);
970
+ const status = aborted ? "aborted" : "failed";
971
+
972
+ if (streamed) {
973
+ await transcriptService.completeConversation(
974
+ assistantSurface,
975
+ conversationId,
976
+ {
977
+ status
978
+ },
979
+ {
980
+ context: assistantContext,
981
+ workspace
982
+ }
983
+ );
984
+
985
+ streamWriter.sendError({
986
+ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR,
987
+ messageId: source.messageId,
988
+ code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
989
+ message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
990
+ status: aborted ? 499 : Number(error?.status || error?.statusCode || 500)
991
+ });
992
+
993
+ streamWriter.sendDone({
994
+ type: ASSISTANT_STREAM_EVENT_TYPES.DONE,
995
+ messageId: source.messageId,
996
+ status
997
+ });
998
+
999
+ return {
1000
+ conversationId,
1001
+ messageId: source.messageId,
1002
+ status
1003
+ };
1004
+ }
1005
+
1006
+ throw error;
1007
+ }
1008
+ }
1009
+
1010
+ async function listConversations(query = {}, options = {}) {
1011
+ const assistantSurface = requireAssistantSurface(resolveCurrentAppConfig(), options?.input?.targetSurfaceId);
1012
+ const context = normalizeObject(options.context);
1013
+ const assistantContext = buildAssistantActionContext(context, assistantSurface);
1014
+ const workspace = assistantSurface.runtimeSurfaceRequiresWorkspace
1015
+ ? resolveWorkspace(assistantContext, options.input || {})
1016
+ : null;
1017
+ return transcriptService.listConversationsForUser(assistantSurface, workspace, assistantContext.actor, query, {
1018
+ context: assistantContext
1019
+ });
1020
+ }
1021
+
1022
+ async function getConversationMessages(conversationId, query = {}, options = {}) {
1023
+ const assistantSurface = requireAssistantSurface(resolveCurrentAppConfig(), options?.input?.targetSurfaceId);
1024
+ const context = normalizeObject(options.context);
1025
+ const assistantContext = buildAssistantActionContext(context, assistantSurface);
1026
+ const workspace = assistantSurface.runtimeSurfaceRequiresWorkspace
1027
+ ? resolveWorkspace(assistantContext, options.input || {})
1028
+ : null;
1029
+ return transcriptService.getConversationMessagesForUser(
1030
+ assistantSurface,
1031
+ workspace,
1032
+ assistantContext.actor,
1033
+ conversationId,
1034
+ query,
1035
+ {
1036
+ context: assistantContext
1037
+ }
1038
+ );
1039
+ }
1040
+
1041
+ return Object.freeze({
1042
+ streamChat,
1043
+ listConversations,
1044
+ getConversationMessages
1045
+ });
1046
+ }
1047
+
1048
+ export { createChatService };