@jskit-ai/assistant-runtime 0.1.192 → 0.1.194

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 (50) hide show
  1. package/fixtures/canonical-conversation/App.vue +188 -0
  2. package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
  3. package/fixtures/canonical-conversation/index.html +1 -0
  4. package/fixtures/canonical-conversation/main.js +61 -0
  5. package/fixtures/canonical-conversation/socket.js +50 -0
  6. package/fixtures/canonical-conversation/vite.config.mjs +178 -0
  7. package/fixtures/conversation/vite.config.mjs +12 -2
  8. package/package.json +23 -10
  9. package/src/client/components/AssistantConversationClientElement.vue +57 -0
  10. package/src/client/composables/useAssistantConversation.js +874 -0
  11. package/src/client/index.js +2 -0
  12. package/src/client/support/assistantRuntimeState.js +1 -1
  13. package/src/client/support/subscribeAssistantConversation.js +205 -0
  14. package/src/server/AssistantProvider.js +82 -106
  15. package/src/server/actionIds.js +10 -0
  16. package/src/server/actions.js +287 -79
  17. package/src/server/createAssistantRuntime.js +138 -0
  18. package/src/server/createAssistantSettingsActions.js +89 -0
  19. package/src/server/index.js +6 -0
  20. package/src/server/inputSchemas.js +90 -0
  21. package/src/server/registerConversationSubscriptions.js +163 -0
  22. package/src/server/registerFastifyConversations.js +76 -0
  23. package/src/server/registerRoutes.js +220 -198
  24. package/src/server/registerSettingsRoutes.js +113 -0
  25. package/src/server/repositories/conversationsRepository.js +12 -5
  26. package/src/server/repositories/messagesRepository.js +12 -0
  27. package/src/server/services/chatService.js +88 -694
  28. package/src/server/services/transcriptService.js +56 -52
  29. package/src/server/sqlConversationStorage.js +192 -0
  30. package/src/server/support/assistantRouteContext.js +91 -0
  31. package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
  32. package/src/shared/assistantSurfaces.js +14 -0
  33. package/src/shared/conversationRealtime.js +9 -0
  34. package/src/shared/index.js +1 -0
  35. package/test/actionDefinitions.test.js +44 -0
  36. package/test/aiConnections.test.js +1 -1
  37. package/test/assistantConversationBinding.browser.test.js +1868 -0
  38. package/test/assistantConversationOperations.test.js +459 -0
  39. package/test/assistantConversationSubscriptions.test.js +692 -0
  40. package/test/assistantIntegrationBoundary.test.js +103 -0
  41. package/test/assistantRuntimeComposition.test.js +68 -0
  42. package/test/assistantSurfacePattern.test.js +1 -1
  43. package/test/assistantSurfaces.test.js +23 -0
  44. package/test/chatServiceLifecycle.test.js +12 -1
  45. package/test/featureRuntime.test.js +238 -0
  46. package/test/registerFastifyConversations.test.js +169 -0
  47. package/test/sqlConversationStorage.test.js +205 -0
  48. package/test/support/memoryAssistantRows.js +51 -0
  49. package/test/turnRequests.test.js +11 -2
  50. package/src/shared/assistantResponseText.js +0 -29
@@ -2,20 +2,12 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { AppError } from "@jskit-ai/kernel/server/runtime";
3
3
  import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import { resolveWorkspaceSlug } from "@jskit-ai/assistant-core/server";
5
- import {
6
- ASSISTANT_STREAM_EVENT_TYPES
7
- } from "@jskit-ai/assistant-core/shared";
5
+ import { ASSISTANT_STREAM_EVENT_TYPES, isAssistantProgressOnlyText } from "@jskit-ai/assistant-core/shared";
8
6
  import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
9
- import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
10
7
 
11
8
  const MAX_HISTORY_MESSAGES = 20;
12
9
  const MAX_INPUT_CHARS = 8000;
13
- const MAX_TOOL_ROUNDS = 16;
14
- const MAX_RECOVERY_PASSES = 3;
15
- const MAX_TOOL_RESULT_FALLBACK_CHARS = 4000;
16
- const CURRENT_TIME_PREFLIGHT_INTENT = "current-time";
17
10
  const CLOCK_INSTRUCTION = "For current or relative date and time questions, first use any available authoritative workspace clock action; never infer the current date or time from model knowledge.";
18
- const COMPLETION_INSTRUCTION = "Do not narrate future work or describe what you are about to do. Either call the required available tool now or provide the completed final answer.";
19
11
 
20
12
  function normalizeConversationId(value) {
21
13
  return normalizeRecordId(value, { fallback: null });
@@ -99,58 +91,6 @@ function isAbortError(error) {
99
91
  return String(error.name || "").trim() === "AbortError";
100
92
  }
101
93
 
102
- function requiresCurrentTime(value = "") {
103
- const text = normalizeText(value);
104
- if (!text) {
105
- return false;
106
- }
107
-
108
- return [
109
- /\b(?:now|today|tomorrow|yesterday|tonight)\b/iu,
110
- /\b(?:current|local)\s+(?:date|day|time|date\s+and\s+time)\b/iu,
111
- /\bwhat(?:'s|\s+is)\s+(?:the\s+)?(?:date|day|time)\b/iu,
112
- /\b(?:this|next|last)\s+(?:day|week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/iu
113
- ].some((pattern) => pattern.test(text));
114
- }
115
-
116
- function resolvePreflightTools(toolDescriptors = [], input = "") {
117
- if (!requiresCurrentTime(input)) {
118
- return [];
119
- }
120
-
121
- const currentTimeTool = toolDescriptors.find((tool) => {
122
- const intents = Array.isArray(tool?.preflight) ? tool.preflight : [];
123
- const requiredParameters = Array.isArray(tool?.parameters?.required)
124
- ? tool.parameters.required
125
- : [];
126
- return requiredParameters.length < 1 && intents.includes(CURRENT_TIME_PREFLIGHT_INTENT);
127
- });
128
-
129
- return currentTimeTool ? [currentTimeTool] : [];
130
- }
131
-
132
- function extractTextDelta(deltaContent) {
133
- if (typeof deltaContent === "string") {
134
- return deltaContent;
135
- }
136
-
137
- if (!Array.isArray(deltaContent)) {
138
- return "";
139
- }
140
-
141
- return deltaContent
142
- .map((entry) => {
143
- if (typeof entry === "string") {
144
- return entry;
145
- }
146
- if (!entry || typeof entry !== "object") {
147
- return "";
148
- }
149
- return String(entry.text || "");
150
- })
151
- .join("");
152
- }
153
-
154
94
  function toCompactJson(value, fallback = "{}") {
155
95
  try {
156
96
  if (!value || typeof value !== "object") {
@@ -217,235 +157,6 @@ function buildSystemPrompt({ targetSurfaceId = "", toolDescriptors = [], workspa
217
157
  return promptSegments.join(" ");
218
158
  }
219
159
 
220
- function buildRecoveryPrompt({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
221
- const normalizedReason = normalizeText(reason).toLowerCase();
222
- const failureSummary = (Array.isArray(toolFailures) ? toolFailures : [])
223
- .slice(0, 3)
224
- .map((entry) => {
225
- const toolName = normalizeText(entry?.name) || "unknown_tool";
226
- const errorCode = normalizeText(entry?.error?.code) || "tool_failed";
227
- return `${toolName}:${errorCode}`;
228
- })
229
- .filter(Boolean)
230
- .join(", ");
231
- const successSummary = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
232
- .slice(0, 3)
233
- .map((entry) => normalizeText(entry?.name))
234
- .filter(Boolean)
235
- .join(", ");
236
-
237
- const failureSuffix = failureSummary ? ` Recent tool failures: ${failureSummary}.` : "";
238
- const successSuffix = successSummary ? ` Successful tools: ${successSummary}.` : "";
239
- if (normalizedReason === "tool_failure") {
240
- 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. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
241
- }
242
-
243
- return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
244
- }
245
-
246
- function buildRecoveryFallbackAnswer({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
247
- if (normalizeText(reason).toLowerCase() === "max_tool_rounds") {
248
- return "Limit reached. Start a new conversation.";
249
- }
250
-
251
- return buildToolOutcomeFallbackAnswer({
252
- toolFailures,
253
- toolSuccesses
254
- });
255
- }
256
-
257
- function toSafeToolResultText(value) {
258
- if (value == null) {
259
- return "null";
260
- }
261
- if (typeof value === "string") {
262
- const normalized = normalizeText(value);
263
- return normalized || "\"\"";
264
- }
265
- try {
266
- return JSON.stringify(value, null, 2);
267
- } catch {
268
- return "\"<unserializable>\"";
269
- }
270
- }
271
-
272
- function buildToolOutcomeFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
273
- const successfulResults = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
274
- .filter((entry) => normalizeText(entry?.name));
275
- const hasFailures = Array.isArray(toolFailures) && toolFailures.length > 0;
276
-
277
- if (successfulResults.length > 0) {
278
- const latestSuccess = successfulResults.at(-1);
279
- const answer = `Latest successful result from ${normalizeText(latestSuccess.name)}:\n${toSafeToolResultText(latestSuccess.result)}`;
280
- if (answer.length <= MAX_TOOL_RESULT_FALLBACK_CHARS) {
281
- return answer;
282
- }
283
-
284
- const suffix = "\n…[truncated]";
285
- return `${answer.slice(0, MAX_TOOL_RESULT_FALLBACK_CHARS - suffix.length)}${suffix}`;
286
- }
287
-
288
- if (hasFailures) {
289
- return "I could not gather additional information from successful operations.";
290
- }
291
-
292
- return "I could not gather additional information from the available operations.";
293
- }
294
-
295
- function sanitizeAssistantMessageText(value) {
296
- let source = String(value || "");
297
- if (!source) {
298
- return "";
299
- }
300
-
301
- const blockPatterns = [
302
- /<[^>\n]*function_calls[^>\n]*>[\s\S]*?(?:<\/[^>\n]*function_calls>|$)/gi,
303
- /<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?(?:<\/[^>\n]*tool_calls?[^>\n]*>|$)/gi,
304
- /<[^>\n]*invoke\b[^>\n]*>[\s\S]*?(?:<\/[^>\n]*invoke>|$)/gi,
305
- /<(?:analysis|reasoning|think)>[\s\S]*?(?:<\/(?:analysis|reasoning|think)>|$)/gi
306
- ];
307
- for (const pattern of blockPatterns) {
308
- source = source.replace(pattern, " ");
309
- }
310
-
311
- const inlineTagPatterns = [
312
- /<[^>\n]*invoke\b[^>]*\/>/gi,
313
- /<\/?[^>\n]*invoke[^>\n]*>/gi,
314
- /<\/?[^>\n]*function_calls[^>\n]*>/gi,
315
- /<\/?[^>\n]*tool_calls?[^>\n]*>/gi,
316
- /<\/?[^>\n]*DSML[^>\n]*>/gi
317
- ];
318
- for (const pattern of inlineTagPatterns) {
319
- source = source.replace(pattern, " ");
320
- }
321
-
322
- return source
323
- .split(/\r?\n/)
324
- .map((line) => line.trim())
325
- .filter(Boolean)
326
- .join("\n");
327
- }
328
-
329
- function buildAssistantToolCallMessage(toolCalls = []) {
330
- return {
331
- role: "assistant",
332
- content: "",
333
- tool_calls: toolCalls.map((toolCall) => ({
334
- id: toolCall.id,
335
- type: "function",
336
- function: {
337
- name: toolCall.name,
338
- arguments: toolCall.arguments
339
- }
340
- }))
341
- };
342
- }
343
-
344
- function parseDsmlToolCallsFromText(value = "") {
345
- const source = String(value || "");
346
- if (!source) {
347
- return [];
348
- }
349
-
350
- const functionCallsMatch = source.match(
351
- /<[^>\n]*function_calls[^>\n]*>([\s\S]*?)<\/[^>\n]*function_calls>/i
352
- );
353
- if (!functionCallsMatch) {
354
- return [];
355
- }
356
-
357
- const blockText = String(functionCallsMatch[1] || "");
358
- if (!blockText) {
359
- return [];
360
- }
361
-
362
- const calls = [];
363
- const invokePattern = /<[^>\n]*invoke\b([^>]*)>([\s\S]*?)<\/[^>\n]*invoke>/gi;
364
- let match = invokePattern.exec(blockText);
365
- while (match) {
366
- const attributes = String(match[1] || "");
367
- const body = normalizeText(String(match[2] || ""));
368
- const quotedNameMatch =
369
- attributes.match(/\bname\s*=\s*"([^"]+)"/i) || attributes.match(/\bname\s*=\s*'([^']+)'/i);
370
- const bareNameMatch = attributes.match(/\bname\s*=\s*([^\s"'/>]+)/i);
371
- const name = normalizeText(quotedNameMatch?.[1] || bareNameMatch?.[1]);
372
- if (name) {
373
- calls.push({
374
- id: `dsml_tool_call_${calls.length + 1}`,
375
- name,
376
- arguments: body && /^[\[{]/.test(body) ? body : "{}"
377
- });
378
- }
379
-
380
- match = invokePattern.exec(blockText);
381
- }
382
-
383
- return calls;
384
- }
385
-
386
- async function consumeCompletionStream(stream, onText = () => {}) {
387
- let assistantText = "";
388
- const toolCallsByIndex = new Map();
389
-
390
- for await (const chunk of stream) {
391
- const choice = chunk?.choices?.[0] || {};
392
- const delta = choice?.delta || {};
393
-
394
- const textDelta = extractTextDelta(delta.content);
395
- if (textDelta) {
396
- assistantText += textDelta;
397
- // An opening provider tag can be split between chunks. Keep it private
398
- // until its boundary is known; complete internal blocks are stripped below.
399
- const tagStart = assistantText.lastIndexOf("<");
400
- const safeText = tagStart > assistantText.lastIndexOf(">") ? assistantText.slice(0, tagStart) : assistantText;
401
- onText(sanitizeAssistantMessageText(safeText));
402
- }
403
-
404
- const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
405
- for (const partialToolCall of toolCalls) {
406
- const index = Number(partialToolCall?.index || 0);
407
- const existing =
408
- toolCallsByIndex.get(index) ||
409
- {
410
- id: normalizeText(partialToolCall?.id) || `tool_call_${index + 1}`,
411
- name: "",
412
- arguments: ""
413
- };
414
-
415
- if (partialToolCall?.id) {
416
- existing.id = normalizeText(partialToolCall.id) || existing.id;
417
- }
418
- if (partialToolCall?.function?.name) {
419
- existing.name += String(partialToolCall.function.name || "");
420
- }
421
- if (partialToolCall?.function?.arguments) {
422
- existing.arguments += String(partialToolCall.function.arguments || "");
423
- }
424
-
425
- toolCallsByIndex.set(index, existing);
426
- }
427
- }
428
-
429
- let toolCalls = [...toolCallsByIndex.values()].map((toolCall, index) => ({
430
- id: normalizeText(toolCall.id) || `tool_call_${index + 1}`,
431
- name: normalizeText(toolCall.name),
432
- arguments: String(toolCall.arguments || "")
433
- }));
434
-
435
- if (toolCalls.length < 1) {
436
- const parsedDsmlCalls = parseDsmlToolCallsFromText(assistantText);
437
- if (parsedDsmlCalls.length > 0) {
438
- toolCalls = parsedDsmlCalls;
439
- assistantText = normalizeText(sanitizeAssistantMessageText(assistantText));
440
- }
441
- }
442
-
443
- return {
444
- assistantText,
445
- toolCalls
446
- };
447
- }
448
-
449
160
  function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
450
161
  const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
451
162
  if (assistantSurface) {
@@ -463,6 +174,7 @@ function buildAssistantActionContext(context = {}, assistantSurface = {}) {
463
174
  }
464
175
 
465
176
  function createChatService({
177
+ conversationRuntime,
466
178
  aiClientFactory,
467
179
  turnRequests,
468
180
  attachments,
@@ -565,6 +277,7 @@ function createChatService({
565
277
  return result;
566
278
 
567
279
  async function executeTurn() {
280
+ if (typeof conversationRuntime?.open !== "function") throw new TypeError("Assistant streaming requires its common conversation runtime.");
568
281
  const conversationResult = await transcriptService.createConversationForTurn(
569
282
  assistantSurface,
570
283
  workspace,
@@ -587,8 +300,9 @@ function createChatService({
587
300
  throw new AppError(500, "Assistant failed to create conversation.");
588
301
  }
589
302
 
590
- async function resolveFiles(attachmentIds) {
591
- if (attachmentIds === undefined || (Array.isArray(attachmentIds) && !attachmentIds.length)) return { attachments: [], content: [] };
303
+ const resolvedAttachments = [];
304
+ async function prepareAttachments(attachmentIds) {
305
+ if (attachmentIds === undefined || (Array.isArray(attachmentIds) && !attachmentIds.length)) return;
592
306
  if (!Array.isArray(attachmentIds) || attachmentIds.length > 10 ||
593
307
  attachmentIds.some(id => typeof id !== "string" || !id || id.length > 256)) {
594
308
  throw new AppError(400, "Invalid attachment identifiers.");
@@ -596,422 +310,101 @@ function createChatService({
596
310
  if (!attachments?.resolve || !aiClient.supportsAttachments) {
597
311
  throw new AppError(400, "File attachments are not configured for this assistant.");
598
312
  }
599
- // Only the application can authorize uploads and turn stored bytes into
600
- // provider input. Browser-supplied paths, URLs and file contents are never used.
601
- return attachments.resolve({ attachmentIds, context: assistantContext, workspace, conversation });
602
- }
603
- const files = await resolveFiles(source.attachmentIds);
604
- const history = [];
605
- for (const message of source.history) {
606
- const previousFiles = await resolveFiles(message.attachmentIds);
607
- history.push({ role: message.role, content: previousFiles.content.length
608
- ? [{ type: "text", text: message.content }, ...previousFiles.content] : message.content });
313
+ // Preserve the facade's existing pre-admission authorization order.
314
+ // Only these server-resolved materials reach the common file reader.
315
+ const files = await attachments.resolve({ attachmentIds, context: assistantContext, workspace, conversation });
316
+ resolvedAttachments.push({ attachmentIds: [...attachmentIds], files });
609
317
  }
610
-
611
- await transcriptService.appendMessage(
612
- assistantSurface,
613
- conversationId,
614
- {
615
- role: "user",
616
- kind: "chat",
617
- clientMessageSid: source.messageId,
618
- contentText: source.input,
619
- metadata: {
620
- surfaceId: assistantSurface.targetSurfaceId,
621
- ...(files.attachments.length ? { attachments: files.attachments } : {})
622
- }
623
- },
624
- {
625
- context: assistantContext,
626
- workspace
627
- }
628
- );
629
-
318
+ await prepareAttachments(source.attachmentIds);
319
+ for (const message of source.history) await prepareAttachments(message.attachmentIds);
630
320
  const toolSet = serviceToolCatalog.resolveToolSet(assistantContext);
631
321
  const customSystemPrompt = await assistantConfigService.resolveSystemPrompt(
632
- assistantSurface,
633
- workspace,
634
- {
635
- surface: assistantSurface.targetSurfaceId
636
- },
637
- {
638
- context: assistantContext,
639
- input: payload
640
- }
322
+ assistantSurface, workspace, { surface: assistantSurface.targetSurfaceId },
323
+ { context: assistantContext, input: payload }
641
324
  );
642
- const systemPrompt = buildSystemPrompt({
643
- targetSurfaceId: assistantSurface.targetSurfaceId,
644
- toolDescriptors: toolSet.tools,
645
- workspaceSlug: resolveWorkspaceSlug(assistantContext, payload),
646
- customSystemPrompt
647
- });
648
-
649
- const messages = [
650
- {
651
- role: "system",
652
- content: systemPrompt
653
- },
654
- ...history,
655
- {
656
- role: "user",
657
- content: files.content.length ? [{ type: "text", text: source.input }, ...files.content] : source.input
658
- }
659
- ];
660
-
661
- async function completeWithAssistantMessage(assistantMessageText, { metadata = {} } = {}) {
662
- const normalizedAssistantMessageText = normalizeText(sanitizeAssistantMessageText(assistantMessageText));
663
- if (!normalizedAssistantMessageText) {
664
- throw new AppError(502, "Assistant returned no output.");
665
- }
666
-
667
- await transcriptService.appendMessage(
668
- assistantSurface,
669
- conversationId,
670
- {
671
- role: "assistant",
672
- kind: "chat",
673
- contentText: normalizedAssistantMessageText
674
- },
675
- {
676
- context: assistantContext,
677
- workspace
678
- }
679
- );
680
-
681
- await transcriptService.completeConversation(
682
- assistantSurface,
683
- conversationId,
684
- {
685
- status: "completed",
686
- metadata
687
- },
688
- {
689
- context: assistantContext,
690
- workspace
691
- }
692
- );
693
-
694
- streamWriter.sendAssistantMessage({
695
- type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE,
696
- text: normalizedAssistantMessageText
697
- });
698
- streamWriter.sendDone({
699
- type: ASSISTANT_STREAM_EVENT_TYPES.DONE,
700
- messageId: source.messageId,
701
- status: "completed"
702
- });
703
-
704
- return {
705
- conversationId,
706
- messageId: source.messageId,
707
- status: "completed"
708
- };
325
+ const configuration = { systemPrompt: buildSystemPrompt({
326
+ targetSurfaceId: assistantSurface.targetSurfaceId, toolDescriptors: toolSet.tools,
327
+ workspaceSlug: resolveWorkspaceSlug(assistantContext, payload), customSystemPrompt
328
+ }), ...(request.integrationId ? { integrationId: request.integrationId } : {}) };
329
+ const runtimeContext = { ...assistantContext, assistantRequest: {
330
+ conversation, workspace, history: source.history, resolvedAttachments
331
+ } };
332
+ let handle;
333
+ try { handle = await conversationRuntime.open({ id: conversationId, context: runtimeContext, configuration }); }
334
+ catch (error) {
335
+ if (error.code !== "conversation_configuration_mismatch") throw error;
336
+ handle = await conversationRuntime.open({ id: conversationId, context: runtimeContext });
337
+ await handle.configure(configuration);
709
338
  }
710
-
339
+ let turnId = null;
340
+ let displayedId = null;
711
341
  let displayedText = "";
712
- async function consumeResponse(stream) {
713
- if (displayedText) {
714
- streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
715
- displayedText = "";
716
- }
717
- return consumeCompletionStream(stream, text => {
718
- if (text === displayedText) return;
719
- if (text.startsWith(displayedText)) {
720
- streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: text.slice(displayedText.length) });
721
- } else {
722
- streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text, status: "streaming" });
342
+ const release = await handle.subscribe(event => {
343
+ if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
344
+ if (!turnId || event.turnId !== turnId) return;
345
+ if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
346
+ if (displayedId !== event.messageId) {
347
+ if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
348
+ displayedId = event.messageId;
349
+ displayedText = "";
723
350
  }
724
- displayedText = text;
725
- });
726
- }
727
-
728
- async function executeToolCalls(toolCalls = [], { toolFailures = [], toolSuccesses = [] } = {}) {
729
- const roundFailures = [];
730
-
731
- for (const toolCall of toolCalls) {
732
- streamWriter.sendToolCall({
733
- type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
734
- toolCallId: toolCall.id,
735
- name: toolCall.name,
736
- arguments: toolCall.arguments
737
- });
738
-
739
- await transcriptService.appendMessage(
740
- assistantSurface,
741
- conversationId,
742
- {
743
- role: "assistant",
744
- kind: "tool_call",
745
- contentText: toolCall.arguments,
746
- metadata: {
747
- toolCallId: toolCall.id,
748
- tool: toolCall.name
749
- }
750
- },
751
- {
752
- context: assistantContext,
753
- workspace
754
- }
755
- );
756
-
757
- const toolResult = await serviceToolCatalog.executeToolCall({
758
- toolName: toolCall.name,
759
- argumentsText: toolCall.arguments,
760
- context: assistantContext,
761
- toolSet
762
- });
763
-
764
- await transcriptService.appendMessage(
765
- assistantSurface,
766
- conversationId,
767
- {
768
- role: "assistant",
769
- kind: "tool_result",
770
- contentText: JSON.stringify(toolResult),
771
- metadata: {
772
- toolCallId: toolCall.id,
773
- tool: toolCall.name,
774
- ok: toolResult.ok === true
775
- }
776
- },
777
- {
778
- context: assistantContext,
779
- workspace
780
- }
781
- );
782
-
783
- if (toolResult.ok) {
784
- toolSuccesses.push({
785
- name: toolCall.name,
786
- result: toolResult.result
787
- });
788
- streamWriter.sendToolResult({
789
- type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
790
- toolCallId: toolCall.id,
791
- name: toolCall.name,
792
- ok: true,
793
- result: toolResult.result
794
- });
795
-
796
- messages.push({
797
- role: "tool",
798
- tool_call_id: toolCall.id,
799
- content: JSON.stringify(toolResult.result ?? null)
800
- });
801
- continue;
802
- }
803
-
804
- const failure = {
805
- name: toolCall.name,
806
- error: toolResult.error
807
- };
808
- roundFailures.push(failure);
809
- toolFailures.push(failure);
810
-
811
- streamWriter.sendToolResult({
812
- type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
813
- toolCallId: toolCall.id,
814
- name: toolCall.name,
815
- ok: false,
816
- error: toolResult.error
817
- });
818
-
819
- messages.push({
820
- role: "tool",
821
- tool_call_id: toolCall.id,
822
- content: JSON.stringify({
823
- error: toolResult.error
824
- })
825
- });
351
+ if (event.text === displayedText) return;
352
+ if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
353
+ else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
354
+ displayedText = event.text;
355
+ } else if (event.type === "tool") {
356
+ const call = event.call;
357
+ if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
358
+ toolCallId: call.id, name: call.name, arguments: call.arguments });
359
+ else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
360
+ toolCallId: call.id, name: call.name, ok: call.result.ok === true,
361
+ ...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
826
362
  }
827
-
828
- return roundFailures;
829
- }
830
-
831
- async function recoverWithoutTools({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
832
- for (let pass = 0; pass < MAX_RECOVERY_PASSES; pass += 1) {
833
- const recoveryMessages = [
834
- ...messages,
835
- {
836
- role: "system",
837
- content: buildRecoveryPrompt({
838
- reason,
839
- toolFailures,
840
- toolSuccesses
841
- })
842
- }
843
- ];
844
-
845
- const completionStream = await aiClient.createChatCompletionStream({
846
- messages: recoveryMessages,
847
- tools: [],
848
- signal: options.abortSignal
849
- });
850
- const completion = await consumeResponse(completionStream);
851
-
852
- const recoveryToolCalls = completion.toolCalls.filter((entry) => entry.name);
853
- if (recoveryToolCalls.length > 0) {
854
- continue;
855
- }
856
-
857
- const assistantMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
858
- if (assistantMessageText && !isAssistantProgressOnlyText(assistantMessageText)) {
859
- return completeWithAssistantMessage(assistantMessageText, {
860
- metadata: {
861
- recoveryReason: reason || "unknown",
862
- toolFailureCount: Array.isArray(toolFailures) ? toolFailures.length : 0
863
- }
864
- });
865
- }
866
- }
867
-
868
- const fallbackText = buildRecoveryFallbackAnswer({
869
- reason,
870
- toolFailures,
871
- toolSuccesses
872
- });
873
- return completeWithAssistantMessage(fallbackText, {
874
- metadata: {
875
- recoveryReason: reason || "unknown",
876
- toolFailureCount: Array.isArray(toolFailures) ? toolFailures.length : 0
877
- }
878
- });
879
- }
880
-
363
+ });
364
+ let cancellation;
365
+ const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
881
366
  let streamed = false;
882
-
883
367
  try {
884
- const receipt = {
885
- type: ASSISTANT_STREAM_EVENT_TYPES.META,
886
- messageId: source.messageId,
887
- conversationId,
888
- provider: aiClient.provider,
889
- model: aiClient.defaultModel
890
- };
891
- await turnRequests.update(claim, { meta: receipt });
892
- streamWriter.sendMeta(receipt);
368
+ const meta = { type: ASSISTANT_STREAM_EVENT_TYPES.META, messageId: source.messageId, conversationId,
369
+ provider: aiClient.provider, model: aiClient.defaultModel };
370
+ await turnRequests.update(claim, { meta });
371
+ streamWriter.sendMeta(meta);
893
372
  streamed = true;
894
-
895
- const excludedToolNames = new Set();
896
- const toolFailures = [];
897
- const toolSuccesses = [];
898
-
899
- const preflightTools = resolvePreflightTools(toolSet.tools, source.input);
900
- for (const [index, tool] of preflightTools.entries()) {
901
- const toolCall = {
902
- id: `assistant_preflight_${index + 1}`,
903
- name: tool.name,
904
- arguments: "{}"
905
- };
906
- messages.push(buildAssistantToolCallMessage([toolCall]));
907
- const preflightFailures = await executeToolCalls([toolCall], {
908
- toolFailures,
909
- toolSuccesses
910
- });
911
- for (const failure of preflightFailures) {
912
- const toolName = normalizeText(failure?.name);
913
- if (toolName) {
914
- excludedToolNames.add(toolName);
915
- }
916
- }
917
- }
918
-
919
- for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
920
- const roundToolDescriptors = toolSet.tools.filter(
921
- (tool) => !excludedToolNames.has(normalizeText(tool.name))
922
- );
923
- const roundToolSchemas = roundToolDescriptors.map((tool) => serviceToolCatalog.toOpenAiToolSchema(tool));
924
-
925
- const completionStream = await aiClient.createChatCompletionStream({
926
- messages,
927
- tools: roundToolSchemas,
928
- signal: options.abortSignal
929
- });
930
-
931
- const completion = await consumeResponse(completionStream);
932
-
933
- const toolCalls = completion.toolCalls.filter((entry) => entry.name);
934
- if (toolCalls.length < 1) {
935
- const finalMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
936
- if (finalMessageText && !isAssistantProgressOnlyText(finalMessageText)) {
937
- return completeWithAssistantMessage(finalMessageText, {
938
- metadata: toolFailures.length > 0
939
- ? {
940
- recoveryReason: "tool_failure",
941
- toolFailureCount: toolFailures.length
942
- }
943
- : {}
944
- });
945
- }
946
-
947
- messages.push({
948
- role: "system",
949
- content: COMPLETION_INSTRUCTION
950
- });
951
- continue;
952
- }
953
-
954
- messages.push(buildAssistantToolCallMessage(toolCalls));
955
-
956
- const roundFailures = await executeToolCalls(toolCalls, {
957
- toolFailures,
958
- toolSuccesses
959
- });
960
-
961
- if (roundFailures.length > 0) {
962
- for (const failure of roundFailures) {
963
- const toolName = normalizeText(failure?.name);
964
- if (toolName) {
965
- excludedToolNames.add(toolName);
966
- }
967
- }
968
- }
373
+ options.abortSignal?.addEventListener("abort", cancel, { once: true });
374
+ options.abortSignal?.throwIfAborted();
375
+ const receipt = await handle.send({ messageId: source.messageId, text: source.input,
376
+ ...(source.attachmentIds !== undefined ? { attachmentIds: source.attachmentIds } : {}) });
377
+ turnId = receipt.turnId;
378
+ const snapshot = await handle.wait();
379
+ const turn = snapshot.conversationLog.find(entry => entry.turnId === turnId);
380
+ const status = turn?.metadata?.runtime?.status;
381
+ if (status !== "complete") {
382
+ const error = new Error(turn?.metadata?.runtime?.error || snapshot.error || "Assistant request failed.");
383
+ if (status === "cancelled") error.name = "AbortError";
384
+ throw error;
969
385
  }
970
-
971
- return recoverWithoutTools({
972
- reason: toolFailures.length > 0 ? "tool_failure" : "max_tool_rounds",
973
- toolFailures,
974
- toolSuccesses
975
- });
386
+ const text = normalizeText(turn.assistant?.text);
387
+ if (!text) throw new AppError(502, "Assistant returned no output.");
388
+ await transcriptService.completeConversation(assistantSurface, conversationId,
389
+ { status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
390
+ streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
391
+ streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
392
+ return { conversationId, messageId: source.messageId, status: "completed" };
976
393
  } catch (error) {
977
- const aborted = isAbortError(error);
394
+ const aborted = isAbortError(error) || options.abortSignal?.aborted;
978
395
  const status = aborted ? "aborted" : "failed";
979
-
980
- if (streamed) {
981
- await transcriptService.completeConversation(
982
- assistantSurface,
983
- conversationId,
984
- {
985
- status
986
- },
987
- {
988
- context: assistantContext,
989
- workspace
990
- }
991
- );
992
-
993
- streamWriter.sendError({
994
- type: ASSISTANT_STREAM_EVENT_TYPES.ERROR,
995
- messageId: source.messageId,
996
- code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
997
- message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
998
- status: aborted ? 499 : Number(error?.status || error?.statusCode || 500)
999
- });
1000
-
1001
- streamWriter.sendDone({
1002
- type: ASSISTANT_STREAM_EVENT_TYPES.DONE,
1003
- messageId: source.messageId,
1004
- status
1005
- });
1006
-
1007
- return {
1008
- conversationId,
1009
- messageId: source.messageId,
1010
- status
1011
- };
1012
- }
1013
-
1014
- throw error;
396
+ if (!streamed) throw error;
397
+ await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
398
+ streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
399
+ code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
400
+ message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
401
+ status: aborted ? 499 : Number(error?.status || error?.statusCode || 500) });
402
+ streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
403
+ return { conversationId, messageId: source.messageId, status };
404
+ } finally {
405
+ options.abortSignal?.removeEventListener("abort", cancel);
406
+ release();
407
+ await cancellation;
1015
408
  }
1016
409
  }
1017
410
  }
@@ -1044,6 +437,7 @@ function createChatService({
1044
437
  }
1045
438
 
1046
439
  return Object.freeze({
440
+ close: () => conversationRuntime?.close(),
1047
441
  streamChat,
1048
442
  listConversations,
1049
443
  getConversationMessages