u-foo 2.5.7 → 2.5.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.5.7",
3
+ "version": "2.5.8",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -437,34 +437,22 @@ function emitPhase(callback, event = {}) {
437
437
  }
438
438
  }
439
439
 
440
- async function runOpenAiLikeTurn({
440
+ // Shared SSE transport skeleton: POST the payload, then read the stream as
441
+ // SSE blocks, dispatch each non-[DONE] block to onEvent, and stop after the
442
+ // batch that carried [DONE]. Timeout/cancel translation and request cleanup
443
+ // live here so each protocol turn only declares its event handling.
444
+ async function runSseRequest({
441
445
  url = "",
442
- apiKey = "",
443
- model = "",
444
- messages = [],
445
- onTextDelta = null,
446
- onThinkingDelta = null,
447
- onPhase = null,
446
+ headers = {},
447
+ payload = {},
448
448
  signal = null,
449
449
  timeoutMs = 300000,
450
+ onPhase = null,
451
+ onNonStream,
452
+ onEvent,
453
+ onTail = null,
454
+ buildResult,
450
455
  } = {}) {
451
- const payload = {
452
- model,
453
- max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
454
- messages,
455
- tools: buildCoreToolSpecs(),
456
- tool_choice: "auto",
457
- stream: true,
458
- temperature: 0,
459
- };
460
-
461
- const headers = {
462
- "content-type": "application/json",
463
- };
464
- if (apiKey) {
465
- headers.authorization = `Bearer ${apiKey}`;
466
- }
467
-
468
456
  const request = createRequestController({ signal, timeoutMs });
469
457
 
470
458
  emitPhase(onPhase, { type: "request_start" });
@@ -484,28 +472,12 @@ async function runOpenAiLikeTurn({
484
472
 
485
473
  if (!response.body || typeof response.body.getReader !== "function") {
486
474
  const data = await response.json();
487
- const message = data && data.choices && data.choices[0] && data.choices[0].message
488
- ? data.choices[0].message
489
- : {};
490
- const text = typeof message.content === "string" ? message.content : "";
491
- const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
492
- if (text && typeof onTextDelta === "function") {
493
- onTextDelta(text);
494
- }
495
- return {
496
- text,
497
- toolCalls,
498
- };
475
+ return onNonStream(data);
499
476
  }
500
477
 
501
478
  const reader = response.body.getReader();
502
479
  const decoder = new TextDecoder();
503
- const toolCallMap = new Map();
504
480
  let rawBuffer = "";
505
- let responseText = "";
506
- const announcedToolNames = new Set();
507
- let nextSyntheticIndex = 0;
508
- let lastSyntheticIndex = -1;
509
481
  let sawDone = false;
510
482
 
511
483
  while (true) {
@@ -517,96 +489,178 @@ async function runOpenAiLikeTurn({
517
489
  rawBuffer = parsed.rest;
518
490
 
519
491
  for (const block of parsed.blocks) {
520
- const payloadText = parseSseDataBlock(block);
521
- if (!payloadText) continue;
522
- if (payloadText === "[DONE]") {
523
- // Stop reading after this batch, but keep the buffered tail and
524
- // finish the blocks already parsed alongside [DONE] instead of
525
- // silently dropping them.
492
+ const { event, data } = parseSseEventBlock(block);
493
+ if (!data) continue;
494
+ if (data === "[DONE]") {
495
+ // Stop reading after this batch instead of waiting for the server
496
+ // to close the connection, but keep the buffered tail and finish
497
+ // the blocks already parsed alongside [DONE] instead of silently
498
+ // dropping them.
526
499
  sawDone = true;
527
500
  continue;
528
501
  }
529
502
 
530
- const chunk = parseJsonSafe(payloadText, null);
531
- if (!chunk || typeof chunk !== "object") continue;
503
+ onEvent({ event, data });
504
+ }
532
505
 
533
- const choice = chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
534
- if (!choice || typeof choice !== "object") continue;
506
+ if (sawDone) break;
507
+ }
535
508
 
536
- const delta = choice.delta && typeof choice.delta === "object" ? choice.delta : {};
509
+ if (typeof onTail === "function") {
510
+ onTail(rawBuffer);
511
+ }
537
512
 
538
- const reasoningChunk = typeof delta.reasoning_content === "string"
539
- ? delta.reasoning_content
540
- : (typeof delta.reasoning === "string" ? delta.reasoning : "");
541
- if (reasoningChunk) {
542
- emitPhase(onPhase, { type: "thinking_delta", text: reasoningChunk });
543
- if (typeof onThinkingDelta === "function") {
544
- onThinkingDelta(reasoningChunk);
545
- }
513
+ return buildResult();
514
+ } catch (err) {
515
+ if (request.timedOut()) {
516
+ const timeoutError = new Error(`CLI timeout (${normalizeTimeoutMs(timeoutMs)}ms)`);
517
+ timeoutError.code = "timeout";
518
+ throw timeoutError;
519
+ }
520
+ if (signal && typeof signal === "object" && signal.aborted) {
521
+ const cancelError = new Error("CLI cancelled");
522
+ cancelError.code = "cancelled";
523
+ throw cancelError;
524
+ }
525
+ throw err;
526
+ } finally {
527
+ request.cleanup();
528
+ }
529
+ }
530
+
531
+ async function runOpenAiLikeTurn({
532
+ url = "",
533
+ apiKey = "",
534
+ model = "",
535
+ messages = [],
536
+ onTextDelta = null,
537
+ onThinkingDelta = null,
538
+ onPhase = null,
539
+ signal = null,
540
+ timeoutMs = 300000,
541
+ } = {}) {
542
+ const payload = {
543
+ model,
544
+ max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
545
+ messages,
546
+ tools: buildCoreToolSpecs(),
547
+ tool_choice: "auto",
548
+ stream: true,
549
+ temperature: 0,
550
+ };
551
+
552
+ const headers = {
553
+ "content-type": "application/json",
554
+ };
555
+ if (apiKey) {
556
+ headers.authorization = `Bearer ${apiKey}`;
557
+ }
558
+
559
+ const toolCallMap = new Map();
560
+ const announcedToolNames = new Set();
561
+ let responseText = "";
562
+ let nextSyntheticIndex = 0;
563
+ let lastSyntheticIndex = -1;
564
+
565
+ return runSseRequest({
566
+ url,
567
+ headers,
568
+ payload,
569
+ signal,
570
+ timeoutMs,
571
+ onPhase,
572
+ onNonStream: (data) => {
573
+ const message = data && data.choices && data.choices[0] && data.choices[0].message
574
+ ? data.choices[0].message
575
+ : {};
576
+ const text = typeof message.content === "string" ? message.content : "";
577
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
578
+ if (text && typeof onTextDelta === "function") {
579
+ onTextDelta(text);
580
+ }
581
+ return {
582
+ text,
583
+ toolCalls,
584
+ };
585
+ },
586
+ onEvent: ({ data }) => {
587
+ const chunk = parseJsonSafe(data, null);
588
+ if (!chunk || typeof chunk !== "object") return;
589
+
590
+ const choice = chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
591
+ if (!choice || typeof choice !== "object") return;
592
+
593
+ const delta = choice.delta && typeof choice.delta === "object" ? choice.delta : {};
594
+
595
+ const reasoningChunk = typeof delta.reasoning_content === "string"
596
+ ? delta.reasoning_content
597
+ : (typeof delta.reasoning === "string" ? delta.reasoning : "");
598
+ if (reasoningChunk) {
599
+ emitPhase(onPhase, { type: "thinking_delta", text: reasoningChunk });
600
+ if (typeof onThinkingDelta === "function") {
601
+ onThinkingDelta(reasoningChunk);
546
602
  }
603
+ }
547
604
 
548
- if (typeof delta.content === "string" && delta.content) {
549
- responseText += delta.content;
550
- emitPhase(onPhase, { type: "text_delta", text: delta.content });
551
- if (typeof onTextDelta === "function") {
552
- onTextDelta(delta.content);
553
- }
605
+ if (typeof delta.content === "string" && delta.content) {
606
+ responseText += delta.content;
607
+ emitPhase(onPhase, { type: "text_delta", text: delta.content });
608
+ if (typeof onTextDelta === "function") {
609
+ onTextDelta(delta.content);
554
610
  }
611
+ }
555
612
 
556
- if (Array.isArray(delta.tool_calls)) {
557
- for (const callPart of delta.tool_calls) {
558
- let index;
559
- if (Number.isFinite(callPart.index)) {
560
- index = callPart.index;
561
- } else if (typeof callPart.id === "string" && callPart.id) {
562
- // Provider omitted index: a chunk carrying an id starts a new
563
- // call, so give it its own synthetic index instead of
564
- // collapsing every call into slot 0.
565
- while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
566
- index = nextSyntheticIndex;
567
- nextSyntheticIndex += 1;
568
- lastSyntheticIndex = index;
569
- } else if (lastSyntheticIndex >= 0) {
570
- // No index and no id: continuation of the latest synthetic call.
571
- index = lastSyntheticIndex;
572
- } else {
573
- index = 0;
574
- }
575
- const previous = toolCallMap.get(index) || {
576
- id: "",
577
- type: "function",
578
- function: {
579
- name: "",
580
- arguments: "",
581
- },
582
- };
613
+ if (Array.isArray(delta.tool_calls)) {
614
+ for (const callPart of delta.tool_calls) {
615
+ let index;
616
+ if (Number.isFinite(callPart.index)) {
617
+ index = callPart.index;
618
+ } else if (typeof callPart.id === "string" && callPart.id) {
619
+ // Provider omitted index: a chunk carrying an id starts a new
620
+ // call, so give it its own synthetic index instead of
621
+ // collapsing every call into slot 0.
622
+ while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
623
+ index = nextSyntheticIndex;
624
+ nextSyntheticIndex += 1;
625
+ lastSyntheticIndex = index;
626
+ } else if (lastSyntheticIndex >= 0) {
627
+ // No index and no id: continuation of the latest synthetic call.
628
+ index = lastSyntheticIndex;
629
+ } else {
630
+ index = 0;
631
+ }
632
+ const previous = toolCallMap.get(index) || {
633
+ id: "",
634
+ type: "function",
635
+ function: {
636
+ name: "",
637
+ arguments: "",
638
+ },
639
+ };
583
640
 
584
- if (typeof callPart.id === "string" && callPart.id) previous.id = callPart.id;
585
- if (callPart.function && typeof callPart.function === "object") {
586
- if (typeof callPart.function.name === "string" && callPart.function.name) {
587
- previous.function.name = callPart.function.name;
588
- }
589
- if (typeof callPart.function.arguments === "string" && callPart.function.arguments) {
590
- previous.function.arguments += callPart.function.arguments;
591
- }
641
+ if (typeof callPart.id === "string" && callPart.id) previous.id = callPart.id;
642
+ if (callPart.function && typeof callPart.function === "object") {
643
+ if (typeof callPart.function.name === "string" && callPart.function.name) {
644
+ previous.function.name = callPart.function.name;
645
+ }
646
+ if (typeof callPart.function.arguments === "string" && callPart.function.arguments) {
647
+ previous.function.arguments += callPart.function.arguments;
592
648
  }
649
+ }
593
650
 
594
- toolCallMap.set(index, previous);
651
+ toolCallMap.set(index, previous);
595
652
 
596
- const toolName = previous.function.name;
597
- const announceKey = `${index}:${toolName}`;
598
- if (toolName && !announcedToolNames.has(announceKey)) {
599
- announcedToolNames.add(announceKey);
600
- emitPhase(onPhase, { type: "tool_request", name: toolName });
601
- }
653
+ const toolName = previous.function.name;
654
+ const announceKey = `${index}:${toolName}`;
655
+ if (toolName && !announcedToolNames.has(announceKey)) {
656
+ announcedToolNames.add(announceKey);
657
+ emitPhase(onPhase, { type: "tool_request", name: toolName });
602
658
  }
603
659
  }
604
660
  }
605
-
606
- if (sawDone) break;
607
- }
608
-
609
- if (rawBuffer.trim()) {
661
+ },
662
+ onTail: (rawBuffer) => {
663
+ if (!rawBuffer.trim()) return;
610
664
  const fallbackBlock = parseSseDataBlock(rawBuffer);
611
665
  if (fallbackBlock && fallbackBlock !== "[DONE]") {
612
666
  const chunk = parseJsonSafe(fallbackBlock, null);
@@ -618,29 +672,14 @@ async function runOpenAiLikeTurn({
618
672
  }
619
673
  }
620
674
  }
621
- }
622
-
623
- return {
675
+ },
676
+ buildResult: () => ({
624
677
  text: responseText,
625
678
  toolCalls: Array.from(toolCallMap.entries())
626
679
  .sort((a, b) => a[0] - b[0])
627
680
  .map((entry) => entry[1]),
628
- };
629
- } catch (err) {
630
- if (request.timedOut()) {
631
- const timeoutError = new Error(`CLI timeout (${normalizeTimeoutMs(timeoutMs)}ms)`);
632
- timeoutError.code = "timeout";
633
- throw timeoutError;
634
- }
635
- if (signal && typeof signal === "object" && signal.aborted) {
636
- const cancelError = new Error("CLI cancelled");
637
- cancelError.code = "cancelled";
638
- throw cancelError;
639
- }
640
- throw err;
641
- } finally {
642
- request.cleanup();
643
- }
681
+ }),
682
+ });
644
683
  }
645
684
 
646
685
  function normalizeAnthropicMessageContent(raw = []) {
@@ -713,25 +752,19 @@ async function runAnthropicTurn({
713
752
  headers["x-api-key"] = apiKey;
714
753
  }
715
754
 
716
- const request = createRequestController({ signal, timeoutMs });
717
-
718
- emitPhase(onPhase, { type: "request_start" });
719
-
720
- try {
721
- const response = await fetch(url, {
722
- method: "POST",
723
- headers,
724
- body: JSON.stringify(payload),
725
- signal: request.signal,
726
- });
727
-
728
- if (!response.ok) {
729
- const body = await response.text().catch(() => "");
730
- throw new Error(`provider request failed (${response.status}): ${clipText(body, 500)}`);
731
- }
732
-
733
- if (!response.body || typeof response.body.getReader !== "function") {
734
- const data = await response.json();
755
+ const blockMap = new Map();
756
+ let responseText = "";
757
+ let nextSyntheticBlockIndex = 0;
758
+ let lastBlockIndex = -1;
759
+
760
+ return runSseRequest({
761
+ url,
762
+ headers,
763
+ payload,
764
+ signal,
765
+ timeoutMs,
766
+ onPhase,
767
+ onNonStream: (data) => {
735
768
  const content = normalizeAnthropicMessageContent(data && data.content);
736
769
  const text = content
737
770
  .filter((item) => item.type === "text")
@@ -745,270 +778,181 @@ async function runAnthropicTurn({
745
778
  assistantContent: content,
746
779
  toolCalls: extractAnthropicToolCalls(content),
747
780
  };
748
- }
749
-
750
- const reader = response.body.getReader();
751
- const decoder = new TextDecoder();
752
- const blockMap = new Map();
753
- let rawBuffer = "";
754
- let responseText = "";
755
- let nextSyntheticBlockIndex = 0;
756
- let lastBlockIndex = -1;
757
- let sawDone = false;
758
-
759
- while (true) {
760
- const { done, value } = await reader.read();
761
- if (done) break;
762
-
763
- rawBuffer += decoder.decode(value, { stream: true });
764
- const parsed = parseSseBlocks(rawBuffer);
765
- rawBuffer = parsed.rest;
766
-
767
- for (const rawBlock of parsed.blocks) {
768
- const { event, data } = parseSseEventBlock(rawBlock);
769
- if (!data) continue;
770
- if (data === "[DONE]") {
771
- // Stop reading after this batch instead of waiting for the server
772
- // to close the connection, mirroring the OpenAI transport.
773
- sawDone = true;
774
- continue;
775
- }
776
-
777
- const payloadChunk = parseJsonSafe(data, null);
778
- if (!payloadChunk || typeof payloadChunk !== "object") continue;
781
+ },
782
+ onEvent: ({ event, data }) => {
783
+ const payloadChunk = parseJsonSafe(data, null);
784
+ if (!payloadChunk || typeof payloadChunk !== "object") return;
785
+
786
+ if (event === "error") {
787
+ const errMsg = payloadChunk.error && payloadChunk.error.message
788
+ ? String(payloadChunk.error.message)
789
+ : "anthropic stream error";
790
+ throw new Error(errMsg);
791
+ }
779
792
 
780
- if (event === "error") {
781
- const errMsg = payloadChunk.error && payloadChunk.error.message
782
- ? String(payloadChunk.error.message)
783
- : "anthropic stream error";
784
- throw new Error(errMsg);
793
+ if (event === "content_block_start") {
794
+ let index;
795
+ if (Number.isFinite(payloadChunk.index)) {
796
+ index = payloadChunk.index;
797
+ } else {
798
+ // Provider omitted index: each start opens a new block, so give
799
+ // it its own synthetic index instead of collapsing every block
800
+ // into slot 0.
801
+ while (blockMap.has(nextSyntheticBlockIndex)) nextSyntheticBlockIndex += 1;
802
+ index = nextSyntheticBlockIndex;
803
+ nextSyntheticBlockIndex += 1;
785
804
  }
786
-
787
- if (event === "content_block_start") {
788
- let index;
789
- if (Number.isFinite(payloadChunk.index)) {
790
- index = payloadChunk.index;
791
- } else {
792
- // Provider omitted index: each start opens a new block, so give
793
- // it its own synthetic index instead of collapsing every block
794
- // into slot 0.
795
- while (blockMap.has(nextSyntheticBlockIndex)) nextSyntheticBlockIndex += 1;
796
- index = nextSyntheticBlockIndex;
797
- nextSyntheticBlockIndex += 1;
798
- }
799
- lastBlockIndex = index;
800
- const contentBlock = payloadChunk.content_block && typeof payloadChunk.content_block === "object"
801
- ? payloadChunk.content_block
802
- : {};
803
-
804
- if (contentBlock.type === "text") {
805
- blockMap.set(index, {
806
- order: index,
807
- type: "text",
808
- text: String(contentBlock.text || ""),
809
- });
810
- } else if (contentBlock.type === "thinking") {
811
- blockMap.set(index, {
812
- order: index,
813
- type: "thinking",
814
- text: String(contentBlock.thinking || ""),
815
- });
816
- } else if (contentBlock.type === "tool_use") {
817
- blockMap.set(index, {
818
- order: index,
819
- type: "tool_use",
820
- id: String(contentBlock.id || ""),
821
- name: String(contentBlock.name || ""),
822
- input: contentBlock.input && typeof contentBlock.input === "object" && !Array.isArray(contentBlock.input)
823
- ? { ...contentBlock.input }
824
- : {},
825
- inputJson: "",
826
- });
827
- const toolName = String(contentBlock.name || "");
828
- if (toolName) {
829
- emitPhase(onPhase, { type: "tool_request", name: toolName });
830
- }
805
+ lastBlockIndex = index;
806
+ const contentBlock = payloadChunk.content_block && typeof payloadChunk.content_block === "object"
807
+ ? payloadChunk.content_block
808
+ : {};
809
+
810
+ if (contentBlock.type === "text") {
811
+ blockMap.set(index, {
812
+ order: index,
813
+ type: "text",
814
+ text: String(contentBlock.text || ""),
815
+ });
816
+ } else if (contentBlock.type === "thinking") {
817
+ blockMap.set(index, {
818
+ order: index,
819
+ type: "thinking",
820
+ text: String(contentBlock.thinking || ""),
821
+ });
822
+ } else if (contentBlock.type === "tool_use") {
823
+ blockMap.set(index, {
824
+ order: index,
825
+ type: "tool_use",
826
+ id: String(contentBlock.id || ""),
827
+ name: String(contentBlock.name || ""),
828
+ input: contentBlock.input && typeof contentBlock.input === "object" && !Array.isArray(contentBlock.input)
829
+ ? { ...contentBlock.input }
830
+ : {},
831
+ inputJson: "",
832
+ });
833
+ const toolName = String(contentBlock.name || "");
834
+ if (toolName) {
835
+ emitPhase(onPhase, { type: "tool_request", name: toolName });
831
836
  }
832
- continue;
833
837
  }
838
+ return;
839
+ }
834
840
 
835
- if (event === "content_block_delta") {
836
- let index;
837
- if (Number.isFinite(payloadChunk.index)) {
838
- index = payloadChunk.index;
839
- } else if (lastBlockIndex >= 0) {
840
- // No index: continuation of the most recently started block.
841
- index = lastBlockIndex;
842
- } else {
843
- index = 0;
844
- }
845
- const delta = payloadChunk.delta && typeof payloadChunk.delta === "object"
846
- ? payloadChunk.delta
847
- : {};
848
- const current = blockMap.get(index) || { order: index, type: "text", text: "" };
849
-
850
- if (delta.type === "text_delta") {
851
- const deltaText = String(delta.text || "");
852
- current.type = "text";
853
- current.text = `${String(current.text || "")}${deltaText}`;
854
- blockMap.set(index, current);
855
- if (deltaText) {
856
- responseText += deltaText;
857
- emitPhase(onPhase, { type: "text_delta", text: deltaText });
858
- if (typeof onTextDelta === "function") {
859
- onTextDelta(deltaText);
860
- }
841
+ if (event === "content_block_delta") {
842
+ let index;
843
+ if (Number.isFinite(payloadChunk.index)) {
844
+ index = payloadChunk.index;
845
+ } else if (lastBlockIndex >= 0) {
846
+ // No index: continuation of the most recently started block.
847
+ index = lastBlockIndex;
848
+ } else {
849
+ index = 0;
850
+ }
851
+ const delta = payloadChunk.delta && typeof payloadChunk.delta === "object"
852
+ ? payloadChunk.delta
853
+ : {};
854
+ const current = blockMap.get(index) || { order: index, type: "text", text: "" };
855
+
856
+ if (delta.type === "text_delta") {
857
+ const deltaText = String(delta.text || "");
858
+ current.type = "text";
859
+ current.text = `${String(current.text || "")}${deltaText}`;
860
+ blockMap.set(index, current);
861
+ if (deltaText) {
862
+ responseText += deltaText;
863
+ emitPhase(onPhase, { type: "text_delta", text: deltaText });
864
+ if (typeof onTextDelta === "function") {
865
+ onTextDelta(deltaText);
861
866
  }
862
- continue;
863
867
  }
868
+ return;
869
+ }
864
870
 
865
- if (delta.type === "thinking_delta") {
866
- const deltaText = String(delta.thinking || "");
867
- current.type = "thinking";
868
- current.text = `${String(current.text || "")}${deltaText}`;
869
- blockMap.set(index, current);
870
- if (deltaText) {
871
- emitPhase(onPhase, { type: "thinking_delta", text: deltaText });
872
- if (typeof onThinkingDelta === "function") {
873
- onThinkingDelta(deltaText);
874
- }
871
+ if (delta.type === "thinking_delta") {
872
+ const deltaText = String(delta.thinking || "");
873
+ current.type = "thinking";
874
+ current.text = `${String(current.text || "")}${deltaText}`;
875
+ blockMap.set(index, current);
876
+ if (deltaText) {
877
+ emitPhase(onPhase, { type: "thinking_delta", text: deltaText });
878
+ if (typeof onThinkingDelta === "function") {
879
+ onThinkingDelta(deltaText);
875
880
  }
876
- continue;
877
881
  }
882
+ return;
883
+ }
878
884
 
879
- if (delta.type === "input_json_delta") {
880
- current.type = "tool_use";
881
- current.inputJson = `${String(current.inputJson || "")}${String(delta.partial_json || "")}`;
882
- blockMap.set(index, current);
883
- continue;
884
- }
885
+ if (delta.type === "input_json_delta") {
886
+ current.type = "tool_use";
887
+ current.inputJson = `${String(current.inputJson || "")}${String(delta.partial_json || "")}`;
888
+ blockMap.set(index, current);
889
+ return;
885
890
  }
886
891
  }
892
+ },
893
+ buildResult: () => {
894
+ const assistantContent = Array.from(blockMap.values())
895
+ .sort((a, b) => a.order - b.order)
896
+ .filter((item) => item.type !== "thinking")
897
+ .map((item) => {
898
+ if (item.type === "text") {
899
+ return {
900
+ type: "text",
901
+ text: String(item.text || ""),
902
+ };
903
+ }
887
904
 
888
- if (sawDone) break;
889
- }
890
-
891
- const assistantContent = Array.from(blockMap.values())
892
- .sort((a, b) => a.order - b.order)
893
- .filter((item) => item.type !== "thinking")
894
- .map((item) => {
895
- if (item.type === "text") {
905
+ const inputFromDelta = normalizeToolCallArgs(item.inputJson || "");
906
+ const mergedInput = {
907
+ ...(item.input && typeof item.input === "object" ? item.input : {}),
908
+ ...(inputFromDelta && typeof inputFromDelta === "object" ? inputFromDelta : {}),
909
+ };
896
910
  return {
897
- type: "text",
898
- text: String(item.text || ""),
911
+ type: "tool_use",
912
+ id: String(item.id || `tool_${randomUUID()}`),
913
+ name: String(item.name || ""),
914
+ input: mergedInput,
899
915
  };
900
- }
901
-
902
- const inputFromDelta = normalizeToolCallArgs(item.inputJson || "");
903
- const mergedInput = {
904
- ...(item.input && typeof item.input === "object" ? item.input : {}),
905
- ...(inputFromDelta && typeof inputFromDelta === "object" ? inputFromDelta : {}),
906
- };
907
- return {
908
- type: "tool_use",
909
- id: String(item.id || `tool_${randomUUID()}`),
910
- name: String(item.name || ""),
911
- input: mergedInput,
912
- };
913
- });
916
+ });
914
917
 
915
- if (!responseText) {
916
- responseText = assistantContent
917
- .filter((item) => item.type === "text")
918
- .map((item) => item.text)
919
- .join("");
920
- }
918
+ if (!responseText) {
919
+ responseText = assistantContent
920
+ .filter((item) => item.type === "text")
921
+ .map((item) => item.text)
922
+ .join("");
923
+ }
921
924
 
922
- return {
923
- text: responseText,
924
- assistantContent,
925
- toolCalls: extractAnthropicToolCalls(assistantContent),
926
- };
927
- } catch (err) {
928
- if (request.timedOut()) {
929
- const timeoutError = new Error(`CLI timeout (${normalizeTimeoutMs(timeoutMs)}ms)`);
930
- timeoutError.code = "timeout";
931
- throw timeoutError;
932
- }
933
- if (signal && typeof signal === "object" && signal.aborted) {
934
- const cancelError = new Error("CLI cancelled");
935
- cancelError.code = "cancelled";
936
- throw cancelError;
937
- }
938
- throw err;
939
- } finally {
940
- request.cleanup();
941
- }
925
+ return {
926
+ text: responseText,
927
+ assistantContent,
928
+ toolCalls: extractAnthropicToolCalls(assistantContent),
929
+ };
930
+ },
931
+ });
942
932
  }
943
933
 
944
- async function runNativeLoopOpenAi({
945
- workspaceRoot = process.cwd(),
946
- prompt = "",
947
- systemPrompt = "",
948
- historyMessages = [],
949
- model = "",
950
- baseUrl = "",
951
- apiKey = "",
952
- timeoutMs = 300000,
953
- onStreamDelta = null,
954
- onThinkingDelta = null,
955
- onPhase = null,
956
- onToolEvent = null,
957
- signal = null,
958
- guards,
959
- } = {}) {
960
- const requestModel = String(model || "").trim();
961
- if (!requestModel) {
962
- throw new Error("ucode model is not configured");
963
- }
964
-
965
- const requestUrl = resolveCompletionUrl(baseUrl);
966
- if (!requestUrl) {
967
- throw new Error("ucode baseUrl is not configured");
968
- }
969
-
970
- const messages = cloneMessageList(historyMessages);
971
- const systemText = String(systemPrompt || "").trim();
972
- const hasSystem = messages.some((entry) => String(entry.role || "").trim() === "system");
973
- if (systemText && !hasSystem) {
974
- messages.unshift({ role: "system", content: systemText });
975
- }
976
- messages.push({ role: "user", content: String(prompt || "") });
977
-
978
- let aggregated = "";
979
- let streamed = false;
980
- let toolCallsExecuted = 0;
981
- let toolErrors = 0;
982
- const toolBudget = resolveNativeToolBudget();
983
-
984
- while (true) {
985
- guards.ensureActive();
986
-
987
- const turnResult = await runOpenAiLikeTurn({
988
- url: requestUrl,
989
- apiKey,
990
- model: requestModel,
991
- messages,
992
- signal,
993
- timeoutMs,
994
- onPhase,
995
- onThinkingDelta,
996
- onTextDelta: (chunk) => {
997
- const text = String(chunk || "");
998
- if (!text) return;
999
- aggregated += text;
1000
- if (typeof onStreamDelta === "function") {
1001
- streamed = true;
1002
- onStreamDelta(text);
1003
- }
1004
- },
1005
- });
1006
-
1007
- const toolCalls = Array.isArray(turnResult.toolCalls)
1008
- ? turnResult.toolCalls.filter((call) => call && call.function && typeof call.function === "object")
1009
- : [];
1010
-
1011
- if (toolCalls.length === 0) {
934
+ // Transport descriptors: everything the shared native loop needs that differs
935
+ // between the OpenAI chat-completions and Anthropic messages protocols —
936
+ // request URL resolution, initial message shaping, turn execution, and
937
+ // assistant/tool-result message formatting.
938
+ const TRANSPORTS = {
939
+ "openai-chat": {
940
+ resolveUrl: resolveCompletionUrl,
941
+ prepareMessages({ messages, systemPrompt, prompt }) {
942
+ const systemText = String(systemPrompt || "").trim();
943
+ const hasSystem = messages.some((entry) => String(entry.role || "").trim() === "system");
944
+ if (systemText && !hasSystem) {
945
+ messages.unshift({ role: "system", content: systemText });
946
+ }
947
+ messages.push({ role: "user", content: String(prompt || "") });
948
+ },
949
+ runTurn: runOpenAiLikeTurn,
950
+ getToolCalls(turnResult) {
951
+ return Array.isArray(turnResult.toolCalls)
952
+ ? turnResult.toolCalls.filter((call) => call && call.function && typeof call.function === "object")
953
+ : [];
954
+ },
955
+ appendFinalAssistantMessage({ messages, turnResult }) {
1012
956
  const text = String(turnResult.text || "").trim();
1013
957
  if (text) {
1014
958
  messages.push({
@@ -1016,78 +960,114 @@ async function runNativeLoopOpenAi({
1016
960
  content: text,
1017
961
  });
1018
962
  }
1019
- if (!aggregated.trim() && text) {
1020
- aggregated = text;
963
+ },
964
+ prepareToolCalls({ messages, toolCalls }) {
965
+ const assistantToolCalls = [];
966
+ for (const call of toolCalls) {
967
+ const callId = String(call.id || `call_${randomUUID()}`);
968
+ const name = normalizeToolName(call.function.name || "");
969
+ const args = normalizeToolCallArgs(call.function.arguments || "");
970
+
971
+ assistantToolCalls.push({
972
+ id: callId,
973
+ type: "function",
974
+ function: {
975
+ name: name || String(call.function.name || ""),
976
+ arguments: toJsonString(args),
977
+ },
978
+ });
1021
979
  }
1022
- return {
1023
- text: aggregated,
1024
- streamed,
1025
- toolCallsExecuted,
1026
- messages,
1027
- };
1028
- }
1029
980
 
1030
- const assistantToolCalls = [];
1031
- for (const call of toolCalls) {
1032
- const callId = String(call.id || `call_${randomUUID()}`);
1033
- const name = normalizeToolName(call.function.name || "");
1034
- const args = normalizeToolCallArgs(call.function.arguments || "");
1035
-
1036
- assistantToolCalls.push({
1037
- id: callId,
1038
- type: "function",
1039
- function: {
1040
- name: name || String(call.function.name || ""),
1041
- arguments: toJsonString(args),
1042
- },
1043
- });
1044
- }
981
+ if (assistantToolCalls.length === 0) return null;
1045
982
 
1046
- if (assistantToolCalls.length === 0) {
1047
- return {
1048
- text: aggregated,
1049
- streamed,
1050
- toolCallsExecuted,
1051
- messages,
1052
- };
1053
- }
1054
-
1055
- messages.push({
1056
- role: "assistant",
1057
- content: null,
1058
- tool_calls: assistantToolCalls,
1059
- });
983
+ messages.push({
984
+ role: "assistant",
985
+ content: null,
986
+ tool_calls: assistantToolCalls,
987
+ });
1060
988
 
1061
- for (const toolCall of assistantToolCalls) {
1062
- const toolResult = runCoreTool({
1063
- tool: toolCall.function.name,
989
+ return assistantToolCalls.map((toolCall) => ({
990
+ name: toolCall.function.name,
1064
991
  args: normalizeToolCallArgs(toolCall.function.arguments),
1065
- workspaceRoot,
1066
- onToolEvent,
1067
- });
1068
- toolCallsExecuted += 1;
1069
- if (!toolResult || toolResult.ok === false) {
1070
- toolErrors += 1;
1071
- }
1072
- enforceNativeToolBudget({
1073
- toolCallsExecuted,
1074
- toolErrors,
1075
- maxToolCalls: toolBudget.maxToolCalls,
1076
- maxToolErrors: toolBudget.maxToolErrors,
1077
- lastTool: toolCall.function.name,
1078
- lastError: toolResult && toolResult.error ? String(toolResult.error) : "",
1079
- });
992
+ source: toolCall,
993
+ }));
994
+ },
995
+ appendToolResult({ messages, call, toolResult }) {
1080
996
  messages.push({
1081
997
  role: "tool",
1082
- tool_call_id: toolCall.id,
998
+ tool_call_id: call.source.id,
1083
999
  content: clipText(toJsonString(toolResult), 12000),
1084
1000
  });
1085
- }
1086
- }
1001
+ },
1002
+ },
1003
+ "anthropic-messages": {
1004
+ resolveUrl: resolveAnthropicMessagesUrl,
1005
+ prepareMessages({ messages, prompt }) {
1006
+ messages.push({
1007
+ role: "user",
1008
+ content: String(prompt || ""),
1009
+ });
1010
+ },
1011
+ runTurn: runAnthropicTurn,
1012
+ getToolCalls(turnResult) {
1013
+ return Array.isArray(turnResult.toolCalls) ? turnResult.toolCalls : [];
1014
+ },
1015
+ appendFinalAssistantMessage({ messages, turnResult }) {
1016
+ const assistantContent = Array.isArray(turnResult.assistantContent)
1017
+ ? turnResult.assistantContent
1018
+ : [];
1019
+ if (assistantContent.length > 0) {
1020
+ messages.push({
1021
+ role: "assistant",
1022
+ content: assistantContent,
1023
+ });
1024
+ } else if (String(turnResult.text || "").trim()) {
1025
+ messages.push({
1026
+ role: "assistant",
1027
+ content: [
1028
+ {
1029
+ type: "text",
1030
+ text: String(turnResult.text || ""),
1031
+ },
1032
+ ],
1033
+ });
1034
+ }
1035
+ },
1036
+ prepareToolCalls({ messages, turnResult, toolCalls }) {
1037
+ const assistantContent = Array.isArray(turnResult.assistantContent)
1038
+ ? turnResult.assistantContent
1039
+ : [];
1087
1040
 
1088
- }
1041
+ messages.push({
1042
+ role: "assistant",
1043
+ content: assistantContent,
1044
+ });
1089
1045
 
1090
- async function runNativeLoopAnthropic({
1046
+ return toolCalls.map((call) => ({
1047
+ name: call.name,
1048
+ args: call.args,
1049
+ source: call,
1050
+ }));
1051
+ },
1052
+ appendToolResult({ collected, call, toolResult }) {
1053
+ collected.push({
1054
+ type: "tool_result",
1055
+ tool_use_id: String(call.source.id || ""),
1056
+ content: clipText(toJsonString(toolResult), 12000),
1057
+ is_error: Boolean(!toolResult || toolResult.ok === false),
1058
+ });
1059
+ },
1060
+ flushToolResults({ messages, collected }) {
1061
+ messages.push({
1062
+ role: "user",
1063
+ content: collected,
1064
+ });
1065
+ },
1066
+ },
1067
+ };
1068
+
1069
+ async function runNativeLoop({
1070
+ transport,
1091
1071
  workspaceRoot = process.cwd(),
1092
1072
  prompt = "",
1093
1073
  systemPrompt = "",
@@ -1108,16 +1088,13 @@ async function runNativeLoopAnthropic({
1108
1088
  throw new Error("ucode model is not configured");
1109
1089
  }
1110
1090
 
1111
- const requestUrl = resolveAnthropicMessagesUrl(baseUrl);
1091
+ const requestUrl = transport.resolveUrl(baseUrl);
1112
1092
  if (!requestUrl) {
1113
1093
  throw new Error("ucode baseUrl is not configured");
1114
1094
  }
1115
1095
 
1116
1096
  const messages = cloneMessageList(historyMessages);
1117
- messages.push({
1118
- role: "user",
1119
- content: String(prompt || ""),
1120
- });
1097
+ transport.prepareMessages({ messages, systemPrompt, prompt });
1121
1098
 
1122
1099
  let aggregated = "";
1123
1100
  let streamed = false;
@@ -1128,7 +1105,7 @@ async function runNativeLoopAnthropic({
1128
1105
  while (true) {
1129
1106
  guards.ensureActive();
1130
1107
 
1131
- const turnResult = await runAnthropicTurn({
1108
+ const turnResult = await transport.runTurn({
1132
1109
  url: requestUrl,
1133
1110
  apiKey,
1134
1111
  model: requestModel,
@@ -1149,28 +1126,10 @@ async function runNativeLoopAnthropic({
1149
1126
  },
1150
1127
  });
1151
1128
 
1152
- const toolCalls = Array.isArray(turnResult.toolCalls) ? turnResult.toolCalls : [];
1129
+ const toolCalls = transport.getToolCalls(turnResult);
1153
1130
 
1154
1131
  if (toolCalls.length === 0) {
1155
- const assistantContent = Array.isArray(turnResult.assistantContent)
1156
- ? turnResult.assistantContent
1157
- : [];
1158
- if (assistantContent.length > 0) {
1159
- messages.push({
1160
- role: "assistant",
1161
- content: assistantContent,
1162
- });
1163
- } else if (String(turnResult.text || "").trim()) {
1164
- messages.push({
1165
- role: "assistant",
1166
- content: [
1167
- {
1168
- type: "text",
1169
- text: String(turnResult.text || ""),
1170
- },
1171
- ],
1172
- });
1173
- }
1132
+ transport.appendFinalAssistantMessage({ messages, turnResult });
1174
1133
  const text = String(turnResult.text || "").trim();
1175
1134
  if (!aggregated.trim() && text) {
1176
1135
  aggregated = text;
@@ -1183,20 +1142,21 @@ async function runNativeLoopAnthropic({
1183
1142
  };
1184
1143
  }
1185
1144
 
1186
- const assistantContent = Array.isArray(turnResult.assistantContent)
1187
- ? turnResult.assistantContent
1188
- : [];
1189
-
1190
- messages.push({
1191
- role: "assistant",
1192
- content: assistantContent,
1193
- });
1145
+ const pendingCalls = transport.prepareToolCalls({ messages, turnResult, toolCalls });
1146
+ if (!pendingCalls) {
1147
+ return {
1148
+ text: aggregated,
1149
+ streamed,
1150
+ toolCallsExecuted,
1151
+ messages,
1152
+ };
1153
+ }
1194
1154
 
1195
- const toolResults = [];
1196
- for (const call of toolCalls) {
1155
+ const collectedResults = [];
1156
+ for (const pending of pendingCalls) {
1197
1157
  const toolResult = runCoreTool({
1198
- tool: call.name,
1199
- args: call.args,
1158
+ tool: pending.name,
1159
+ args: pending.args,
1200
1160
  workspaceRoot,
1201
1161
  onToolEvent,
1202
1162
  });
@@ -1209,23 +1169,21 @@ async function runNativeLoopAnthropic({
1209
1169
  toolErrors,
1210
1170
  maxToolCalls: toolBudget.maxToolCalls,
1211
1171
  maxToolErrors: toolBudget.maxToolErrors,
1212
- lastTool: call.name,
1172
+ lastTool: pending.name,
1213
1173
  lastError: toolResult && toolResult.error ? String(toolResult.error) : "",
1214
1174
  });
1215
- toolResults.push({
1216
- type: "tool_result",
1217
- tool_use_id: String(call.id || ""),
1218
- content: clipText(toJsonString(toolResult), 12000),
1219
- is_error: Boolean(!toolResult || toolResult.ok === false),
1175
+ transport.appendToolResult({
1176
+ messages,
1177
+ collected: collectedResults,
1178
+ call: pending,
1179
+ toolResult,
1220
1180
  });
1221
1181
  }
1222
1182
 
1223
- messages.push({
1224
- role: "user",
1225
- content: toolResults,
1226
- });
1183
+ if (typeof transport.flushToolResults === "function") {
1184
+ transport.flushToolResults({ messages, collected: collectedResults });
1185
+ }
1227
1186
  }
1228
-
1229
1187
  }
1230
1188
 
1231
1189
  async function runNativeAgentTask({
@@ -1277,11 +1235,10 @@ async function runNativeAgentTask({
1277
1235
  model,
1278
1236
  });
1279
1237
 
1280
- const loopRunner = runtime.transport === "anthropic-messages"
1281
- ? runNativeLoopAnthropic
1282
- : runNativeLoopOpenAi;
1238
+ const transport = TRANSPORTS[runtime.transport] || TRANSPORTS["openai-chat"];
1283
1239
 
1284
- const runResult = await loopRunner({
1240
+ const runResult = await runNativeLoop({
1241
+ transport,
1285
1242
  workspaceRoot,
1286
1243
  prompt: promptText,
1287
1244
  systemPrompt,