@arcote.tech/arc-chat 0.7.9 → 0.7.11

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.
@@ -3,13 +3,17 @@ import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech
3
3
  import type {
4
4
  ArcToolAny,
5
5
  AssistantContentBlock,
6
- ChatStreamEvent,
7
6
  Conversation,
8
7
  ConversationTurn,
9
8
  LLMProvider,
10
9
  ToolCall,
11
10
  } from "@arcote.tech/arc-ai";
12
- import { broadcast, endStream } from "../streaming/stream-registry";
11
+ import {
12
+ finalize,
13
+ publish,
14
+ startStream,
15
+ type PublishableEvent,
16
+ } from "../streaming/stream-registry";
13
17
 
14
18
  // ─── Config ─────────────────────────────────────────────────────
15
19
 
@@ -125,7 +129,7 @@ async function buildInstructions(
125
129
  scopeId,
126
130
  };
127
131
  const result = await (instruction.handler as Function)(instructionCtx);
128
- if (typeof result === "string") return { prompt: result };
132
+ if (typeof result === "string") return result as unknown as InstructionResult;
129
133
  if (result && typeof result === "object" && "prompt" in result) return result as InstructionResult;
130
134
  return { prompt: "" };
131
135
  }
@@ -136,11 +140,6 @@ async function buildInstructions(
136
140
  * Decide whether to ask the provider for a continuation (delta) or send the
137
141
  * full conversation. Continuation is only used when the provider supports it
138
142
  * AND we have a known `responseId` to anchor the request.
139
- *
140
- * @param history Full conversation history including any new turns appended
141
- * for this call.
142
- * @param newTurnsStartIdx Index in `history` where "new" turns begin
143
- * (everything before is "already known" by the model).
144
143
  */
145
144
  function makeConversation(
146
145
  provider: LLMProvider,
@@ -190,10 +189,11 @@ interface RunLoopConfig {
190
189
  instruction?: ArcFunction<any>;
191
190
  /** ID pustego assistant row'a utworzonego synchronicznie w mutacji
192
191
  * triggerującej generację (`sendMessage`/`systemMessage`/`startStage`/
193
- * `respondToTool`). Listener używa go w PIERWSZEJ iteracji zamiast
194
- * wołać `startAssistantTurn`. Dzięki temu klient widzi assistant row
195
- * natychmiast po mutacji i otwiera SSE zanim chunki zaczną lecieć.
196
- * Następne iteracje (multi-turn po server tool exec) tworzą fresh rows. */
192
+ * `respondToTool`/`retryGeneration`). Listener używa go w PIERWSZEJ
193
+ * iteracji zamiast wołać `startAssistantTurn`. Dzięki temu klient widzi
194
+ * assistant row natychmiast po mutacji i otwiera SSE zanim chunki zaczną
195
+ * lecieć. Następne iteracje (multi-turn po server tool exec) tworzą fresh
196
+ * rows. */
197
197
  preCreatedAssistantMessageId?: string;
198
198
  }
199
199
 
@@ -213,36 +213,22 @@ async function runGenerationLoop(config: RunLoopConfig) {
213
213
  instruction,
214
214
  } = config;
215
215
 
216
- let history = config.history;
216
+ const history = config.history;
217
217
  let newTurnsStartIdx = config.initialNewTurnsStartIdx;
218
218
  let executionCount = 0;
219
219
  /** The in-progress assistant row for the CURRENT iteration. Pre-set z
220
- * `preCreatedAssistantMessageId` dla pierwszej iteracji (atomowo utworzony
221
- * w mutacji). Wartość `undefined` przy iteracjach 2+ → loop wywoła
222
- * `startAssistantTurn` jak wcześniej. Closed at the bottom via
223
- * `completeAssistantTurn`. Error handler używa do mark open turn jako
224
- * failed. */
220
+ * `preCreatedAssistantMessageId` dla pierwszej iteracji. Wartość
221
+ * `undefined` przy iteracjach 2+ → loop wywoła `startAssistantTurn`. */
225
222
  let currentTurnId: string | undefined = config.preCreatedAssistantMessageId;
226
223
  /** True gdy w bieżącej iteracji `currentTurnId` był pre-utworzony przez
227
224
  * mutację. Wtedy skipujemy ponowne `startAssistantTurn`. */
228
225
  let usingPreCreatedTurn = config.preCreatedAssistantMessageId != null;
229
- /** Monotonicznie rosnący sequence number na całą sesję — klient po stronie
230
- * React trzyma `lastSeq` i dedupuje. */
231
- let seqCounter = 0;
232
- /** Wrapper na broadcast — wstrzykuje seq + messageId (gdy znany). */
233
- const send = (
234
- evt: Omit<ChatStreamEvent, "seq" | "sessionId"> & {
235
- seq?: number;
236
- sessionId?: string;
237
- },
238
- ) => {
239
- seqCounter += 1;
240
- broadcast(sessionId, {
241
- ...evt,
242
- sessionId,
243
- seq: seqCounter,
244
- messageId: evt.messageId ?? currentTurnId,
245
- } as ChatStreamEvent);
226
+
227
+ /** Pushuje event do in-memory stream-registry per `currentTurnId`. Registry
228
+ * akumuluje `currentBlocks` i broadcast'uje do wszystkich subscriberów. */
229
+ const send = (event: PublishableEvent) => {
230
+ if (!currentTurnId) return;
231
+ publish(currentTurnId, event);
246
232
  };
247
233
 
248
234
  try {
@@ -266,7 +252,7 @@ async function runGenerationLoop(config: RunLoopConfig) {
266
252
 
267
253
  // Open a new in-progress assistant row before the stream starts. The
268
254
  // frontend detects `isGenerating: true` on this row and subscribes to
269
- // the SSE stream identified by `sessionId`.
255
+ // the per-messageId SSE stream.
270
256
  //
271
257
  // Pierwsza iteracja: row już utworzony w mutacji triggerującej (przez
272
258
  // `preCreatedAssistantMessageId`) → skipujemy. Kolejne iteracje
@@ -280,37 +266,11 @@ async function runGenerationLoop(config: RunLoopConfig) {
280
266
  currentTurnId = turnStart.messageId;
281
267
  }
282
268
 
283
- // Snapshot policy co N=20 chunków LUB co T=2s zapisujemy `partialBlocks`
284
- // do DB. Page reload mid-stream klient czyta partial + kontynuuje SSE.
285
- let chunksSinceSnapshot = 0;
286
- let lastSnapshotAt = Date.now();
287
- const SNAPSHOT_EVERY_N = 20;
288
- const SNAPSHOT_EVERY_MS = 2000;
289
- /** Aktualnie budowane bloki — accumulator dla snapshotu. */
290
- const liveBlocks: AssistantContentBlock[] = [];
291
- const liveToolCalls = new Map<
292
- string,
293
- { name: string; argumentsBuffer: string }
294
- >();
295
- const maybeSnapshot = async (force = false) => {
296
- chunksSinceSnapshot += 1;
297
- const due =
298
- force ||
299
- chunksSinceSnapshot >= SNAPSHOT_EVERY_N ||
300
- Date.now() - lastSnapshotAt >= SNAPSHOT_EVERY_MS;
301
- if (!due || !currentTurnId) return;
302
- chunksSinceSnapshot = 0;
303
- lastSnapshotAt = Date.now();
304
- try {
305
- await ctx.mutate(messageElement).saveProgressSnapshot({
306
- messageId: currentTurnId,
307
- partialBlocks: JSON.stringify(liveBlocks),
308
- partialLastSeq: seqCounter,
309
- });
310
- } catch {
311
- // snapshot best-effort — pojawi się przy kolejnym chunku
312
- }
313
- };
269
+ // Open the in-memory stream od teraz `subscribe(currentTurnId)`
270
+ // zwraca live SSE z `init` + zachowanymi chunkami. Idempotent: jeśli
271
+ // klient zdąży się zasubskrybować wcześniej (race), startStream nie
272
+ // robi nic.
273
+ startStream(currentTurnId!);
314
274
 
315
275
  const result = await provider.streamComplete(
316
276
  {
@@ -325,39 +285,18 @@ async function runGenerationLoop(config: RunLoopConfig) {
325
285
  },
326
286
  (chunk) => {
327
287
  if (chunk.type === "text_delta" && chunk.textDelta) {
328
- // accumulate w liveBlocks (last text block lub nowy)
329
- const last = liveBlocks[liveBlocks.length - 1];
330
- if (last && last.type === "text") {
331
- last.text += chunk.textDelta;
332
- } else {
333
- liveBlocks.push({ type: "text", text: chunk.textDelta });
334
- }
335
288
  send({ type: "text_delta", textDelta: chunk.textDelta });
336
- void maybeSnapshot();
337
289
  } else if (chunk.type === "tool_call_started" && chunk.toolCallId) {
338
- liveToolCalls.set(chunk.toolCallId, {
339
- name: chunk.toolCallName ?? "",
340
- argumentsBuffer: "",
341
- });
342
- liveBlocks.push({
343
- type: "tool_call",
344
- id: chunk.toolCallId,
345
- name: chunk.toolCallName ?? "",
346
- arguments: {},
347
- });
348
290
  send({
349
291
  type: "tool_call_pending",
350
292
  toolCallId: chunk.toolCallId,
351
293
  toolCallName: chunk.toolCallName,
352
294
  });
353
- void maybeSnapshot(true);
354
295
  } else if (
355
296
  chunk.type === "tool_call_arguments_delta" &&
356
297
  chunk.toolCallId &&
357
298
  chunk.argumentsDelta
358
299
  ) {
359
- const tc = liveToolCalls.get(chunk.toolCallId);
360
- if (tc) tc.argumentsBuffer += chunk.argumentsDelta;
361
300
  send({
362
301
  type: "tool_call_arguments_delta",
363
302
  toolCallId: chunk.toolCallId,
@@ -367,27 +306,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
367
306
  chunk.type === "tool_call_arguments_complete" &&
368
307
  chunk.toolCallId
369
308
  ) {
370
- // update accumulated block z complete args
371
- const args = chunk.arguments ?? {};
372
- const block = liveBlocks.find(
373
- (b): b is Extract<AssistantContentBlock, { type: "tool_call" }> =>
374
- b.type === "tool_call" && b.id === chunk.toolCallId,
375
- );
376
- if (block) block.arguments = args;
377
- // toolCallName z liveBlocks (provider zna nazwę od tool_call_started)
378
- // — bez tego klient pushuje tool z `toolName: ""` i nie znajduje
379
- // viewComponent w toolsMap → fallback do generic ChatToolLog
380
- // ("Wykonuję..."), AskQuestionsView nigdy nie mountuje się.
381
- const toolCallName =
382
- liveToolCalls.get(chunk.toolCallId)?.name ?? block?.name;
383
309
  send({
384
310
  type: "tool_call_arguments_complete",
385
311
  toolCallId: chunk.toolCallId,
386
- toolCallName,
387
- arguments: args,
312
+ toolCallName: chunk.toolCallName,
313
+ arguments: chunk.arguments ?? {},
388
314
  });
389
- void maybeSnapshot(true);
390
- } else if (chunk.type === "usage_update") {
315
+ } else if (chunk.type === "usage_update" && chunk.usage) {
391
316
  send({ type: "usage_update", usage: chunk.usage });
392
317
  }
393
318
  },
@@ -412,47 +337,47 @@ async function runGenerationLoop(config: RunLoopConfig) {
412
337
  const hasToolCalls =
413
338
  result.finishReason === "tool_call" && toolCalls.length > 0;
414
339
 
415
- // Close the turn row — same row that was opened above. The final turn
416
- // (no tool calls) carries the usage; intermediate turns carry only the
417
- // blocks + responseId.
340
+ // Close the turn row — same row that was opened above. Final blocks
341
+ // are the SINGLE persistent write of message content in this turn.
342
+ // Intermediate turns (gdy mamy tool calls) carry blocks + responseId;
343
+ // ostatni turn nosi też usage.
418
344
  await ctx.mutate(messageElement).completeAssistantTurn({
419
345
  messageId: currentTurnId!,
420
346
  blocks: JSON.stringify(result.blocks),
421
347
  previousResponseId: result.responseId,
422
348
  usage: hasToolCalls ? undefined : JSON.stringify(result.usage),
423
349
  });
350
+
351
+ // Tear down the in-memory stream: broadcast `done` do subscriberów,
352
+ // close controllery, drop registry entry po grace window. Klient z
353
+ // `done` flippuje isStreaming=false i renderuje final blocks z DB.
354
+ finalize(
355
+ currentTurnId!,
356
+ hasToolCalls
357
+ ? undefined
358
+ : {
359
+ usage: result.usage,
360
+ finishReason: result.finishReason,
361
+ executionCount,
362
+ },
363
+ );
424
364
  currentTurnId = undefined;
425
365
 
426
- if (!hasToolCalls) {
427
- send({
428
- type: "done",
429
- usage: result.usage,
430
- finishReason: result.finishReason,
431
- executionCount,
432
- lastSeq: seqCounter,
433
- });
434
- endStream(sessionId);
435
- return;
436
- }
366
+ if (!hasToolCalls) return;
437
367
 
438
368
  const serverCalls = toolCalls.filter((tc) => serverToolsMap.has(tc.name));
439
369
  const interactiveCalls = toolCalls.filter((tc) =>
440
370
  interactiveToolNames.has(tc.name),
441
371
  );
442
372
 
443
- // Execute server tools — append each result to history as a separate turn
373
+ // Execute server tools — append each result to history as a separate turn.
374
+ // Note: ten turn (`finalize`d powyżej) już jest zamknięty, więc kolejne
375
+ // `send()` byłyby no-opem. Server-tool execution emit'uje eventy poprzez
376
+ // mutację `saveToolResult` (która tworzy tool_result row w DB) — klient
377
+ // dostaje je via aggregate query update. Nie korzystamy ze stream-registry
378
+ // dla tool execution.
444
379
  const newToolResults: ConversationTurn[] = [];
445
380
  for (const tc of serverCalls) {
446
- // `tool_call_pending` poszło już ze streamingu (przy `started`).
447
- // Teraz `executing` po stronie servera.
448
- send({
449
- type: "tool_call_arguments_complete",
450
- toolCallId: tc.id,
451
- toolCallName: tc.name,
452
- arguments: tc.arguments,
453
- executionCount,
454
- });
455
-
456
381
  const tool = serverToolsMap.get(tc.name);
457
382
  let resultContent: string;
458
383
  let isError = false;
@@ -482,19 +407,6 @@ async function runGenerationLoop(config: RunLoopConfig) {
482
407
  isError,
483
408
  });
484
409
 
485
- send({
486
- type: "tool_call_executed",
487
- toolCallId: tc.id,
488
- toolCallName: tc.name,
489
- toolResult: {
490
- toolCallId: tc.id,
491
- name: tc.name,
492
- content: resultContent,
493
- isError,
494
- },
495
- executionCount,
496
- });
497
-
498
410
  newToolResults.push({
499
411
  role: "tool_result",
500
412
  toolCallId: tc.id,
@@ -505,16 +417,9 @@ async function runGenerationLoop(config: RunLoopConfig) {
505
417
  }
506
418
 
507
419
  // Interactive tools — stop the loop, wait for userResponded.
508
- // The assistant turn (with the interactive tool_call) is already
509
- // persisted above. Listener B will resume.
510
- if (interactiveCalls.length > 0) {
511
- send({
512
- type: "interactive_tool_request",
513
- toolCalls: interactiveCalls,
514
- executionCount,
515
- });
516
- return;
517
- }
420
+ // The assistant turn (with the interactive tool_call) was already
421
+ // finalized above. Listener B (resume) will create a fresh turn.
422
+ if (interactiveCalls.length > 0) return;
518
423
 
519
424
  // Append tool results to history; mark them as the "new turns" for the
520
425
  // next iteration's continuation request.
@@ -526,12 +431,12 @@ async function runGenerationLoop(config: RunLoopConfig) {
526
431
  }
527
432
  } catch (err) {
528
433
  const errorMsg = `AI error: ${err instanceof Error ? err.message : String(err)}`;
529
- send({
530
- type: "error",
531
- error: errorMsg,
532
- executionCount,
533
- });
534
434
  if (currentTurnId) {
435
+ publish(currentTurnId, {
436
+ type: "error",
437
+ error: errorMsg,
438
+ executionCount,
439
+ });
535
440
  try {
536
441
  await ctx.mutate(messageElement).completeAssistantTurn({
537
442
  messageId: currentTurnId,
@@ -539,8 +444,8 @@ async function runGenerationLoop(config: RunLoopConfig) {
539
444
  error: errorMsg,
540
445
  });
541
446
  } catch {}
447
+ finalize(currentTurnId, { error: errorMsg, executionCount });
542
448
  }
543
- endStream(sessionId);
544
449
  }
545
450
  }
546
451
 
@@ -579,8 +484,6 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
579
484
  scopeId,
580
485
  content: userContent,
581
486
  model: modelName,
582
- role,
583
- assistantMessageId,
584
487
  } = event.payload as any;
585
488
 
586
489
  const model = modelName ?? "gpt-5";
@@ -613,9 +516,6 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
613
516
  maxExecutionCount,
614
517
  toolChoice: config.toolChoice,
615
518
  instruction,
616
- // Pre-utworzony empty assistant row z mutacji `sendMessage`/
617
- // `systemMessage`/`startStage` — pierwsza iteracja używa go zamiast
618
- // wołać `startAssistantTurn`.
619
519
  preCreatedAssistantMessageId: (
620
520
  event.payload as { assistantMessageId?: string }
621
521
  ).assistantMessageId,
@@ -653,13 +553,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
653
553
  .query([messageElement, ...allQueryElements])
654
554
  .mutate([messageElement, ...allMutationElements])
655
555
  .handle(async (ctx, event) => {
656
- const {
657
- sessionId,
658
- scopeId,
659
- toolCallId,
660
- toolName,
661
- content: toolResult,
662
- } = event.payload;
556
+ const { sessionId, scopeId, toolCallId } = event.payload;
663
557
 
664
558
  const dbMessages = await ctx
665
559
  .query(messageElement)
@@ -670,8 +564,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
670
564
  const history = buildHistory(dbMessages);
671
565
 
672
566
  // Compute "new turns start" — index of the just-arrived tool_result.
673
- // Anything before it is "already known" (assistant emitted the matching
674
- // tool_call earlier and OpenAI has it server-side).
675
567
  let newTurnsStartIdx = history.length;
676
568
  for (let i = history.length - 1; i >= 0; i--) {
677
569
  const t = history[i];
@@ -681,7 +573,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
681
573
  }
682
574
  }
683
575
 
684
- // Determine the model from the most recent assistant row in DB
685
576
  const lastAssistantRow = [...dbMessages]
686
577
  .reverse()
687
578
  .find((m: any) => m.role === "assistant" && m.model);
@@ -690,9 +581,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
690
581
  const provider = resolveProvider(model, scopeId);
691
582
  if (!provider) return;
692
583
 
693
- void toolName;
694
- void toolResult;
695
-
696
584
  await runGenerationLoop({
697
585
  ctx,
698
586
  messageElement,
@@ -708,10 +596,99 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
708
596
  maxExecutionCount,
709
597
  toolChoice: config.toolChoice,
710
598
  instruction,
711
- // Pre-utworzony empty assistant row z mutacji `respondToTool`.
712
599
  preCreatedAssistantMessageId: (
713
600
  event.payload as { assistantMessageId?: string }
714
601
  ).assistantMessageId,
715
602
  });
716
603
  });
717
604
  }
605
+
606
+ // ─── Listener C: retryRequested → AI retry ──────────────────────
607
+
608
+ /**
609
+ * Reaguje na `retryRequested` emit'owany przez mutację `retryGeneration`
610
+ * (klient klika Retry po SSE 410). Interrupted assistant row jest już
611
+ * usunięty z DB przez projection; w event payload mamy fresh
612
+ * `preCreatedAssistantMessageId`. Listener buduje historię z DB (kończy
613
+ * się na ostatniej user message, bo fresh assistant ma `isGenerating=true`
614
+ * bez `blocks` → skip'owany przez `buildHistory`) i odpala generation loop.
615
+ */
616
+ export function createAiRetryListener(config: AiGenerationListenerConfig) {
617
+ const {
618
+ name,
619
+ messageElement,
620
+ resolveProvider,
621
+ instruction,
622
+ serverTools,
623
+ interactiveTools,
624
+ allQueryElements,
625
+ allMutationElements,
626
+ maxExecutionCount,
627
+ } = config;
628
+
629
+ const retryRequestedEvent = messageElement.getEvent("retryRequested");
630
+ const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
631
+ const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
632
+ const allToolsForLLM = [...serverTools, ...interactiveTools];
633
+ const toolDefs =
634
+ allToolsForLLM.length > 0
635
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
636
+ : undefined;
637
+
638
+ return listener(`${name}AiRetry`)
639
+ .listenTo([retryRequestedEvent])
640
+ .async()
641
+ .query([messageElement, ...allQueryElements])
642
+ .mutate([messageElement, ...allMutationElements])
643
+ .handle(async (ctx, event) => {
644
+ const {
645
+ messageId: assistantMsgId,
646
+ sessionId,
647
+ scopeId,
648
+ model: modelName,
649
+ } = event.payload as any;
650
+
651
+ const dbMessages = await ctx
652
+ .query(messageElement)
653
+ .getByScope({ scopeId });
654
+
655
+ const history = buildHistory(dbMessages);
656
+
657
+ // Find the last user turn — that's the boundary for "new turns" so the
658
+ // continuation request only sends it as delta. Pre-existing assistant
659
+ // turns w historii zatrzymują się przed nią.
660
+ let newTurnsStartIdx = history.length;
661
+ for (let i = history.length - 1; i >= 0; i--) {
662
+ if (history[i].role === "user") {
663
+ newTurnsStartIdx = i;
664
+ break;
665
+ }
666
+ }
667
+
668
+ const lastAssistantRow = [...dbMessages]
669
+ .reverse()
670
+ .find((m: any) => m.role === "assistant" && m.model);
671
+ const model = modelName ?? lastAssistantRow?.model ?? "gpt-5";
672
+
673
+ const provider = resolveProvider(model, scopeId);
674
+ if (!provider) return;
675
+
676
+ await runGenerationLoop({
677
+ ctx,
678
+ messageElement,
679
+ provider,
680
+ model,
681
+ history,
682
+ initialNewTurnsStartIdx: newTurnsStartIdx,
683
+ toolDefs,
684
+ serverToolsMap,
685
+ interactiveToolNames,
686
+ scopeId,
687
+ sessionId,
688
+ maxExecutionCount,
689
+ toolChoice: config.toolChoice,
690
+ instruction,
691
+ preCreatedAssistantMessageId: assistantMsgId,
692
+ });
693
+ });
694
+ }