@jskit-ai/assistant 0.1.4

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