@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/CHANGELOG.md +24 -0
- package/README.md +2 -2
- package/dist/types/agent.d.ts +18 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/tokenizer.d.ts +1 -0
- package/dist/types/types.d.ts +20 -5
- package/package.json +6 -6
- package/src/agent-loop.ts +310 -417
- package/src/agent.ts +26 -5
- package/src/compaction/compaction.ts +1 -1
- package/src/compaction/openai.ts +1 -1
- package/src/compaction/shake.ts +1 -1
- package/src/index.ts +2 -0
- package/src/proxy.ts +12 -0
- package/src/tokenizer.ts +17 -0
- package/src/types.ts +22 -5
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 {
|
|
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
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
message =
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
758
|
-
//
|
|
759
|
-
//
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
843
|
-
|
|
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 (
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
//
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
-
}
|