arcane-os 0.3.4 → 0.3.5

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 +12 -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 +466 -206
  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 +765 -135
  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 +34 -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) {
@@ -2774,6 +3320,14 @@ export class AIProviderRuntime {
2774
3320
  let providerOpenPromise = null;
2775
3321
  let iterator = null;
2776
3322
  let cleanupPromise = null;
3323
+ const llmToolCallCorrelation = role === 'llm'
3324
+ ? createLLMStreamToolCallCorrelation()
3325
+ : null;
3326
+ let privateStreamObservation = null;
3327
+ const projectedStreamChunks = [];
3328
+ const projectedStreamReaders = [];
3329
+ let projectedStreamClosed = false;
3330
+ let projectedStreamError = null;
2777
3331
  let terminalSettled = false;
2778
3332
  let resolveTerminal;
2779
3333
  let rejectTerminal;
@@ -2784,6 +3338,61 @@ export class AIProviderRuntime {
2784
3338
  terminal.catch(function retainAIProviderStreamTerminalRejection() {});
2785
3339
  requestRecord.promise = terminal;
2786
3340
 
3341
+ function drainProjectedAIProviderStreamReaders() {
3342
+ while (projectedStreamReaders.length
3343
+ && projectedStreamChunks.length) {
3344
+ const reader = projectedStreamReaders.shift();
3345
+ reader.resolve({
3346
+ value: projectedStreamChunks.shift(),
3347
+ done: false
3348
+ });
3349
+ }
3350
+ if (!projectedStreamClosed || projectedStreamChunks.length) {
3351
+ return;
3352
+ }
3353
+ while (projectedStreamReaders.length) {
3354
+ const reader = projectedStreamReaders.shift();
3355
+ if (projectedStreamError) {
3356
+ reader.reject(projectedStreamError);
3357
+ } else {
3358
+ reader.resolve({value: undefined, done: true});
3359
+ }
3360
+ }
3361
+ }
3362
+
3363
+ function publishProjectedAIProviderStreamChunk(value) {
3364
+ projectedStreamChunks.push(value);
3365
+ drainProjectedAIProviderStreamReaders();
3366
+ }
3367
+
3368
+ function closeProjectedAIProviderStream(error = null) {
3369
+ if (projectedStreamClosed) {
3370
+ return;
3371
+ }
3372
+ projectedStreamClosed = true;
3373
+ projectedStreamError = error;
3374
+ drainProjectedAIProviderStreamReaders();
3375
+ }
3376
+
3377
+ function readProjectedAIProviderStreamChunk() {
3378
+ if (projectedStreamChunks.length) {
3379
+ return Promise.resolve({
3380
+ value: projectedStreamChunks.shift(),
3381
+ done: false
3382
+ });
3383
+ }
3384
+ if (projectedStreamClosed) {
3385
+ return projectedStreamError
3386
+ ? Promise.reject(projectedStreamError)
3387
+ : Promise.resolve({value: undefined, done: true});
3388
+ }
3389
+ return new Promise(
3390
+ function awaitProjectedAIProviderStreamChunk(resolve, reject) {
3391
+ projectedStreamReaders.push({resolve, reject});
3392
+ }
3393
+ );
3394
+ }
3395
+
2787
3396
  function settleAIProviderStream(error, value) {
2788
3397
  if (terminalSettled) {
2789
3398
  return;
@@ -2799,9 +3408,11 @@ export class AIProviderRuntime {
2799
3408
  }
2800
3409
  runtime.#drainRoleRequestQueue(slot);
2801
3410
  if (error) {
3411
+ closeProjectedAIProviderStream(error);
2802
3412
  restoreAIProviderRoleAfterRequest(error);
2803
3413
  rejectTerminal(error);
2804
3414
  } else {
3415
+ closeProjectedAIProviderStream();
2805
3416
  restoreAIProviderRoleAfterRequest(null);
2806
3417
  resolveTerminal(value);
2807
3418
  }
@@ -2875,6 +3486,15 @@ export class AIProviderRuntime {
2875
3486
  }
2876
3487
 
2877
3488
  async function cancelAIProviderStream(reason, terminalError = null) {
3489
+ const terminalOutcome = terminalError ?? normalizedAbort(
3490
+ reason instanceof Error
3491
+ ? reason
3492
+ : operationError(
3493
+ 'The AI stream was cancelled.',
3494
+ 'ARCANE_AI_REQUEST_ABORTED'
3495
+ )
3496
+ );
3497
+ closeProjectedAIProviderStream(terminalOutcome);
2878
3498
  if (cleanupPromise) {
2879
3499
  return cleanupPromise;
2880
3500
  }
@@ -2888,14 +3508,6 @@ export class AIProviderRuntime {
2888
3508
  );
2889
3509
  controller.abort(reason);
2890
3510
  (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
3511
  try {
2900
3512
  const cleanupOutcome = await cleanupOwnedAIProviderStream(reason);
2901
3513
  assertStreamCleanupComplete(cleanupOutcome);
@@ -2987,9 +3599,51 @@ export class AIProviderRuntime {
2987
3599
  requestSequence,
2988
3600
  controller.signal
2989
3601
  );
3602
+ privateStreamObservation = (
3603
+ async function observePrivateAIProviderStream() {
3604
+ try {
3605
+ while (true) {
3606
+ const result = await iterator.next();
3607
+ runtime.#assertCurrentRequest(
3608
+ slot,
3609
+ generation,
3610
+ requestSequence,
3611
+ controller.signal
3612
+ );
3613
+ if (result.done) {
3614
+ return;
3615
+ }
3616
+ if (role !== 'llm') {
3617
+ publishProjectedAIProviderStreamChunk(result.value);
3618
+ continue;
3619
+ }
3620
+ llmToolCallCorrelation.observe(result.value);
3621
+ const projected = projectLLMStreamChunk(result.value);
3622
+ if (projected !== OMITTED_LLM_STREAM_DATA) {
3623
+ publishProjectedAIProviderStreamChunk(projected);
3624
+ }
3625
+ }
3626
+ } catch (error) {
3627
+ const normalized = isAbort(error, controller.signal)
3628
+ ? normalizedAbort(error)
3629
+ : error;
3630
+ closeProjectedAIProviderStream(normalized);
3631
+ try {
3632
+ await cancelAIProviderStream(normalized, normalized);
3633
+ } catch {
3634
+ // Cancellation owns any cleanup failure.
3635
+ }
3636
+ throw normalized;
3637
+ }
3638
+ }
3639
+ )();
3640
+ privateStreamObservation.catch(
3641
+ function retainPrivateAIProviderStreamRejection() {}
3642
+ );
2990
3643
  Promise.resolve(opened.result).then(
2991
- function acceptAIProviderStreamResult(value) {
3644
+ async function acceptAIProviderStreamResult(value) {
2992
3645
  try {
3646
+ await privateStreamObservation;
2993
3647
  runtime.#assertCurrentRequest(
2994
3648
  slot,
2995
3649
  generation,
@@ -3002,8 +3656,17 @@ export class AIProviderRuntime {
3002
3656
  'AI provider stream result'
3003
3657
  )
3004
3658
  : value;
3659
+ llmToolCallCorrelation?.assertTerminal(terminalValue);
3005
3660
  settleAIProviderStream(null, terminalValue);
3006
3661
  } catch (error) {
3662
+ if (cleanupPromise) {
3663
+ try {
3664
+ await cleanupPromise;
3665
+ } catch {
3666
+ // Stream cancellation owns terminal cleanup failure.
3667
+ }
3668
+ return;
3669
+ }
3007
3670
  const normalized = isAbort(error, controller.signal)
3008
3671
  ? normalizedAbort(error)
3009
3672
  : error;
@@ -3025,49 +3688,15 @@ export class AIProviderRuntime {
3025
3688
  const handle = {
3026
3689
  result: terminal,
3027
3690
  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
- }
3691
+ next() {
3692
+ return readProjectedAIProviderStreamChunk();
3060
3693
  },
3061
3694
  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
- }
3695
+ cancelAIProviderStream(
3696
+ operationError(
3697
+ 'The AI stream consumer stopped before completion.',
3698
+ 'ARCANE_AI_REQUEST_ABORTED'
3699
+ )
3071
3700
  ).catch(
3072
3701
  function reportReturnedAIProviderStreamCancellationFailure(error) {
3073
3702
  console.error(
@@ -3079,7 +3708,7 @@ export class AIProviderRuntime {
3079
3708
  return {value, done: true};
3080
3709
  },
3081
3710
  async throw(error) {
3082
- await cancelAIProviderStream(error);
3711
+ await cancelAIProviderStream(error, error);
3083
3712
  throw error;
3084
3713
  },
3085
3714
  [Symbol.asyncIterator]() {
@@ -3091,6 +3720,7 @@ export class AIProviderRuntime {
3091
3720
  const normalized = isAbort(error, controller.signal)
3092
3721
  ? normalizedAbort(error)
3093
3722
  : error;
3723
+ closeProjectedAIProviderStream(normalized);
3094
3724
  if (cleanupPromise) {
3095
3725
  try {
3096
3726
  await cleanupPromise;
@@ -3500,7 +4130,7 @@ export class AIProviderRuntime {
3500
4130
  {
3501
4131
  state: 'loading',
3502
4132
  operationId,
3503
- progress: immutableProgress(progress)
4133
+ progress: completeProgress(progress)
3504
4134
  }
3505
4135
  )
3506
4136
  );