arcane-os 0.3.4 → 0.3.6

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 (35) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -7
  3. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +315 -119
  4. package/browser-runtime/ai/model-controller.mjs +439 -95
  5. package/package.json +1 -1
  6. package/runtime/arcane/components/assistant-panel.html +2 -1
  7. package/runtime/arcane/components/chat.html +469 -220
  8. package/runtime/arcane/components/speech.html +33 -13
  9. package/runtime/arcane/components/voice-transcription.html +27 -3
  10. package/runtime/arcane/entities/Chat.js +165 -97
  11. package/runtime/arcane/entities/IntentEnvelope.js +52 -118
  12. package/runtime/arcane/entities/TWiNPolicyDecision.js +33 -130
  13. package/runtime/arcane/entities/User.js +38 -44
  14. package/runtime/arcane/modules/AI.js +569 -302
  15. package/runtime/arcane/modules/AIProviderRuntime.js +776 -151
  16. package/runtime/arcane/modules/AIRuntimeState.js +22 -24
  17. package/runtime/arcane/modules/ApiModelDatabase.js +54 -48
  18. package/runtime/arcane/modules/CaseEvidenceIndexer.js +6 -10
  19. package/runtime/arcane/modules/CommunicationHub.js +90 -94
  20. package/runtime/arcane/modules/ComponentContracts.js +23 -9
  21. package/runtime/arcane/modules/ConfiguredAIChatSession.js +77 -77
  22. package/runtime/arcane/modules/ConversationTimebox.js +76 -104
  23. package/runtime/arcane/modules/DBLS.js +14 -12
  24. package/runtime/arcane/modules/DBOPFS.js +20 -15
  25. package/runtime/arcane/modules/Errors.js +196 -436
  26. package/runtime/arcane/modules/HTMLImport.js +49 -32
  27. package/runtime/arcane/modules/Ollama.js +16 -14
  28. package/runtime/arcane/modules/PersistentAIChatSession.js +174 -93
  29. package/runtime/arcane/modules/RecordReviewStore.js +40 -35
  30. package/runtime/arcane/modules/TerminalClient.js +12 -14
  31. package/runtime/arcane/modules/ThemeBootstrap.js +7 -7
  32. package/runtime/arcane/modules/ThemeManager.js +5 -5
  33. package/runtime/arcane/modules/TimeGuard.js +5 -65
  34. package/runtime/arcane/modules/WaitForComponent.js +43 -40
  35. package/src/installed-sdk-runtime.mjs +11 -1
@@ -236,6 +236,8 @@ function validateLLMMessageToolCalls(message, label) {
236
236
  );
237
237
  }
238
238
  if (Object.hasOwn(message, 'toolCalls')
239
+ || Object.hasOwn(message, 'tool_call')
240
+ || Object.hasOwn(message, 'toolCall')
239
241
  || Object.hasOwn(message, 'function_call')
240
242
  || Object.hasOwn(message, 'functionCall')) {
241
243
  fail(
@@ -254,17 +256,19 @@ function validateLLMMessageToolCalls(message, label) {
254
256
  'ARCANE_AI_TOOL_CALL_INVALID'
255
257
  );
256
258
  }
257
- if (descriptor.value.length > 1) {
258
- fail(
259
- 'The Arcane AI runtime accepts one structural tool call at a time.',
260
- 'ARCANE_AI_PARALLEL_TOOLS_UNSUPPORTED'
261
- );
262
- }
259
+ const ids = new Set();
263
260
  for (let index = 0; index < descriptor.value.length; index += 1) {
264
- validateLLMToolCall(
261
+ const id = validateLLMToolCall(
265
262
  descriptor.value[index],
266
263
  `${label}.tool_calls[${index}]`
267
264
  );
265
+ if (ids.has(id)) {
266
+ fail(
267
+ `${label}.tool_calls contains a duplicate structural tool-call ID.`,
268
+ 'ARCANE_AI_TOOL_CALL_INVALID'
269
+ );
270
+ }
271
+ ids.add(id);
268
272
  }
269
273
  return descriptor.value;
270
274
  }
@@ -274,7 +278,7 @@ function validateLLMRequestPayload(payload) {
274
278
  if (!Array.isArray(payload.messages)) {
275
279
  fail('AI LLM request payload.messages must be an array.');
276
280
  }
277
- let pendingToolCallId = null;
281
+ const pendingToolCallIds = new Set();
278
282
  for (let index = 0; index < payload.messages.length; index += 1) {
279
283
  const message = payload.messages[index];
280
284
  if (!isPlainRecord(message)) {
@@ -293,13 +297,21 @@ function validateLLMRequestPayload(payload) {
293
297
  );
294
298
  let openedToolCall = false;
295
299
  if (calls.length) {
296
- if (pendingToolCallId !== null) {
300
+ if (pendingToolCallIds.size) {
297
301
  fail(
298
- 'The Arcane AI runtime accepts one structural tool call at a time.',
299
- 'ARCANE_AI_PARALLEL_TOOLS_UNSUPPORTED'
302
+ 'Every pending structural tool result must be supplied before another assistant tool-call sequence.',
303
+ 'ARCANE_AI_TOOL_RESULT_REQUIRED'
300
304
  );
301
305
  }
302
- pendingToolCallId = calls[0].id;
306
+ for (const call of calls) {
307
+ if (pendingToolCallIds.has(call.id)) {
308
+ fail(
309
+ 'An assistant structural tool-call sequence contains a duplicate ID.',
310
+ 'ARCANE_AI_TOOL_CALL_INVALID'
311
+ );
312
+ }
313
+ pendingToolCallIds.add(call.id);
314
+ }
303
315
  openedToolCall = true;
304
316
  }
305
317
  if (message.role === 'tool') {
@@ -310,15 +322,15 @@ function validateLLMRequestPayload(payload) {
310
322
  'ARCANE_AI_INVALID_TOOL_MESSAGE'
311
323
  );
312
324
  }
313
- if (pendingToolCallId === null
325
+ if (!pendingToolCallIds.size
314
326
  || typeof message.tool_call_id !== 'string'
315
- || message.tool_call_id !== pendingToolCallId) {
327
+ || !pendingToolCallIds.has(message.tool_call_id)) {
316
328
  fail(
317
329
  `AI LLM request payload.messages[${index}] does not settle the pending structural tool call.`,
318
330
  'ARCANE_AI_INVALID_TOOL_MESSAGE'
319
331
  );
320
332
  }
321
- pendingToolCallId = null;
333
+ pendingToolCallIds.delete(message.tool_call_id);
322
334
  } else {
323
335
  if (Object.hasOwn(message, 'tool_call_id')) {
324
336
  fail(
@@ -326,7 +338,7 @@ function validateLLMRequestPayload(payload) {
326
338
  'ARCANE_AI_INVALID_TOOL_MESSAGE'
327
339
  );
328
340
  }
329
- if (pendingToolCallId !== null && !openedToolCall) {
341
+ if (pendingToolCallIds.size && !openedToolCall) {
330
342
  fail(
331
343
  `AI LLM request payload.messages[${index}] precedes the pending structural tool result.`,
332
344
  'ARCANE_AI_TOOL_RESULT_REQUIRED'
@@ -334,7 +346,7 @@ function validateLLMRequestPayload(payload) {
334
346
  }
335
347
  }
336
348
  }
337
- if (pendingToolCallId !== null) {
349
+ if (pendingToolCallIds.size) {
338
350
  fail(
339
351
  'The pending structural tool call must be settled before requesting another response.',
340
352
  'ARCANE_AI_TOOL_RESULT_REQUIRED'
@@ -345,13 +357,10 @@ function validateLLMRequestPayload(payload) {
345
357
  payload.parallelToolCalls,
346
358
  payload.parallel_tool_calls
347
359
  ];
348
- if (parallelValues.some(function enablesParallelLLMTools(value) {
349
- return value !== undefined && value !== false;
360
+ if (parallelValues.some(function invalidParallelLLMToolPreference(value) {
361
+ return value !== undefined && typeof value !== 'boolean';
350
362
  })) {
351
- fail(
352
- 'The Arcane AI runtime accepts one structural tool call at a time.',
353
- 'ARCANE_AI_PARALLEL_TOOLS_UNSUPPORTED'
354
- );
363
+ fail('AI LLM parallel tool-call preferences must be boolean when provided.');
355
364
  }
356
365
  }
357
366
 
@@ -404,7 +413,6 @@ function validateLLMTerminalResult(value, label) {
404
413
  );
405
414
  }
406
415
  const indexes = new Set();
407
- let totalToolCalls = 0;
408
416
  for (let position = 0; position < choicesDescriptor.value.length; position += 1) {
409
417
  const choice = choicesDescriptor.value[position];
410
418
  if (!isPlainRecord(choice)
@@ -424,56 +432,601 @@ function validateLLMTerminalResult(value, label) {
424
432
  'ARCANE_AI_INVALID_PROVIDER_RESULT'
425
433
  );
426
434
  }
427
- const calls = validateLLMTerminalMessage(
435
+ validateLLMTerminalMessage(
428
436
  messageDescriptor.value,
429
437
  `${label}.choices[${position}].message`
430
438
  );
431
- if (position > 0 && calls.length) {
432
- fail(
433
- `${label} placed a structural tool call outside the selected first choice.`,
434
- 'ARCANE_AI_INVALID_PROVIDER_RESULT'
439
+ }
440
+ return value;
441
+ }
442
+
443
+ function llmStreamToolCallMismatch(message, cause) {
444
+ return operationError(
445
+ message,
446
+ 'ARCANE_AI_STREAM_TOOL_CALL_MISMATCH',
447
+ cause
448
+ );
449
+ }
450
+
451
+ function canonicalLLMToolCall(call) {
452
+ return {
453
+ id: call.id,
454
+ type: call.type,
455
+ function: {
456
+ name: call.function.name,
457
+ arguments: call.function.arguments
458
+ }
459
+ };
460
+ }
461
+
462
+ function copyLLMDataValue(value, seen = new Map()) {
463
+ if (!value || typeof value !== 'object') {
464
+ return value;
465
+ }
466
+ if (seen.has(value)) {
467
+ return seen.get(value);
468
+ }
469
+ if (!Array.isArray(value) && !isPlainRecord(value)) {
470
+ return value;
471
+ }
472
+ const result = Array.isArray(value)
473
+ ? new Array(value.length)
474
+ : Object.create(Object.getPrototypeOf(value));
475
+ seen.set(value, result);
476
+ const descriptors = Object.getOwnPropertyDescriptors(value);
477
+ for (const key of Reflect.ownKeys(descriptors)) {
478
+ if (Array.isArray(value) && key === 'length') {
479
+ continue;
480
+ }
481
+ const descriptor = descriptors[key];
482
+ if (Object.hasOwn(descriptor, 'value')) {
483
+ descriptor.value = copyLLMDataValue(descriptor.value, seen);
484
+ }
485
+ Object.defineProperty(result, key, descriptor);
486
+ }
487
+ return result;
488
+ }
489
+
490
+ function sameLLMDataValue(left, right, seen = new Map()) {
491
+ if (Object.is(left, right)) {
492
+ return true;
493
+ }
494
+ if (!left || !right
495
+ || typeof left !== 'object'
496
+ || typeof right !== 'object'
497
+ || Array.isArray(left) !== Array.isArray(right)) {
498
+ return false;
499
+ }
500
+ if (!Array.isArray(left)
501
+ && (!isPlainRecord(left) || !isPlainRecord(right))) {
502
+ return false;
503
+ }
504
+ const matched = seen.get(left);
505
+ if (matched !== undefined) {
506
+ return matched === right;
507
+ }
508
+ seen.set(left, right);
509
+ const leftDescriptors = Object.getOwnPropertyDescriptors(left);
510
+ const rightDescriptors = Object.getOwnPropertyDescriptors(right);
511
+ const leftKeys = Reflect.ownKeys(leftDescriptors);
512
+ const rightKeys = Reflect.ownKeys(rightDescriptors);
513
+ if (leftKeys.length !== rightKeys.length) {
514
+ return false;
515
+ }
516
+ for (const key of leftKeys) {
517
+ if (!Object.hasOwn(rightDescriptors, key)) {
518
+ return false;
519
+ }
520
+ const leftDescriptor = leftDescriptors[key];
521
+ const rightDescriptor = rightDescriptors[key];
522
+ const leftIsData = Object.hasOwn(leftDescriptor, 'value');
523
+ const rightIsData = Object.hasOwn(rightDescriptor, 'value');
524
+ if (leftIsData !== rightIsData) {
525
+ return false;
526
+ }
527
+ if (leftIsData) {
528
+ if (!sameLLMDataValue(
529
+ leftDescriptor.value,
530
+ rightDescriptor.value,
531
+ seen
532
+ )) {
533
+ return false;
534
+ }
535
+ } else if (leftDescriptor.get !== rightDescriptor.get
536
+ || leftDescriptor.set !== rightDescriptor.set) {
537
+ return false;
538
+ }
539
+ }
540
+ return true;
541
+ }
542
+
543
+ function sameCanonicalLLMToolCalls(left, right) {
544
+ return left.length === right.length
545
+ && left.every(function sameCanonicalLLMToolCall(call, index) {
546
+ const other = right[index];
547
+ return call.id === other?.id
548
+ && call.type === other?.type
549
+ && call.function.name === other?.function?.name
550
+ && call.function.arguments === other?.function?.arguments;
551
+ });
552
+ }
553
+
554
+ function terminalLLMToolCallRecord(choiceIndex, completeCalls) {
555
+ return {
556
+ choiceIndex,
557
+ completeCalls,
558
+ canonicalCalls: completeCalls.map(canonicalLLMToolCall)
559
+ };
560
+ }
561
+
562
+ function terminalLLMToolCalls(value) {
563
+ if (typeof value === 'string') {
564
+ return {direct: terminalLLMToolCallRecord(null, []), choices: []};
565
+ }
566
+ if (Object.hasOwn(value, 'message')) {
567
+ const completeCalls = Array.isArray(value.message?.tool_calls)
568
+ ? value.message.tool_calls
569
+ : [];
570
+ return {
571
+ direct: terminalLLMToolCallRecord(null, completeCalls),
572
+ choices: []
573
+ };
574
+ }
575
+ return {
576
+ direct: null,
577
+ choices: value.choices.map(function retainTerminalLLMChoice(choice) {
578
+ const completeCalls = Array.isArray(choice.message?.tool_calls)
579
+ ? choice.message.tool_calls
580
+ : [];
581
+ return terminalLLMToolCallRecord(choice.index, completeCalls);
582
+ })
583
+ };
584
+ }
585
+
586
+ function createLLMStreamToolCallCorrelation() {
587
+ const choices = new Map();
588
+ const choiceOrder = [];
589
+ let direct = null;
590
+
591
+ function createRecord(choiceIndex = null) {
592
+ return {
593
+ choiceIndex,
594
+ completeCalls: null,
595
+ fragments: new Map(),
596
+ seen: false,
597
+ observed: false
598
+ };
599
+ }
600
+
601
+ function directRecord() {
602
+ if (!direct) {
603
+ direct = createRecord();
604
+ }
605
+ return direct;
606
+ }
607
+
608
+ function choiceRecord(index, hasExplicitIndex) {
609
+ const key = hasExplicitIndex
610
+ ? `index:${index}`
611
+ : `position:${index}`;
612
+ if (!choices.has(key)) {
613
+ const record = createRecord(hasExplicitIndex ? index : null);
614
+ choices.set(key, record);
615
+ choiceOrder.push(record);
616
+ }
617
+ return choices.get(key);
618
+ }
619
+
620
+ function structuralToolCalls(source, label) {
621
+ if (!isPlainRecord(source)) {
622
+ return null;
623
+ }
624
+ if (Object.hasOwn(source, 'toolCalls')
625
+ || Object.hasOwn(source, 'tool_call')
626
+ || Object.hasOwn(source, 'toolCall')
627
+ || Object.hasOwn(source, 'function_call')
628
+ || Object.hasOwn(source, 'functionCall')) {
629
+ throw llmStreamToolCallMismatch(
630
+ `${label} contains noncanonical structural tool-call data.`
435
631
  );
436
632
  }
437
- totalToolCalls += calls.length;
438
- if (totalToolCalls > 1) {
439
- fail(
440
- 'The Arcane AI runtime accepts one structural tool call at a time.',
441
- 'ARCANE_AI_PARALLEL_TOOLS_UNSUPPORTED'
633
+ if (!Object.hasOwn(source, 'tool_calls')) {
634
+ return null;
635
+ }
636
+ const descriptor = Object.getOwnPropertyDescriptor(source, 'tool_calls');
637
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')
638
+ || !Array.isArray(descriptor.value)) {
639
+ throw llmStreamToolCallMismatch(
640
+ `${label}.tool_calls must be an array data property.`
442
641
  );
443
642
  }
643
+ return descriptor.value;
444
644
  }
445
- return value;
645
+
646
+ function observeCompleteMessage(message, record, label) {
647
+ const calls = structuralToolCalls(message, label);
648
+ if (!calls?.length) {
649
+ return;
650
+ }
651
+ let completeCalls;
652
+ try {
653
+ completeCalls = validateLLMMessageToolCalls(message, label)
654
+ .map(function retainCompleteLLMStreamToolCall(call) {
655
+ return copyLLMDataValue(call);
656
+ });
657
+ } catch (cause) {
658
+ throw llmStreamToolCallMismatch(
659
+ `${label} did not contain complete structural tool calls.`,
660
+ cause
661
+ );
662
+ }
663
+ if (record.completeCalls
664
+ && !sameLLMDataValue(record.completeCalls, completeCalls)) {
665
+ throw llmStreamToolCallMismatch(
666
+ `${label} changed its complete structural tool calls during streaming.`
667
+ );
668
+ }
669
+ record.completeCalls = completeCalls;
670
+ record.observed = true;
671
+ }
672
+
673
+ function observeFragments(source, record, label) {
674
+ const calls = structuralToolCalls(source, label);
675
+ if (!calls?.length) {
676
+ return;
677
+ }
678
+ record.observed = true;
679
+ for (let position = 0; position < calls.length; position += 1) {
680
+ const fragment = calls[position];
681
+ if (!isPlainRecord(fragment)) {
682
+ throw llmStreamToolCallMismatch(
683
+ `${label}.tool_calls[${position}] must be a structural fragment object.`
684
+ );
685
+ }
686
+ if (fragment.index !== undefined
687
+ && (!Number.isSafeInteger(fragment.index) || fragment.index < 0)) {
688
+ throw llmStreamToolCallMismatch(
689
+ `${label}.tool_calls[${position}] has an invalid structural call index.`
690
+ );
691
+ }
692
+ const toolIndex = fragment.index ?? position;
693
+ const retained = record.fragments.get(toolIndex) ?? {
694
+ index: toolIndex,
695
+ id: '',
696
+ type: '',
697
+ name: '',
698
+ arguments: '',
699
+ invalid: false
700
+ };
701
+ if (fragment.id !== undefined) {
702
+ if (typeof fragment.id !== 'string'
703
+ || !fragment.id
704
+ || (retained.id && retained.id !== fragment.id)) {
705
+ retained.invalid = true;
706
+ } else {
707
+ retained.id = fragment.id;
708
+ }
709
+ }
710
+ if (fragment.type !== undefined) {
711
+ if (typeof fragment.type !== 'string'
712
+ || !fragment.type
713
+ || (retained.type && retained.type !== fragment.type)) {
714
+ retained.invalid = true;
715
+ } else {
716
+ retained.type = fragment.type;
717
+ }
718
+ }
719
+ if (fragment.function !== undefined
720
+ && !isPlainRecord(fragment.function)) {
721
+ retained.invalid = true;
722
+ }
723
+ const functionFragment = isPlainRecord(fragment.function)
724
+ ? fragment.function
725
+ : {};
726
+ if (functionFragment.name !== undefined) {
727
+ if (typeof functionFragment.name !== 'string') {
728
+ retained.invalid = true;
729
+ } else {
730
+ retained.name += functionFragment.name;
731
+ }
732
+ }
733
+ if (functionFragment.arguments !== undefined) {
734
+ if (typeof functionFragment.arguments !== 'string') {
735
+ retained.invalid = true;
736
+ } else {
737
+ retained.arguments += functionFragment.arguments;
738
+ }
739
+ }
740
+ record.fragments.set(toolIndex, retained);
741
+ }
742
+ }
743
+
744
+ function observeChoice(choice, position) {
745
+ if (!isPlainRecord(choice)) {
746
+ return;
747
+ }
748
+ const delta = isPlainRecord(choice.delta) ? choice.delta : null;
749
+ const message = isPlainRecord(choice.message) ? choice.message : null;
750
+ const hasStructuralData = [choice, delta, message].some(
751
+ function hasChoiceStructuralData(source) {
752
+ return source && (
753
+ Object.hasOwn(source, 'tool_calls')
754
+ || Object.hasOwn(source, 'toolCalls')
755
+ || Object.hasOwn(source, 'tool_call')
756
+ || Object.hasOwn(source, 'toolCall')
757
+ || Object.hasOwn(source, 'function_call')
758
+ || Object.hasOwn(source, 'functionCall')
759
+ );
760
+ }
761
+ );
762
+ if (choice.index !== undefined
763
+ && (!Number.isSafeInteger(choice.index) || choice.index < 0)) {
764
+ throw llmStreamToolCallMismatch(
765
+ `AI provider stream choice ${position} has an invalid index.`
766
+ );
767
+ }
768
+ const hasExplicitIndex = choice.index !== undefined;
769
+ const index = choice.index ?? position;
770
+ const record = choiceRecord(index, hasExplicitIndex);
771
+ record.seen = true;
772
+ if (!hasStructuralData) {
773
+ return;
774
+ }
775
+ observeFragments(choice, record, `AI provider stream choice ${position}`);
776
+ if (delta) {
777
+ observeFragments(
778
+ delta,
779
+ record,
780
+ `AI provider stream choice ${position}.delta`
781
+ );
782
+ }
783
+ if (message) {
784
+ observeCompleteMessage(
785
+ message,
786
+ record,
787
+ `AI provider stream choice ${position}.message`
788
+ );
789
+ }
790
+ }
791
+
792
+ function observe(chunk) {
793
+ if (!isPlainRecord(chunk)) {
794
+ return;
795
+ }
796
+ const record = directRecord();
797
+ observeFragments(chunk, record, 'AI provider stream chunk');
798
+ if (isPlainRecord(chunk.delta)) {
799
+ observeFragments(
800
+ chunk.delta,
801
+ record,
802
+ 'AI provider stream chunk.delta'
803
+ );
804
+ }
805
+ if (isPlainRecord(chunk.message)) {
806
+ observeCompleteMessage(
807
+ chunk.message,
808
+ record,
809
+ 'AI provider stream chunk.message'
810
+ );
811
+ }
812
+ if (Array.isArray(chunk.choices)) {
813
+ for (let position = 0; position < chunk.choices.length; position += 1) {
814
+ observeChoice(chunk.choices[position], position);
815
+ }
816
+ }
817
+ }
818
+
819
+ function completedCalls(record, label) {
820
+ if (!record?.observed) {
821
+ return null;
822
+ }
823
+ let fragmentCalls = null;
824
+ if (record.fragments.size) {
825
+ const orderedFragments = [...record.fragments.values()]
826
+ .sort(function orderLLMStreamToolCalls(left, right) {
827
+ return left.index - right.index;
828
+ });
829
+ for (let index = 0; index < orderedFragments.length; index += 1) {
830
+ if (orderedFragments[index].index !== index) {
831
+ throw llmStreamToolCallMismatch(
832
+ `${label} omitted an ordered structural tool-call index.`
833
+ );
834
+ }
835
+ }
836
+ fragmentCalls = orderedFragments.map(
837
+ function completeLLMStreamToolCall(fragment, index) {
838
+ if (fragment.invalid) {
839
+ throw llmStreamToolCallMismatch(
840
+ `${label} structural tool call ${index} changed an exact field.`
841
+ );
842
+ }
843
+ const call = {
844
+ id: fragment.id,
845
+ type: fragment.type,
846
+ function: {
847
+ name: fragment.name,
848
+ arguments: fragment.arguments
849
+ }
850
+ };
851
+ try {
852
+ validateLLMToolCall(
853
+ call,
854
+ `${label} structural tool call ${index}`
855
+ );
856
+ } catch (cause) {
857
+ throw llmStreamToolCallMismatch(
858
+ `${label} did not retain a complete structural tool call.`,
859
+ cause
860
+ );
861
+ }
862
+ return call;
863
+ }
864
+ );
865
+ }
866
+ if (record.completeCalls
867
+ && fragmentCalls
868
+ && !sameCanonicalLLMToolCalls(record.completeCalls, fragmentCalls)) {
869
+ throw llmStreamToolCallMismatch(
870
+ `${label} complete and fragmented structural tool calls do not match.`
871
+ );
872
+ }
873
+ return {
874
+ completeCalls: record.completeCalls,
875
+ fragmentCalls
876
+ };
877
+ }
878
+
879
+ function assertRecordMatchesTerminal(record, terminal, label) {
880
+ const streamedCalls = completedCalls(record, label);
881
+ if (!streamedCalls) {
882
+ return;
883
+ }
884
+ if (streamedCalls.completeCalls
885
+ && !sameLLMDataValue(
886
+ streamedCalls.completeCalls,
887
+ terminal.completeCalls
888
+ )) {
889
+ throw llmStreamToolCallMismatch(
890
+ `${label} complete tool-call envelopes do not match its terminal result.`
891
+ );
892
+ }
893
+ if (streamedCalls.fragmentCalls
894
+ && !sameCanonicalLLMToolCalls(
895
+ streamedCalls.fragmentCalls,
896
+ terminal.canonicalCalls
897
+ )) {
898
+ throw llmStreamToolCallMismatch(
899
+ `${label} structural tool-call fragments do not match its terminal result.`
900
+ );
901
+ }
902
+ }
903
+
904
+ function assertTerminal(value) {
905
+ const terminal = terminalLLMToolCalls(value);
906
+ const streamedChoices = choiceOrder.filter(
907
+ function retainSeenLLMStreamChoice(record) {
908
+ return record.seen;
909
+ }
910
+ );
911
+ const observedDirect = direct?.observed ? direct : null;
912
+ if (terminal.direct) {
913
+ if (observedDirect) {
914
+ assertRecordMatchesTerminal(
915
+ observedDirect,
916
+ terminal.direct,
917
+ 'AI provider direct stream result'
918
+ );
919
+ }
920
+ if (streamedChoices.length > 1) {
921
+ throw llmStreamToolCallMismatch(
922
+ 'The AI provider stream observed choices that are omitted from its direct terminal result.'
923
+ );
924
+ }
925
+ if (streamedChoices.length === 1) {
926
+ assertRecordMatchesTerminal(
927
+ streamedChoices[0],
928
+ terminal.direct,
929
+ 'AI provider first-seen stream choice'
930
+ );
931
+ }
932
+ return;
933
+ }
934
+
935
+ const terminalByIndex = new Map(
936
+ terminal.choices.map(function indexTerminalLLMChoice(choice) {
937
+ return [choice.choiceIndex, choice];
938
+ })
939
+ );
940
+ const matchedTerminalIndexes = new Set();
941
+ for (const record of streamedChoices) {
942
+ if (record.choiceIndex === null) {
943
+ continue;
944
+ }
945
+ const terminalChoice = terminalByIndex.get(record.choiceIndex);
946
+ if (!terminalChoice) {
947
+ throw llmStreamToolCallMismatch(
948
+ `The AI provider stream observed choice ${record.choiceIndex}, which is omitted from its terminal result.`
949
+ );
950
+ }
951
+ assertRecordMatchesTerminal(
952
+ record,
953
+ terminalChoice,
954
+ `AI provider stream choice ${record.choiceIndex}`
955
+ );
956
+ matchedTerminalIndexes.add(record.choiceIndex);
957
+ }
958
+
959
+ if (observedDirect) {
960
+ const firstTerminalChoice = terminal.choices[0];
961
+ assertRecordMatchesTerminal(
962
+ observedDirect,
963
+ firstTerminalChoice,
964
+ `AI provider first terminal choice ${firstTerminalChoice.choiceIndex}`
965
+ );
966
+ matchedTerminalIndexes.add(firstTerminalChoice.choiceIndex);
967
+ }
968
+
969
+ for (const record of streamedChoices) {
970
+ if (record.choiceIndex !== null) {
971
+ continue;
972
+ }
973
+ const terminalChoice = terminal.choices.find(
974
+ function findFirstUnmatchedTerminalLLMChoice(choice) {
975
+ return !matchedTerminalIndexes.has(choice.choiceIndex);
976
+ }
977
+ );
978
+ if (!terminalChoice) {
979
+ throw llmStreamToolCallMismatch(
980
+ 'The AI provider stream observed a first-seen choice that is omitted from its terminal result.'
981
+ );
982
+ }
983
+ assertRecordMatchesTerminal(
984
+ record,
985
+ terminalChoice,
986
+ `AI provider first-seen stream choice for terminal choice ${terminalChoice.choiceIndex}`
987
+ );
988
+ matchedTerminalIndexes.add(terminalChoice.choiceIndex);
989
+ }
990
+ }
991
+
992
+ return {observe, assertTerminal};
446
993
  }
447
994
 
448
- function isLLMStreamContentKey(key) {
449
- return key === 'content'
450
- || key === 'text'
451
- || key === 'thinking'
452
- || key === 'reasoning'
453
- || key === 'reasoning_content';
995
+ function isLLMStreamStructuralKey(key) {
996
+ return key === 'tool_calls'
997
+ || key === 'toolCalls'
998
+ || key === 'tool_call'
999
+ || key === 'toolCall'
1000
+ || key === 'function_call'
1001
+ || key === 'functionCall';
454
1002
  }
455
1003
 
456
- function projectLLMStreamContent(value, seen = new WeakSet()) {
457
- if (!value || typeof value !== 'object' || seen.has(value)) {
458
- return null;
1004
+ const OMITTED_LLM_STREAM_DATA = Symbol('omitted-llm-stream-data');
1005
+
1006
+ function projectLLMStreamData(value, seen = new Map()) {
1007
+ if (value === null || value === undefined) {
1008
+ return value;
1009
+ }
1010
+ if (typeof value !== 'object') {
1011
+ return value;
1012
+ }
1013
+ if (seen.has(value)) {
1014
+ return seen.get(value);
459
1015
  }
460
- seen.add(value);
461
1016
  if (Array.isArray(value)) {
462
1017
  const result = [];
1018
+ seen.set(value, result);
463
1019
  for (const item of value) {
464
- const projected = projectLLMStreamContent(item, seen);
465
- if (projected !== null) {
466
- result.push(projected);
467
- }
1020
+ const projected = projectLLMStreamData(item, seen);
1021
+ if (projected !== OMITTED_LLM_STREAM_DATA) result.push(projected);
468
1022
  }
469
- seen.delete(value);
470
- return result.length ? result : null;
471
- }
472
- if (!isPlainRecord(value)) {
473
- seen.delete(value);
474
- return null;
1023
+ return result.length || value.length === 0
1024
+ ? result
1025
+ : OMITTED_LLM_STREAM_DATA;
475
1026
  }
476
1027
  const result = {};
1028
+ seen.set(value, result);
1029
+ let sourceDataFields = 0;
477
1030
  const descriptors = Object.getOwnPropertyDescriptors(value);
478
1031
  for (const key of Reflect.ownKeys(descriptors)) {
479
1032
  if (typeof key === 'symbol') {
@@ -483,26 +1036,20 @@ function projectLLMStreamContent(value, seen = new WeakSet()) {
483
1036
  if (!Object.hasOwn(descriptor, 'value')) {
484
1037
  continue;
485
1038
  }
486
- if (isLLMStreamContentKey(key)
487
- && descriptor.value !== null
488
- && descriptor.value !== undefined) {
489
- result[key] = descriptor.value;
1039
+ sourceDataFields += 1;
1040
+ if (isLLMStreamStructuralKey(key)) {
490
1041
  continue;
491
1042
  }
492
- const projected = projectLLMStreamContent(descriptor.value, seen);
493
- if (projected !== null) {
494
- result[key] = projected;
495
- }
1043
+ const projected = projectLLMStreamData(descriptor.value, seen);
1044
+ if (projected !== OMITTED_LLM_STREAM_DATA) result[key] = projected;
496
1045
  }
497
- seen.delete(value);
498
- return Object.keys(result).length ? result : null;
1046
+ return Object.keys(result).length || sourceDataFields === 0
1047
+ ? result
1048
+ : OMITTED_LLM_STREAM_DATA;
499
1049
  }
500
1050
 
501
1051
  function projectLLMStreamChunk(value) {
502
- if (typeof value === 'string') {
503
- return value;
504
- }
505
- return projectLLMStreamContent(value);
1052
+ return projectLLMStreamData(value);
506
1053
  }
507
1054
 
508
1055
  function assertCallbackFreeProviderValue(value, seen = new WeakSet()) {
@@ -715,21 +1262,20 @@ function nullableTupleIdentifier(value) {
715
1262
  : null;
716
1263
  }
717
1264
 
718
- function immutableProgress(value) {
719
- assertClosedRecord(
720
- value,
721
- ['phase', 'completed', 'total', 'unit', 'heartbeat'],
722
- 'AI provider progress'
723
- );
724
- return completeValue(
725
- {
726
- phase: value.phase,
727
- completed: value.completed,
728
- total: value.total,
729
- unit: value.unit,
730
- heartbeat: value.heartbeat
1265
+ function completeProgress(value) {
1266
+ if (!isPlainRecord(value)) {
1267
+ fail('AI provider progress must be a plain object.');
1268
+ }
1269
+ const result = {};
1270
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1271
+ for (const key of Reflect.ownKeys(descriptors)) {
1272
+ const descriptor = descriptors[key];
1273
+ if (typeof key === 'symbol' || !Object.hasOwn(descriptor, 'value')) {
1274
+ fail('AI provider progress must contain string-keyed data properties only.');
731
1275
  }
732
- );
1276
+ result[key] = descriptor.value;
1277
+ }
1278
+ return completeValue(result);
733
1279
  }
734
1280
 
735
1281
  function stateError(error, fallbackCode) {
@@ -1485,15 +2031,12 @@ export class AIProviderRuntime {
1485
2031
  }
1486
2032
  }
1487
2033
 
1488
- #pendingSpeechProviderHydrationMatches(role, slot, provider, routes) {
1489
- if (!provider || !slot.selection || !routes.default) {
2034
+ #pendingSpeechProviderHydrationMatches(role, slot) {
2035
+ if (!slot.selection) {
1490
2036
  return false;
1491
2037
  }
1492
2038
  const pending = slot.selection;
1493
- if (this.#providers.has(providerKey(role, pending.providerId))
1494
- || provider.id !== pending.providerId
1495
- || routes.default.providerId !== pending.providerId
1496
- || routes.default.modelId !== pending.modelId) {
2039
+ if (this.#providers.has(providerKey(role, pending.providerId))) {
1497
2040
  return false;
1498
2041
  }
1499
2042
  const currentSelections = [
@@ -1502,10 +2045,12 @@ export class AIProviderRuntime {
1502
2045
  slot.routes.localOnly
1503
2046
  ].filter(Boolean);
1504
2047
  return currentSelections.length > 0
1505
- && currentSelections.every(selection =>
1506
- selection.providerId === pending.providerId
1507
- && selection.modelId === pending.modelId
1508
- && selection.localOnly === null
2048
+ && currentSelections.every(
2049
+ function isSamePendingSpeechSelection(selection) {
2050
+ return selection.providerId === pending.providerId
2051
+ && selection.modelId === pending.modelId
2052
+ && selection.localOnly === null;
2053
+ }
1509
2054
  );
1510
2055
  }
1511
2056
 
@@ -1558,9 +2103,7 @@ export class AIProviderRuntime {
1558
2103
  || slot.selection)
1559
2104
  && !this.#pendingSpeechProviderHydrationMatches(
1560
2105
  role,
1561
- slot,
1562
- replacement.provider,
1563
- replacement.routes
2106
+ slot
1564
2107
  )) {
1565
2108
  throw speechProviderReplacementError(
1566
2109
  'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_REQUIRED',
@@ -1703,9 +2246,7 @@ export class AIProviderRuntime {
1703
2246
  || slot.selection)
1704
2247
  && !this.#pendingSpeechProviderHydrationMatches(
1705
2248
  role,
1706
- slot,
1707
- replacement.providers[role],
1708
- replacement.routes[role]
2249
+ slot
1709
2250
  )) {
1710
2251
  throw speechProviderReplacementError(
1711
2252
  'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_REQUIRED',
@@ -2774,6 +3315,14 @@ export class AIProviderRuntime {
2774
3315
  let providerOpenPromise = null;
2775
3316
  let iterator = null;
2776
3317
  let cleanupPromise = null;
3318
+ const llmToolCallCorrelation = role === 'llm'
3319
+ ? createLLMStreamToolCallCorrelation()
3320
+ : null;
3321
+ let privateStreamObservation = null;
3322
+ const projectedStreamChunks = [];
3323
+ const projectedStreamReaders = [];
3324
+ let projectedStreamClosed = false;
3325
+ let projectedStreamError = null;
2777
3326
  let terminalSettled = false;
2778
3327
  let resolveTerminal;
2779
3328
  let rejectTerminal;
@@ -2784,6 +3333,61 @@ export class AIProviderRuntime {
2784
3333
  terminal.catch(function retainAIProviderStreamTerminalRejection() {});
2785
3334
  requestRecord.promise = terminal;
2786
3335
 
3336
+ function drainProjectedAIProviderStreamReaders() {
3337
+ while (projectedStreamReaders.length
3338
+ && projectedStreamChunks.length) {
3339
+ const reader = projectedStreamReaders.shift();
3340
+ reader.resolve({
3341
+ value: projectedStreamChunks.shift(),
3342
+ done: false
3343
+ });
3344
+ }
3345
+ if (!projectedStreamClosed || projectedStreamChunks.length) {
3346
+ return;
3347
+ }
3348
+ while (projectedStreamReaders.length) {
3349
+ const reader = projectedStreamReaders.shift();
3350
+ if (projectedStreamError) {
3351
+ reader.reject(projectedStreamError);
3352
+ } else {
3353
+ reader.resolve({value: undefined, done: true});
3354
+ }
3355
+ }
3356
+ }
3357
+
3358
+ function publishProjectedAIProviderStreamChunk(value) {
3359
+ projectedStreamChunks.push(value);
3360
+ drainProjectedAIProviderStreamReaders();
3361
+ }
3362
+
3363
+ function closeProjectedAIProviderStream(error = null) {
3364
+ if (projectedStreamClosed) {
3365
+ return;
3366
+ }
3367
+ projectedStreamClosed = true;
3368
+ projectedStreamError = error;
3369
+ drainProjectedAIProviderStreamReaders();
3370
+ }
3371
+
3372
+ function readProjectedAIProviderStreamChunk() {
3373
+ if (projectedStreamChunks.length) {
3374
+ return Promise.resolve({
3375
+ value: projectedStreamChunks.shift(),
3376
+ done: false
3377
+ });
3378
+ }
3379
+ if (projectedStreamClosed) {
3380
+ return projectedStreamError
3381
+ ? Promise.reject(projectedStreamError)
3382
+ : Promise.resolve({value: undefined, done: true});
3383
+ }
3384
+ return new Promise(
3385
+ function awaitProjectedAIProviderStreamChunk(resolve, reject) {
3386
+ projectedStreamReaders.push({resolve, reject});
3387
+ }
3388
+ );
3389
+ }
3390
+
2787
3391
  function settleAIProviderStream(error, value) {
2788
3392
  if (terminalSettled) {
2789
3393
  return;
@@ -2799,9 +3403,11 @@ export class AIProviderRuntime {
2799
3403
  }
2800
3404
  runtime.#drainRoleRequestQueue(slot);
2801
3405
  if (error) {
3406
+ closeProjectedAIProviderStream(error);
2802
3407
  restoreAIProviderRoleAfterRequest(error);
2803
3408
  rejectTerminal(error);
2804
3409
  } else {
3410
+ closeProjectedAIProviderStream();
2805
3411
  restoreAIProviderRoleAfterRequest(null);
2806
3412
  resolveTerminal(value);
2807
3413
  }
@@ -2875,6 +3481,15 @@ export class AIProviderRuntime {
2875
3481
  }
2876
3482
 
2877
3483
  async function cancelAIProviderStream(reason, terminalError = null) {
3484
+ const terminalOutcome = terminalError ?? normalizedAbort(
3485
+ reason instanceof Error
3486
+ ? reason
3487
+ : operationError(
3488
+ 'The AI stream was cancelled.',
3489
+ 'ARCANE_AI_REQUEST_ABORTED'
3490
+ )
3491
+ );
3492
+ closeProjectedAIProviderStream(terminalOutcome);
2878
3493
  if (cleanupPromise) {
2879
3494
  return cleanupPromise;
2880
3495
  }
@@ -2888,14 +3503,6 @@ export class AIProviderRuntime {
2888
3503
  );
2889
3504
  controller.abort(reason);
2890
3505
  (async function closeAIProviderStream() {
2891
- const terminalOutcome = terminalError ?? normalizedAbort(
2892
- reason instanceof Error
2893
- ? reason
2894
- : operationError(
2895
- 'The AI stream was cancelled.',
2896
- 'ARCANE_AI_REQUEST_ABORTED'
2897
- )
2898
- );
2899
3506
  try {
2900
3507
  const cleanupOutcome = await cleanupOwnedAIProviderStream(reason);
2901
3508
  assertStreamCleanupComplete(cleanupOutcome);
@@ -2987,9 +3594,51 @@ export class AIProviderRuntime {
2987
3594
  requestSequence,
2988
3595
  controller.signal
2989
3596
  );
3597
+ privateStreamObservation = (
3598
+ async function observePrivateAIProviderStream() {
3599
+ try {
3600
+ while (true) {
3601
+ const result = await iterator.next();
3602
+ runtime.#assertCurrentRequest(
3603
+ slot,
3604
+ generation,
3605
+ requestSequence,
3606
+ controller.signal
3607
+ );
3608
+ if (result.done) {
3609
+ return;
3610
+ }
3611
+ if (role !== 'llm') {
3612
+ publishProjectedAIProviderStreamChunk(result.value);
3613
+ continue;
3614
+ }
3615
+ llmToolCallCorrelation.observe(result.value);
3616
+ const projected = projectLLMStreamChunk(result.value);
3617
+ if (projected !== OMITTED_LLM_STREAM_DATA) {
3618
+ publishProjectedAIProviderStreamChunk(projected);
3619
+ }
3620
+ }
3621
+ } catch (error) {
3622
+ const normalized = isAbort(error, controller.signal)
3623
+ ? normalizedAbort(error)
3624
+ : error;
3625
+ closeProjectedAIProviderStream(normalized);
3626
+ try {
3627
+ await cancelAIProviderStream(normalized, normalized);
3628
+ } catch {
3629
+ // Cancellation owns any cleanup failure.
3630
+ }
3631
+ throw normalized;
3632
+ }
3633
+ }
3634
+ )();
3635
+ privateStreamObservation.catch(
3636
+ function retainPrivateAIProviderStreamRejection() {}
3637
+ );
2990
3638
  Promise.resolve(opened.result).then(
2991
- function acceptAIProviderStreamResult(value) {
3639
+ async function acceptAIProviderStreamResult(value) {
2992
3640
  try {
3641
+ await privateStreamObservation;
2993
3642
  runtime.#assertCurrentRequest(
2994
3643
  slot,
2995
3644
  generation,
@@ -3002,8 +3651,17 @@ export class AIProviderRuntime {
3002
3651
  'AI provider stream result'
3003
3652
  )
3004
3653
  : value;
3654
+ llmToolCallCorrelation?.assertTerminal(terminalValue);
3005
3655
  settleAIProviderStream(null, terminalValue);
3006
3656
  } catch (error) {
3657
+ if (cleanupPromise) {
3658
+ try {
3659
+ await cleanupPromise;
3660
+ } catch {
3661
+ // Stream cancellation owns terminal cleanup failure.
3662
+ }
3663
+ return;
3664
+ }
3007
3665
  const normalized = isAbort(error, controller.signal)
3008
3666
  ? normalizedAbort(error)
3009
3667
  : error;
@@ -3025,49 +3683,15 @@ export class AIProviderRuntime {
3025
3683
  const handle = {
3026
3684
  result: terminal,
3027
3685
  cancel: cancelAIProviderStream,
3028
- async next(value) {
3029
- try {
3030
- let nextValue = value;
3031
- while (true) {
3032
- const result = await iterator.next(nextValue);
3033
- nextValue = undefined;
3034
- runtime.#assertCurrentRequest(
3035
- slot,
3036
- generation,
3037
- requestSequence,
3038
- controller.signal
3039
- );
3040
- if (role !== 'llm') {
3041
- return result;
3042
- }
3043
- if (result.done) {
3044
- return {value: undefined, done: true};
3045
- }
3046
- const projected = projectLLMStreamChunk(result.value);
3047
- if (projected !== null) {
3048
- return {
3049
- value: projected,
3050
- done: false
3051
- };
3052
- }
3053
- }
3054
- } catch (error) {
3055
- await cancelAIProviderStream(error);
3056
- throw isAbort(error, controller.signal)
3057
- ? normalizedAbort(error)
3058
- : error;
3059
- }
3686
+ next() {
3687
+ return readProjectedAIProviderStreamChunk();
3060
3688
  },
3061
3689
  async return(value) {
3062
- Promise.resolve().then(
3063
- function beginReturnedAIProviderStreamCancellation() {
3064
- return cancelAIProviderStream(
3065
- operationError(
3066
- 'The AI stream consumer stopped before completion.',
3067
- 'ARCANE_AI_REQUEST_ABORTED'
3068
- )
3069
- );
3070
- }
3690
+ cancelAIProviderStream(
3691
+ operationError(
3692
+ 'The AI stream consumer stopped before completion.',
3693
+ 'ARCANE_AI_REQUEST_ABORTED'
3694
+ )
3071
3695
  ).catch(
3072
3696
  function reportReturnedAIProviderStreamCancellationFailure(error) {
3073
3697
  console.error(
@@ -3079,7 +3703,7 @@ export class AIProviderRuntime {
3079
3703
  return {value, done: true};
3080
3704
  },
3081
3705
  async throw(error) {
3082
- await cancelAIProviderStream(error);
3706
+ await cancelAIProviderStream(error, error);
3083
3707
  throw error;
3084
3708
  },
3085
3709
  [Symbol.asyncIterator]() {
@@ -3091,6 +3715,7 @@ export class AIProviderRuntime {
3091
3715
  const normalized = isAbort(error, controller.signal)
3092
3716
  ? normalizedAbort(error)
3093
3717
  : error;
3718
+ closeProjectedAIProviderStream(normalized);
3094
3719
  if (cleanupPromise) {
3095
3720
  try {
3096
3721
  await cleanupPromise;
@@ -3500,7 +4125,7 @@ export class AIProviderRuntime {
3500
4125
  {
3501
4126
  state: 'loading',
3502
4127
  operationId,
3503
- progress: immutableProgress(progress)
4128
+ progress: completeProgress(progress)
3504
4129
  }
3505
4130
  )
3506
4131
  );