@oh-my-pi/pi-agent-core 16.0.4 → 16.0.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.
package/src/agent-loop.ts CHANGED
@@ -3,12 +3,16 @@
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
5
  import {
6
- type ApiKeyResolveContext,
7
6
  type AssistantMessage,
8
7
  type AssistantMessageEvent,
8
+ arkToWireSchema,
9
9
  type Context,
10
10
  EventStream,
11
+ isApiKeyResolver,
12
+ isArkSchema,
11
13
  isZodSchema,
14
+ resolveApiKeyOnce,
15
+ seedApiKeyResolver,
12
16
  streamSimple,
13
17
  type ToolResultMessage,
14
18
  type TSchema,
@@ -33,7 +37,7 @@ import {
33
37
  signalListLabel,
34
38
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
35
39
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
36
- import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
40
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
37
41
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
38
42
  import {
39
43
  type AgentTelemetry,
@@ -111,6 +115,7 @@ function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefi
111
115
  case "qwen3":
112
116
  case "gemini":
113
117
  case "gemma":
118
+ case "minimax":
114
119
  return value;
115
120
  default:
116
121
  return undefined;
@@ -563,6 +568,9 @@ export function normalizeTools(
563
568
  if (isZodSchema(parameters)) {
564
569
  const wired = zodToWireSchema(parameters);
565
570
  parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
571
+ } else if (isArkSchema(parameters)) {
572
+ const wired = arkToWireSchema(parameters);
573
+ parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
566
574
  } else {
567
575
  parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
568
576
  }
@@ -636,6 +644,20 @@ interface StepCounter {
636
644
  count: number;
637
645
  }
638
646
 
647
+ function isDeadlineExceeded(deadline: number | undefined): boolean {
648
+ return deadline !== undefined && Date.now() >= deadline;
649
+ }
650
+
651
+ function endAgentStream(
652
+ stream: EventStream<AgentEvent, AgentMessage[]>,
653
+ newMessages: AgentMessage[],
654
+ telemetry: AgentTelemetry | undefined,
655
+ stepCount: number,
656
+ ): void {
657
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
658
+ stream.end(newMessages);
659
+ }
660
+
639
661
  /**
640
662
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
641
663
  * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -664,245 +686,287 @@ async function runLoopBody(
664
686
  stepCounter: StepCounter,
665
687
  streamFn?: StreamFn,
666
688
  ): Promise<void> {
667
- let firstTurn = true;
668
- // Check for steering messages at start (user may have typed while waiting).
669
- // Skip when the run is already externally aborted — dequeuing would strand
670
- // the messages in a run that is about to die.
671
- let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
672
- let harmonyRetryAttempt = 0;
673
- let harmonyTruncateResumeCount = 0;
674
- let pausedTurnContinuations = 0;
675
-
676
- // Outer loop: continues when queued follow-up messages arrive after agent would stop
677
- while (true) {
678
- let hasMoreToolCalls = true;
679
-
680
- // Inner loop: process tool calls and steering messages
681
- while (hasMoreToolCalls || pendingMessages.length > 0) {
682
- // Yield at the top of each iteration to prevent busy-wait when
683
- // the agent loop is executing tool calls back-to-back.
684
- await yieldIfDue();
685
- if (!firstTurn) {
686
- stream.push({ type: "turn_start" });
687
- } else {
688
- firstTurn = false;
689
- }
689
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
690
+ if (config.deadline !== undefined) {
691
+ const deadlineAbortController = new AbortController();
692
+ const delay = config.deadline - Date.now();
693
+ if (delay <= 0) {
694
+ deadlineAbortController.abort("Deadline exceeded");
695
+ } else {
696
+ deadlineTimer = setTimeout(() => {
697
+ deadlineAbortController.abort("Deadline exceeded");
698
+ }, delay);
699
+ }
700
+ signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
701
+ }
690
702
 
691
- // Process pending messages (inject before next assistant response)
692
- if (pendingMessages.length > 0) {
693
- for (const message of pendingMessages) {
694
- stream.push({ type: "message_start", message });
695
- stream.push({ type: "message_end", message });
696
- currentContext.messages.push(message);
697
- newMessages.push(message);
703
+ try {
704
+ let firstTurn = true;
705
+ if (isDeadlineExceeded(config.deadline)) {
706
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
707
+ return;
708
+ }
709
+ // Check for steering messages at start (user may have typed while waiting).
710
+ // Skip when the run is already externally aborted — dequeuing would strand
711
+ // the messages in a run that is about to die.
712
+ let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
713
+ let harmonyRetryAttempt = 0;
714
+ let harmonyTruncateResumeCount = 0;
715
+ let pausedTurnContinuations = 0;
716
+
717
+ // Outer loop: continues when queued follow-up messages arrive after agent would stop
718
+ while (true) {
719
+ let hasMoreToolCalls = true;
720
+
721
+ // Inner loop: process tool calls and steering messages
722
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
723
+ if (isDeadlineExceeded(config.deadline)) {
724
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
725
+ return;
726
+ }
727
+ // Yield at the top of each iteration to prevent busy-wait when
728
+ // the agent loop is executing tool calls back-to-back.
729
+ await yieldIfDue();
730
+ if (!firstTurn) {
731
+ stream.push({ type: "turn_start" });
732
+ } else {
733
+ firstTurn = false;
698
734
  }
699
- pendingMessages = [];
700
- }
701
735
 
702
- // Refresh prompt/tool context from live state before each model call
703
- if (config.syncContextBeforeModelCall) {
704
- await config.syncContextBeforeModelCall(currentContext);
705
- }
736
+ // Process pending messages (inject before next assistant response)
737
+ if (pendingMessages.length > 0) {
738
+ for (const message of pendingMessages) {
739
+ stream.push({ type: "message_start", message });
740
+ stream.push({ type: "message_end", message });
741
+ currentContext.messages.push(message);
742
+ newMessages.push(message);
743
+ }
744
+ pendingMessages = [];
745
+ }
706
746
 
707
- // Stream assistant response
708
- let recovered: HarmonyRecoveredToolCall | undefined;
709
- let message: AssistantMessage;
710
- try {
711
- message = await streamAssistantResponse(
712
- currentContext,
713
- config,
714
- signal,
715
- stream,
716
- telemetry,
717
- invokeAgentSpan,
718
- stepCounter,
719
- streamFn,
720
- harmonyRetryAttempt,
721
- );
722
- harmonyRetryAttempt = 0;
723
- harmonyTruncateResumeCount = 0;
724
- } catch (err) {
725
- if (!(err instanceof HarmonyLeakInterruption)) throw err;
726
- if (err.recovered) {
727
- if (harmonyTruncateResumeCount >= 2) {
728
- await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
729
- throw new Error(
730
- `GPT-5 Harmony leak recurred after truncate-and-resume recovery (${signalListLabel(err.detection.signals)}).`,
731
- );
747
+ // Refresh prompt/tool context from live state before each model call
748
+ if (config.syncContextBeforeModelCall) {
749
+ await config.syncContextBeforeModelCall(currentContext);
750
+ }
751
+
752
+ // Stream assistant response
753
+ let recovered: HarmonyRecoveredToolCall | undefined;
754
+ let message: AssistantMessage;
755
+ try {
756
+ message = await streamAssistantResponse(
757
+ currentContext,
758
+ config,
759
+ signal,
760
+ stream,
761
+ telemetry,
762
+ invokeAgentSpan,
763
+ stepCounter,
764
+ streamFn,
765
+ harmonyRetryAttempt,
766
+ );
767
+ harmonyRetryAttempt = 0;
768
+ harmonyTruncateResumeCount = 0;
769
+ } catch (err) {
770
+ if (!(err instanceof HarmonyLeakInterruption)) throw err;
771
+ if (err.recovered) {
772
+ if (harmonyTruncateResumeCount >= 2) {
773
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
774
+ throw new Error(
775
+ `GPT-5 Harmony leak recurred after truncate-and-resume recovery (${signalListLabel(err.detection.signals)}).`,
776
+ );
777
+ }
778
+ harmonyTruncateResumeCount++;
779
+ recovered = err.recovered;
780
+ message = recovered.message;
781
+ await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
782
+ } else {
783
+ if (harmonyRetryAttempt >= 2) {
784
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
785
+ throw new Error(
786
+ `GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (${signalListLabel(err.detection.signals)}).`,
787
+ );
788
+ }
789
+ await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
790
+ harmonyRetryAttempt++;
791
+ continue;
732
792
  }
733
- harmonyTruncateResumeCount++;
734
- recovered = err.recovered;
735
- message = recovered.message;
736
- await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
737
- } else {
738
- if (harmonyRetryAttempt >= 2) {
739
- await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
740
- throw new Error(
741
- `GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (${signalListLabel(err.detection.signals)}).`,
742
- );
793
+ }
794
+ if (recovered) {
795
+ message = snapshotAssistantMessage(message);
796
+ currentContext.messages.push(message);
797
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
798
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
799
+ }
800
+ newMessages.push(message);
801
+
802
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
803
+ // Create placeholder tool results for any tool calls in the aborted message
804
+ // This maintains the tool_use/tool_result pairing that the API requires
805
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
806
+ const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
807
+ const toolResults: ToolResultMessage[] = [];
808
+ for (const toolCall of toolCalls) {
809
+ const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
810
+ currentContext.messages.push(result);
811
+ newMessages.push(result);
812
+ toolResults.push(result);
813
+ // The placeholder result above keeps the API's tool_use/tool_result
814
+ // pairing intact, but no execute_tool span is started for these
815
+ // calls. Mirror the run-collector entry directly so the run
816
+ // summary's tool counters and `coverage.toolsInvoked` reflect
817
+ // what the user actually saw on the wire.
818
+ recordSkippedTool(telemetry, {
819
+ toolCallId: toolCall.id,
820
+ toolName: toolCall.name,
821
+ status: message.stopReason === "aborted" ? "aborted" : "error",
822
+ });
743
823
  }
744
- await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
745
- harmonyRetryAttempt++;
746
- continue;
824
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
825
+
826
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
827
+ stream.end(newMessages);
828
+ return;
747
829
  }
748
- }
749
- if (recovered) {
750
- message = snapshotAssistantMessage(message);
751
- currentContext.messages.push(message);
752
- stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
753
- stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
754
- }
755
- newMessages.push(message);
756
830
 
757
- if (message.stopReason === "error" || message.stopReason === "aborted") {
758
- // Create placeholder tool results for any tool calls in the aborted message
759
- // This maintains the tool_use/tool_result pairing that the API requires
831
+ // Run tools whenever the turn carries tool_use blocks AND was not truncated.
832
+ // `stop_reason` is provider metadata that never goes back on the wire, so it
833
+ // does not gate continuation validity: replaying a tool_use turn with the
834
+ // tool_results appended is accepted whether the turn ended on `tool_use` or
835
+ // `end_turn` (adaptive/interleaved-thinking Opus routinely emits tool calls
836
+ // under `end_turn`; verified against the live Anthropic API). The only
837
+ // continuation hazard is a thinking block carrying a stale/invalid signature,
838
+ // which `transformMessages` already neutralizes — it strips the signature on
839
+ // non-`toolUse` turns and the encoder downgrades the unsigned block to text,
840
+ // which the API accepts. So treat `stop` (end_turn/pause_turn) the same as
841
+ // `toolUse`. `length` (max_tokens) is the one reason we must NOT run: the
842
+ // trailing tool_use may be truncated with incomplete arguments — those calls
843
+ // are abandoned below. (`error`/`aborted` already returned above.)
760
844
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
761
845
  const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
762
- const toolResults: ToolResultMessage[] = [];
763
- for (const toolCall of toolCalls) {
764
- const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
765
- currentContext.messages.push(result);
766
- newMessages.push(result);
767
- toolResults.push(result);
768
- // The placeholder result above keeps the API's tool_use/tool_result
769
- // pairing intact, but no execute_tool span is started for these
770
- // calls. Mirror the run-collector entry directly so the run
771
- // summary's tool counters and `coverage.toolsInvoked` reflect
772
- // what the user actually saw on the wire.
773
- recordSkippedTool(telemetry, {
774
- toolCallId: toolCall.id,
775
- toolName: toolCall.name,
776
- status: message.stopReason === "aborted" ? "aborted" : "error",
777
- });
846
+ const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
847
+ hasMoreToolCalls = runnableStop && toolCalls.length > 0;
848
+
849
+ const deadlinePassed = isDeadlineExceeded(config.deadline);
850
+ if (hasMoreToolCalls && deadlinePassed) {
851
+ hasMoreToolCalls = false;
778
852
  }
779
- await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
780
853
 
781
- stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
782
- stream.end(newMessages);
783
- return;
784
- }
854
+ const toolResults: ToolResultMessage[] = [];
855
+ if (hasMoreToolCalls) {
856
+ const executionResult = await executeToolCalls(
857
+ currentContext,
858
+ message,
859
+ signal,
860
+ stream,
861
+ config,
862
+ telemetry,
863
+ invokeAgentSpan,
864
+ );
785
865
 
786
- // Run tools whenever the turn carries tool_use blocks AND was not truncated.
787
- // `stop_reason` is provider metadata that never goes back on the wire, so it
788
- // does not gate continuation validity: replaying a tool_use turn with the
789
- // tool_results appended is accepted whether the turn ended on `tool_use` or
790
- // `end_turn` (adaptive/interleaved-thinking Opus routinely emits tool calls
791
- // under `end_turn`; verified against the live Anthropic API). The only
792
- // continuation hazard is a thinking block carrying a stale/invalid signature,
793
- // which `transformMessages` already neutralizes — it strips the signature on
794
- // non-`toolUse` turns and the encoder downgrades the unsigned block to text,
795
- // which the API accepts. So treat `stop` (end_turn/pause_turn) the same as
796
- // `toolUse`. `length` (max_tokens) is the one reason we must NOT run: the
797
- // trailing tool_use may be truncated with incomplete arguments — those calls
798
- // are abandoned below. (`error`/`aborted` already returned above.)
799
- type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
800
- const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
801
- const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
802
- hasMoreToolCalls = runnableStop && toolCalls.length > 0;
803
-
804
- const toolResults: ToolResultMessage[] = [];
805
- if (hasMoreToolCalls) {
806
- const executionResult = await executeToolCalls(
807
- currentContext,
808
- message,
809
- signal,
810
- stream,
811
- config,
812
- telemetry,
813
- invokeAgentSpan,
814
- );
866
+ toolResults.push(...executionResult.toolResults);
815
867
 
816
- toolResults.push(...executionResult.toolResults);
868
+ for (const result of toolResults) {
869
+ currentContext.messages.push(result);
870
+ newMessages.push(result);
871
+ }
872
+ } else if (toolCalls.length > 0) {
873
+ // Turn ended on a non-runnable reason (`length` truncation) or deadline was exceeded
874
+ // but left toolCall blocks behind. pair each with a placeholder result.
875
+ const skipReason = deadlinePassed ? "aborted" : message.stopReason === "length" ? "length" : "skipped";
876
+ const skipErrMsg = deadlinePassed ? "Deadline exceeded" : undefined;
877
+ for (const toolCall of toolCalls) {
878
+ const result = createAbortedToolResult(toolCall, stream, skipReason, skipErrMsg);
879
+ currentContext.messages.push(result);
880
+ newMessages.push(result);
881
+ toolResults.push(result);
882
+ recordSkippedTool(telemetry, {
883
+ toolCallId: toolCall.id,
884
+ toolName: toolCall.name,
885
+ status: deadlinePassed ? "aborted" : "skipped",
886
+ });
887
+ }
888
+ if (message.stopReason === "length" && toolResults.length > 0 && !deadlinePassed) {
889
+ hasMoreToolCalls = true;
890
+ }
891
+ }
817
892
 
818
- for (const result of toolResults) {
819
- currentContext.messages.push(result);
820
- newMessages.push(result);
893
+ if (toolCalls.length > 0) {
894
+ pausedTurnContinuations = 0;
895
+ } else if (
896
+ !hasMoreToolCalls &&
897
+ message.stopReason === "stop" &&
898
+ message.stopDetails?.type === "pause_turn" &&
899
+ pausedTurnContinuations < MAX_PAUSED_TURN_CONTINUATIONS
900
+ ) {
901
+ // Non-terminal stop: the provider ended the response but not the turn
902
+ // (e.g. Codex `end_turn: false` on a commentary-only progress update).
903
+ // Re-sample with the assistant message replayed so the model keeps
904
+ // working; the next round folds steering/asides in like any other
905
+ // mid-work turn.
906
+ pausedTurnContinuations++;
907
+ hasMoreToolCalls = true;
821
908
  }
822
- } else if (toolCalls.length > 0) {
823
- // Turn ended on a non-runnable reason (`length` truncation) but left
824
- // toolCall blocks behind. The trailing call's arguments may be incomplete,
825
- // so don't execute or continue — pair each with a placeholder result to keep
826
- // the tool_use/tool_result contract valid for any later request that
827
- // replays this turn. When the truncation was `length`, surface an actionable
828
- // hint so the model doesn't loop by re-emitting the same oversized payload
829
- // (e.g. 1000+ line `write` content blowing past the model's output cap).
830
- const skipReason = message.stopReason === "length" ? "length" : "skipped";
831
- for (const toolCall of toolCalls) {
832
- const result = createAbortedToolResult(toolCall, stream, skipReason);
833
- currentContext.messages.push(result);
834
- newMessages.push(result);
835
- toolResults.push(result);
836
- recordSkippedTool(telemetry, {
837
- toolCallId: toolCall.id,
838
- toolName: toolCall.name,
839
- status: "skipped",
840
- });
909
+
910
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
911
+
912
+ if (isDeadlineExceeded(config.deadline)) {
913
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
914
+ return;
841
915
  }
842
- if (message.stopReason === "length" && toolResults.length > 0) {
843
- hasMoreToolCalls = true;
916
+ // On external abort (user interrupt), leave the steering queue intact: the
917
+ // session aborts then continues, delivering the queue into a fresh run.
918
+ // Draining it here would inject the messages right before a model call that
919
+ // instantly aborts — message lands in history, agent never responds. The
920
+ // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
921
+ // still owns every message until this dequeue.
922
+ const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
923
+ if (hasMoreToolCalls) {
924
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
925
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
926
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
927
+ } else {
928
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
929
+ // asides for the outer drain below so a passive aside can't trigger an extra model
930
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
931
+ pendingMessages = steering;
844
932
  }
845
933
  }
846
934
 
847
- if (toolCalls.length > 0) {
848
- pausedTurnContinuations = 0;
849
- } else if (
850
- !hasMoreToolCalls &&
851
- message.stopReason === "stop" &&
852
- message.stopDetails?.type === "pause_turn" &&
853
- pausedTurnContinuations < MAX_PAUSED_TURN_CONTINUATIONS
854
- ) {
855
- // Non-terminal stop: the provider ended the response but not the turn
856
- // (e.g. Codex `end_turn: false` on a commentary-only progress update).
857
- // Re-sample with the assistant message replayed so the model keeps
858
- // working; the next round folds steering/asides in like any other
859
- // mid-work turn.
860
- pausedTurnContinuations++;
861
- hasMoreToolCalls = true;
935
+ if (isDeadlineExceeded(config.deadline)) {
936
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
937
+ return;
862
938
  }
863
939
 
864
- await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
865
-
866
- // On external abort (user interrupt), leave the steering queue intact: the
867
- // session aborts then continues, delivering the queue into a fresh run.
868
- // Draining it here would inject the messages right before a model call that
869
- // instantly aborts — message lands in history, agent never responds. The
870
- // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
871
- // still owns every message until this dequeue.
872
- const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
873
- if (hasMoreToolCalls) {
874
- // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
875
- const asides = resolveAsides(await config.getAsideMessages?.());
876
- pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
877
- } else {
878
- // Stop boundary: only steering (live user input) forces another turn here. Leave
879
- // asides for the outer drain below so a passive aside can't trigger an extra model
880
- // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
881
- pendingMessages = steering;
940
+ // Agent would stop here. Drain non-interrupting asides + follow-up messages.
941
+ await config.onBeforeYield?.();
942
+
943
+ if (isDeadlineExceeded(config.deadline)) {
944
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
945
+ return;
946
+ }
947
+ // Skip queue drains when externally aborted (same stranding hazard as above).
948
+ // Re-poll steering too: a steer can land between the stop-boundary dequeue
949
+ // above and this yield point (e.g. queued while onBeforeYield ran). Without
950
+ // this poll it would strand in the queue until the next manual prompt.
951
+ const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
952
+ const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
953
+ const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || [];
954
+ if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
955
+ // Set as pending so the inner loop processes them before stopping.
956
+ pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages];
957
+ continue;
882
958
  }
883
- }
884
959
 
885
- // Agent would stop here. Drain non-interrupting asides + follow-up messages.
886
- await config.onBeforeYield?.();
887
- // Skip queue drains when externally aborted (same stranding hazard as above).
888
- // Re-poll steering too: a steer can land between the stop-boundary dequeue
889
- // above and this yield point (e.g. queued while onBeforeYield ran). Without
890
- // this poll it would strand in the queue until the next manual prompt.
891
- const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
892
- const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
893
- const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || [];
894
- if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
895
- // Set as pending so the inner loop processes them before stopping.
896
- pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages];
897
- continue;
960
+ // No more messages, exit
961
+ break;
898
962
  }
899
963
 
900
- // No more messages, exit
901
- break;
964
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
965
+ } finally {
966
+ if (deadlineTimer) {
967
+ clearTimeout(deadlineTimer);
968
+ }
902
969
  }
903
-
904
- stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
905
- stream.end(newMessages);
906
970
  }
907
971
 
908
972
  async function emitHarmonyAudit(
@@ -985,17 +1049,6 @@ async function streamAssistantResponse(
985
1049
 
986
1050
  const streamFunction = streamFn || streamSimple;
987
1051
 
988
- // Resolve API key (important for expiring tokens) — do this before resolving
989
- // metadata so that the session-sticky credential recorded by getApiKey is
990
- // visible to metadataResolver (e.g. for the correct account_uuid in metadata.user_id).
991
- const staticApiKey = typeof config.apiKey === "string" ? config.apiKey : undefined;
992
- const resolvedApiKey =
993
- (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || staticApiKey;
994
-
995
- // Re-resolve metadata after credential selection so the per-request value
996
- // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
997
- const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
998
-
999
1052
  const dynamicToolChoice = config.getToolChoice?.();
1000
1053
  const dynamicReasoning = config.getReasoning?.();
1001
1054
  const dynamicDisableReasoning = config.getDisableReasoning?.();
@@ -1006,7 +1059,6 @@ async function streamAssistantResponse(
1006
1059
  ? AbortSignal.any([signal, harmonyAbortController.signal])
1007
1060
  : harmonyAbortController.signal
1008
1061
  : signal;
1009
- const repetitionAbortController = new AbortController();
1010
1062
  // Owned tool calling: aborted by the stream wrapper when the model starts
1011
1063
  // fabricating a `<tool_response>`, so the provider stops generating the rest of
1012
1064
  // the hallucinated turn. Merged into the provider signal ONLY (not
@@ -1015,10 +1067,20 @@ async function streamAssistantResponse(
1015
1067
  const promptToolAbortController = ownedDialect ? new AbortController() : undefined;
1016
1068
  const providerAbortSignals: AbortSignal[] = [];
1017
1069
  if (requestSignal) providerAbortSignals.push(requestSignal);
1018
- providerAbortSignals.push(repetitionAbortController.signal);
1019
1070
  if (promptToolAbortController) providerAbortSignals.push(promptToolAbortController.signal);
1020
1071
  const finalRequestSignal =
1021
- providerAbortSignals.length === 1 ? providerAbortSignals[0]! : AbortSignal.any(providerAbortSignals);
1072
+ providerAbortSignals.length === 0
1073
+ ? undefined
1074
+ : providerAbortSignals.length === 1
1075
+ ? providerAbortSignals[0]!
1076
+ : AbortSignal.any(providerAbortSignals);
1077
+ const requestApiKey = (config.getApiKey ? await config.getApiKey(config.model) : undefined) ?? config.apiKey;
1078
+ const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1079
+ const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1080
+
1081
+ // Re-resolve metadata after credential selection so the per-request value
1082
+ // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
1083
+ const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
1022
1084
  const effectiveTemperature =
1023
1085
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1024
1086
  // Owned tool calling sends no native tools, so any tool_choice would error.
@@ -1069,19 +1131,7 @@ async function streamAssistantResponse(
1069
1131
  return await runInActiveSpan(chatSpan, async () => {
1070
1132
  let response = await streamFunction(config.model, llmContext, {
1071
1133
  ...config,
1072
- // Hand streamSimple a resolver so its central auth-retry policy can
1073
- // re-resolve on 401 / usage-limit: the initial step reuses the key
1074
- // already resolved above (which set the session-sticky credential
1075
- // feeding metadataResolver), and retry steps forward the a/b/c ctx
1076
- // to config.getApiKey (force-refresh, then rotate). With no
1077
- // getApiKey hook the caller's own apiKey (string or resolver) flows
1078
- // through unchanged.
1079
- apiKey: config.getApiKey
1080
- ? (ctx: ApiKeyResolveContext) =>
1081
- ctx.error === undefined
1082
- ? resolvedApiKey
1083
- : Promise.resolve(config.getApiKey!(config.model.provider, ctx))
1084
- : config.apiKey,
1134
+ apiKey,
1085
1135
  metadata: resolvedMetadata,
1086
1136
  toolChoice: effectiveToolChoice,
1087
1137
  reasoning: effectiveReasoning,
@@ -1130,56 +1180,6 @@ async function streamAssistantResponse(
1130
1180
  return aborted;
1131
1181
  };
1132
1182
 
1133
- const finishRepetitionStream = async (
1134
- kind: "text" | "thinking",
1135
- pattern: string,
1136
- count: number,
1137
- ): Promise<AssistantMessage> => {
1138
- repetitionAbortController.abort();
1139
- try {
1140
- const cleanup = responseIterator.return?.();
1141
- if (cleanup) void cleanup.catch(() => {});
1142
- } catch {
1143
- // ignore
1144
- }
1145
- if (partialMessage) {
1146
- truncateRepetition(partialMessage, kind, pattern);
1147
- partialMessage.stopReason = "error";
1148
- partialMessage.errorMessage = `Repetition loop detected: assistant repeated "${pattern.trim()}" ${count} times consecutively.`;
1149
- }
1150
- const finalMsg = snapshotAssistantMessage(
1151
- partialMessage ?? {
1152
- role: "assistant",
1153
- content: [],
1154
- api: config.model.api,
1155
- provider: config.model.provider,
1156
- model: config.model.id,
1157
- usage: {
1158
- input: 0,
1159
- output: 0,
1160
- cacheRead: 0,
1161
- cacheWrite: 0,
1162
- totalTokens: 0,
1163
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1164
- },
1165
- stopReason: "error",
1166
- errorMessage: `Repetition loop detected.`,
1167
- timestamp: Date.now(),
1168
- },
1169
- );
1170
- if (addedPartial) {
1171
- context.messages[context.messages.length - 1] = finalMsg;
1172
- } else {
1173
- context.messages.push(finalMsg);
1174
- }
1175
- if (!addedPartial) {
1176
- stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMsg) });
1177
- }
1178
- stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMsg) });
1179
- await finishChat(finalMsg);
1180
- return finalMsg;
1181
- };
1182
-
1183
1183
  // Set up a single abort race: register the abort listener once for the whole
1184
1184
  // stream and reuse the same race promise for every iterator.next() instead of
1185
1185
  // allocating Promise.withResolvers and add/removeEventListener per event.
@@ -1196,14 +1196,6 @@ async function streamAssistantResponse(
1196
1196
  detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
1197
1197
  }
1198
1198
 
1199
- // Rolling tail of streamed text/thinking used for repetition-loop detection.
1200
- // Bounded to REPETITION_WINDOW chars and reset when the active block kind
1201
- // switches (text <-> thinking) so detection stays O(1) per delta and never
1202
- // miscounts a repeated unit across a thinking/answer boundary.
1203
- let repetitionTail = "";
1204
- let repetitionKind: "text" | "thinking" | undefined;
1205
- const isGeminiModel = config.model.provider.includes("google") || config.model.provider.includes("gemini");
1206
-
1207
1199
  try {
1208
1200
  while (true) {
1209
1201
  let next: IteratorResult<AssistantMessageEvent>;
@@ -1239,6 +1231,12 @@ async function streamAssistantResponse(
1239
1231
  }
1240
1232
  }
1241
1233
  finalMessage = snapshotAssistantMessage(finalMessage);
1234
+ // Expand inline macros (and any other registered rewrite) on the
1235
+ // finalized message before it reaches the context, the UI, or tool
1236
+ // dispatch — so a single mutation is the source of truth for all three.
1237
+ if (config.transformAssistantMessage) {
1238
+ await config.transformAssistantMessage(finalMessage, requestSignal);
1239
+ }
1242
1240
  if (addedPartial) {
1243
1241
  context.messages[context.messages.length - 1] = finalMessage;
1244
1242
  } else {
@@ -1262,9 +1260,19 @@ async function streamAssistantResponse(
1262
1260
  switch (event.type) {
1263
1261
  case "start":
1264
1262
  partialMessage = event.partial;
1265
- context.messages.push(partialMessage);
1266
- addedPartial = true;
1267
- stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
1263
+ if (addedPartial) {
1264
+ context.messages[context.messages.length - 1] = partialMessage;
1265
+ completedToolCallIds.clear();
1266
+ stream.push({
1267
+ type: "message_update",
1268
+ assistantMessageEvent: snapshotAssistantMessageEvent(event),
1269
+ message: snapshotAssistantMessage(partialMessage),
1270
+ });
1271
+ } else {
1272
+ context.messages.push(partialMessage);
1273
+ addedPartial = true;
1274
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
1275
+ }
1268
1276
  break;
1269
1277
 
1270
1278
  case "text_start":
@@ -1288,27 +1296,6 @@ async function streamAssistantResponse(
1288
1296
  assistantMessageEvent: snapshotAssistantMessageEvent(event),
1289
1297
  message: snapshotAssistantMessage(partialMessage),
1290
1298
  });
1291
-
1292
- if (isGeminiModel && (event.type === "text_delta" || event.type === "thinking_delta")) {
1293
- const kind = event.type === "text_delta" ? "text" : "thinking";
1294
- if (repetitionKind !== kind) {
1295
- repetitionKind = kind;
1296
- repetitionTail = "";
1297
- }
1298
- repetitionTail += event.delta;
1299
- if (repetitionTail.length > REPETITION_WINDOW) {
1300
- repetitionTail = repetitionTail.slice(-REPETITION_WINDOW);
1301
- }
1302
- const repetition = detectRepetition(repetitionTail);
1303
- if (repetition) {
1304
- const [pattern, count] = repetition;
1305
- logger.warn("Repetition loop detected during assistant stream, aborting.", {
1306
- pattern,
1307
- count,
1308
- });
1309
- return await finishRepetitionStream(kind, pattern, count);
1310
- }
1311
- }
1312
1299
  }
1313
1300
  break;
1314
1301
  }
@@ -1944,97 +1931,3 @@ function createSkippedToolResult(): AgentToolResult<any> {
1944
1931
  details: {},
1945
1932
  };
1946
1933
  }
1947
-
1948
- const REPETITION_WINDOW = 250;
1949
- const REPETITION_MIN_REPEATED_CHARS = 180;
1950
-
1951
- function detectRepetition(text: string): [pattern: string, count: number] | null {
1952
- if (text.length < REPETITION_MIN_REPEATED_CHARS) return null;
1953
-
1954
- const windowSize = Math.min(text.length, REPETITION_WINDOW);
1955
- const searchSpace = text.slice(-windowSize);
1956
-
1957
- for (let len = 2; len <= 60; len++) {
1958
- if (searchSpace.length < len * 4) continue;
1959
-
1960
- const pattern = searchSpace.slice(-len);
1961
- // Only treat a repeated unit as a pathological loop when it carries real
1962
- // linguistic content (a letter or a pictographic emoji). Runs made purely of
1963
- // digits, whitespace or punctuation are legitimate in tabular / hex / numeric
1964
- // output (e.g. "00 00 00", "0, 0, 0", "| -- | -- |") and must not trip.
1965
- if (!/[\p{L}\p{Extended_Pictographic}]/u.test(pattern)) continue;
1966
-
1967
- let count = 0;
1968
- let pos = searchSpace.length;
1969
- while (pos >= len) {
1970
- const chunk = searchSpace.slice(pos - len, pos);
1971
- if (chunk === pattern) {
1972
- count++;
1973
- pos -= len;
1974
- } else {
1975
- break;
1976
- }
1977
- }
1978
-
1979
- if (count >= 4 && len * count >= REPETITION_MIN_REPEATED_CHARS) {
1980
- return [pattern, count];
1981
- }
1982
- }
1983
- return null;
1984
- }
1985
-
1986
- function truncateRepetition(message: AssistantMessage, kind: "text" | "thinking", pattern: string): void {
1987
- // A repetition loop streams into a single growing block (real providers) or a run
1988
- // of same-kind blocks (some transports), always at the tail of the message. Gather
1989
- // that trailing contiguous run and collapse its repeated copies down to one, so the
1990
- // committed transcript keeps a representative sample instead of the full runaway.
1991
- const matches = (block: AssistantContentBlock): boolean =>
1992
- kind === "text" ? block.type === "text" : block.type === "thinking";
1993
- const readBlock = (block: AssistantContentBlock): string =>
1994
- block.type === "text" ? block.text : block.type === "thinking" ? block.thinking : "";
1995
- const clearThinkingReplayAnchors = (block: AssistantContentBlock): void => {
1996
- if (block.type !== "thinking") return;
1997
- block.thinkingSignature = undefined;
1998
- block.itemId = undefined;
1999
- };
2000
- const writeBlock = (block: AssistantContentBlock, value: string): void => {
2001
- if (block.type === "text") {
2002
- block.text = value;
2003
- } else if (block.type === "thinking") {
2004
- block.thinking = value;
2005
- clearThinkingReplayAnchors(block);
2006
- }
2007
- };
2008
-
2009
- const trailing: AssistantContentBlock[] = [];
2010
- for (let i = message.content.length - 1; i >= 0; i--) {
2011
- const block = message.content[i];
2012
- if (!matches(block)) break;
2013
- trailing.unshift(block);
2014
- }
2015
- if (trailing.length === 0) return;
2016
- if (kind === "thinking") {
2017
- for (const block of trailing) clearThinkingReplayAnchors(block);
2018
- }
2019
-
2020
- let joined = "";
2021
- for (const block of trailing) joined += readBlock(block);
2022
-
2023
- let kept = joined;
2024
- while (kept.length >= pattern.length * 2 && kept.slice(kept.length - pattern.length * 2) === pattern + pattern) {
2025
- kept = kept.slice(0, kept.length - pattern.length);
2026
- }
2027
-
2028
- let remainingToRemove = joined.length - kept.length;
2029
- for (let i = trailing.length - 1; i >= 0 && remainingToRemove > 0; i--) {
2030
- const block = trailing[i];
2031
- const value = readBlock(block);
2032
- if (value.length <= remainingToRemove) {
2033
- remainingToRemove -= value.length;
2034
- writeBlock(block, "");
2035
- } else {
2036
- writeBlock(block, value.slice(0, value.length - remainingToRemove));
2037
- remainingToRemove = 0;
2038
- }
2039
- }
2040
- }