@oh-my-pi/pi-agent-core 16.0.4 → 16.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/dist/types/agent.d.ts +7 -5
- package/dist/types/types.d.ts +7 -5
- package/package.json +6 -6
- package/src/agent-loop.ts +286 -414
- package/src/agent.ts +10 -5
- package/src/types.ts +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.0.5] - 2026-06-17
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Changed `AgentOptions.getApiKey` and `AgentLoopConfig.getApiKey` to receive the active `Model` and return an API key or `ApiKeyResolver`, so credential routing stays model-scoped and retry context is no longer exposed through the agent-core API
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added agent-loop deadline support for graceful wall-clock session stops.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Changed Gemini repetition-loop detection to live in the pi-ai stream layer instead of the agent loop. The agent no longer runs its own Gemini-gated verbatim repetition check (`detectRepetition`/`truncateRepetition`); loops now surface as a retryable transient stream error that the standard auto-retry path discards and re-samples, rather than a committed contentful error message.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed `PI_DIALECT=minimax` being ignored by the owned tool-calling env selector. ([#2759](https://github.com/can1357/oh-my-pi/issues/2759))
|
|
22
|
+
|
|
5
23
|
## [16.0.1] - 2026-06-15
|
|
6
24
|
|
|
7
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -151,8 +151,8 @@ const agent = new Agent({
|
|
|
151
151
|
// Custom stream function (for proxy backends)
|
|
152
152
|
streamFn: streamProxy,
|
|
153
153
|
|
|
154
|
-
// Dynamic API key resolution (for expiring OAuth tokens)
|
|
155
|
-
getApiKey: async (
|
|
154
|
+
// Dynamic model-scoped API key resolution (for expiring OAuth tokens)
|
|
155
|
+
getApiKey: async (model) => tokenForModel(model),
|
|
156
156
|
|
|
157
157
|
// Tool execution context (late-bound UI/session access)
|
|
158
158
|
getToolContext: () => ({ /* app-defined */ }),
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ApiKey, type AssistantMessage, type AssistantMessageEvent, type Context, type CursorExecHandlers, type CursorToolResultHandler, type Effort, type ImageContent, type Message, type Model, type ProviderSessionState, type ServiceTier, type SimpleStreamOptions, type ThinkingBudgets, type ToolChoice } from "@oh-my-pi/pi-ai";
|
|
2
2
|
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
3
3
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
4
4
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
@@ -47,6 +47,8 @@ export interface AgentOptions {
|
|
|
47
47
|
* Custom stream function (for proxy backends, etc.). Default uses streamSimple.
|
|
48
48
|
*/
|
|
49
49
|
streamFn?: StreamFn;
|
|
50
|
+
/** Absolute wall-clock deadline in Unix epoch milliseconds. */
|
|
51
|
+
deadline?: number;
|
|
50
52
|
/**
|
|
51
53
|
* Optional session identifier forwarded to LLM providers.
|
|
52
54
|
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|
|
@@ -62,10 +64,10 @@ export interface AgentOptions {
|
|
|
62
64
|
*/
|
|
63
65
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
64
66
|
/**
|
|
65
|
-
* Resolves an API key dynamically for each LLM call.
|
|
66
|
-
* Useful for expiring tokens
|
|
67
|
+
* Resolves an API key or resolver dynamically for each LLM call.
|
|
68
|
+
* Useful for expiring tokens and model-scoped credential routing.
|
|
67
69
|
*/
|
|
68
|
-
getApiKey?: (
|
|
70
|
+
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
69
71
|
/**
|
|
70
72
|
* Inspect or replace provider payloads before they are sent.
|
|
71
73
|
*/
|
|
@@ -174,7 +176,7 @@ export interface AgentPromptOptions {
|
|
|
174
176
|
export declare class Agent {
|
|
175
177
|
#private;
|
|
176
178
|
streamFn: StreamFn;
|
|
177
|
-
getApiKey?: (
|
|
179
|
+
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
178
180
|
/**
|
|
179
181
|
* Hook invoked after tool arguments are validated and before execution.
|
|
180
182
|
* Reassign at any time to swap the implementation (e.g. on extension reload).
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ApiKey, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
|
|
2
2
|
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
3
3
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
4
4
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
@@ -29,6 +29,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
29
29
|
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|
|
30
30
|
*/
|
|
31
31
|
sessionId?: string;
|
|
32
|
+
/** Absolute wall-clock deadline in Unix epoch milliseconds. */
|
|
33
|
+
deadline?: number;
|
|
32
34
|
/**
|
|
33
35
|
* Optional resolver called per LLM request to produce request metadata.
|
|
34
36
|
* When set, the agent loop evaluates it **after** `getApiKey` resolves the
|
|
@@ -87,12 +89,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
87
89
|
*/
|
|
88
90
|
transformProviderContext?: (context: Context, model: Model) => Context;
|
|
89
91
|
/**
|
|
90
|
-
* Resolves
|
|
92
|
+
* Resolves the API key or resolver for the current model before each LLM call.
|
|
91
93
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
+
* Returning an ApiKeyResolver lets the stream retry policy refresh or rotate
|
|
95
|
+
* the model-scoped credential after auth/usage-limit errors.
|
|
94
96
|
*/
|
|
95
|
-
getApiKey?: (
|
|
97
|
+
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
96
98
|
/**
|
|
97
99
|
* Returns steering messages to inject into the conversation mid-run.
|
|
98
100
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "16.0.
|
|
4
|
+
"version": "16.0.5",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "16.0.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.0.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
42
|
-
"@oh-my-pi/snapcompact": "16.0.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.0.5",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.0.5",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.0.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.0.5",
|
|
42
|
+
"@oh-my-pi/snapcompact": "16.0.5",
|
|
43
43
|
"@opentelemetry/api": "^1.9.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
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,
|
|
9
8
|
type Context,
|
|
10
9
|
EventStream,
|
|
10
|
+
isApiKeyResolver,
|
|
11
11
|
isZodSchema,
|
|
12
|
+
resolveApiKeyOnce,
|
|
13
|
+
seedApiKeyResolver,
|
|
12
14
|
streamSimple,
|
|
13
15
|
type ToolResultMessage,
|
|
14
16
|
type TSchema,
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
signalListLabel,
|
|
34
36
|
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
35
37
|
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
36
|
-
import {
|
|
38
|
+
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
37
39
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
38
40
|
import {
|
|
39
41
|
type AgentTelemetry,
|
|
@@ -111,6 +113,7 @@ function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefi
|
|
|
111
113
|
case "qwen3":
|
|
112
114
|
case "gemini":
|
|
113
115
|
case "gemma":
|
|
116
|
+
case "minimax":
|
|
114
117
|
return value;
|
|
115
118
|
default:
|
|
116
119
|
return undefined;
|
|
@@ -636,6 +639,20 @@ interface StepCounter {
|
|
|
636
639
|
count: number;
|
|
637
640
|
}
|
|
638
641
|
|
|
642
|
+
function isDeadlineExceeded(deadline: number | undefined): boolean {
|
|
643
|
+
return deadline !== undefined && Date.now() >= deadline;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function endAgentStream(
|
|
647
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
648
|
+
newMessages: AgentMessage[],
|
|
649
|
+
telemetry: AgentTelemetry | undefined,
|
|
650
|
+
stepCount: number,
|
|
651
|
+
): void {
|
|
652
|
+
stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
|
|
653
|
+
stream.end(newMessages);
|
|
654
|
+
}
|
|
655
|
+
|
|
639
656
|
/**
|
|
640
657
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
641
658
|
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
|
@@ -664,245 +681,287 @@ async function runLoopBody(
|
|
|
664
681
|
stepCounter: StepCounter,
|
|
665
682
|
streamFn?: StreamFn,
|
|
666
683
|
): 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
|
-
}
|
|
684
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
685
|
+
if (config.deadline !== undefined) {
|
|
686
|
+
const deadlineAbortController = new AbortController();
|
|
687
|
+
const delay = config.deadline - Date.now();
|
|
688
|
+
if (delay <= 0) {
|
|
689
|
+
deadlineAbortController.abort("Deadline exceeded");
|
|
690
|
+
} else {
|
|
691
|
+
deadlineTimer = setTimeout(() => {
|
|
692
|
+
deadlineAbortController.abort("Deadline exceeded");
|
|
693
|
+
}, delay);
|
|
694
|
+
}
|
|
695
|
+
signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
|
|
696
|
+
}
|
|
690
697
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
+
try {
|
|
699
|
+
let firstTurn = true;
|
|
700
|
+
if (isDeadlineExceeded(config.deadline)) {
|
|
701
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Check for steering messages at start (user may have typed while waiting).
|
|
705
|
+
// Skip when the run is already externally aborted — dequeuing would strand
|
|
706
|
+
// the messages in a run that is about to die.
|
|
707
|
+
let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
|
|
708
|
+
let harmonyRetryAttempt = 0;
|
|
709
|
+
let harmonyTruncateResumeCount = 0;
|
|
710
|
+
let pausedTurnContinuations = 0;
|
|
711
|
+
|
|
712
|
+
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
713
|
+
while (true) {
|
|
714
|
+
let hasMoreToolCalls = true;
|
|
715
|
+
|
|
716
|
+
// Inner loop: process tool calls and steering messages
|
|
717
|
+
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
|
718
|
+
if (isDeadlineExceeded(config.deadline)) {
|
|
719
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Yield at the top of each iteration to prevent busy-wait when
|
|
723
|
+
// the agent loop is executing tool calls back-to-back.
|
|
724
|
+
await yieldIfDue();
|
|
725
|
+
if (!firstTurn) {
|
|
726
|
+
stream.push({ type: "turn_start" });
|
|
727
|
+
} else {
|
|
728
|
+
firstTurn = false;
|
|
698
729
|
}
|
|
699
|
-
pendingMessages = [];
|
|
700
|
-
}
|
|
701
730
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
731
|
+
// Process pending messages (inject before next assistant response)
|
|
732
|
+
if (pendingMessages.length > 0) {
|
|
733
|
+
for (const message of pendingMessages) {
|
|
734
|
+
stream.push({ type: "message_start", message });
|
|
735
|
+
stream.push({ type: "message_end", message });
|
|
736
|
+
currentContext.messages.push(message);
|
|
737
|
+
newMessages.push(message);
|
|
738
|
+
}
|
|
739
|
+
pendingMessages = [];
|
|
740
|
+
}
|
|
706
741
|
|
|
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
|
-
|
|
742
|
+
// Refresh prompt/tool context from live state before each model call
|
|
743
|
+
if (config.syncContextBeforeModelCall) {
|
|
744
|
+
await config.syncContextBeforeModelCall(currentContext);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Stream assistant response
|
|
748
|
+
let recovered: HarmonyRecoveredToolCall | undefined;
|
|
749
|
+
let message: AssistantMessage;
|
|
750
|
+
try {
|
|
751
|
+
message = await streamAssistantResponse(
|
|
752
|
+
currentContext,
|
|
753
|
+
config,
|
|
754
|
+
signal,
|
|
755
|
+
stream,
|
|
756
|
+
telemetry,
|
|
757
|
+
invokeAgentSpan,
|
|
758
|
+
stepCounter,
|
|
759
|
+
streamFn,
|
|
760
|
+
harmonyRetryAttempt,
|
|
761
|
+
);
|
|
762
|
+
harmonyRetryAttempt = 0;
|
|
763
|
+
harmonyTruncateResumeCount = 0;
|
|
764
|
+
} catch (err) {
|
|
765
|
+
if (!(err instanceof HarmonyLeakInterruption)) throw err;
|
|
766
|
+
if (err.recovered) {
|
|
767
|
+
if (harmonyTruncateResumeCount >= 2) {
|
|
768
|
+
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
769
|
+
throw new Error(
|
|
770
|
+
`GPT-5 Harmony leak recurred after truncate-and-resume recovery (${signalListLabel(err.detection.signals)}).`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
harmonyTruncateResumeCount++;
|
|
774
|
+
recovered = err.recovered;
|
|
775
|
+
message = recovered.message;
|
|
776
|
+
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
|
|
777
|
+
} else {
|
|
778
|
+
if (harmonyRetryAttempt >= 2) {
|
|
779
|
+
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
780
|
+
throw new Error(
|
|
781
|
+
`GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (${signalListLabel(err.detection.signals)}).`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
|
|
785
|
+
harmonyRetryAttempt++;
|
|
786
|
+
continue;
|
|
732
787
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
message =
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
788
|
+
}
|
|
789
|
+
if (recovered) {
|
|
790
|
+
message = snapshotAssistantMessage(message);
|
|
791
|
+
currentContext.messages.push(message);
|
|
792
|
+
stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
|
|
793
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
|
|
794
|
+
}
|
|
795
|
+
newMessages.push(message);
|
|
796
|
+
|
|
797
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
798
|
+
// Create placeholder tool results for any tool calls in the aborted message
|
|
799
|
+
// This maintains the tool_use/tool_result pairing that the API requires
|
|
800
|
+
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
801
|
+
const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
|
|
802
|
+
const toolResults: ToolResultMessage[] = [];
|
|
803
|
+
for (const toolCall of toolCalls) {
|
|
804
|
+
const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
|
|
805
|
+
currentContext.messages.push(result);
|
|
806
|
+
newMessages.push(result);
|
|
807
|
+
toolResults.push(result);
|
|
808
|
+
// The placeholder result above keeps the API's tool_use/tool_result
|
|
809
|
+
// pairing intact, but no execute_tool span is started for these
|
|
810
|
+
// calls. Mirror the run-collector entry directly so the run
|
|
811
|
+
// summary's tool counters and `coverage.toolsInvoked` reflect
|
|
812
|
+
// what the user actually saw on the wire.
|
|
813
|
+
recordSkippedTool(telemetry, {
|
|
814
|
+
toolCallId: toolCall.id,
|
|
815
|
+
toolName: toolCall.name,
|
|
816
|
+
status: message.stopReason === "aborted" ? "aborted" : "error",
|
|
817
|
+
});
|
|
743
818
|
}
|
|
744
|
-
await
|
|
745
|
-
|
|
746
|
-
|
|
819
|
+
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
|
|
820
|
+
|
|
821
|
+
stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
|
|
822
|
+
stream.end(newMessages);
|
|
823
|
+
return;
|
|
747
824
|
}
|
|
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
825
|
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
//
|
|
826
|
+
// Run tools whenever the turn carries tool_use blocks AND was not truncated.
|
|
827
|
+
// `stop_reason` is provider metadata that never goes back on the wire, so it
|
|
828
|
+
// does not gate continuation validity: replaying a tool_use turn with the
|
|
829
|
+
// tool_results appended is accepted whether the turn ended on `tool_use` or
|
|
830
|
+
// `end_turn` (adaptive/interleaved-thinking Opus routinely emits tool calls
|
|
831
|
+
// under `end_turn`; verified against the live Anthropic API). The only
|
|
832
|
+
// continuation hazard is a thinking block carrying a stale/invalid signature,
|
|
833
|
+
// which `transformMessages` already neutralizes — it strips the signature on
|
|
834
|
+
// non-`toolUse` turns and the encoder downgrades the unsigned block to text,
|
|
835
|
+
// which the API accepts. So treat `stop` (end_turn/pause_turn) the same as
|
|
836
|
+
// `toolUse`. `length` (max_tokens) is the one reason we must NOT run: the
|
|
837
|
+
// trailing tool_use may be truncated with incomplete arguments — those calls
|
|
838
|
+
// are abandoned below. (`error`/`aborted` already returned above.)
|
|
760
839
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
761
840
|
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
|
-
});
|
|
841
|
+
const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
|
|
842
|
+
hasMoreToolCalls = runnableStop && toolCalls.length > 0;
|
|
843
|
+
|
|
844
|
+
const deadlinePassed = isDeadlineExceeded(config.deadline);
|
|
845
|
+
if (hasMoreToolCalls && deadlinePassed) {
|
|
846
|
+
hasMoreToolCalls = false;
|
|
778
847
|
}
|
|
779
|
-
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
|
|
780
848
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
849
|
+
const toolResults: ToolResultMessage[] = [];
|
|
850
|
+
if (hasMoreToolCalls) {
|
|
851
|
+
const executionResult = await executeToolCalls(
|
|
852
|
+
currentContext,
|
|
853
|
+
message,
|
|
854
|
+
signal,
|
|
855
|
+
stream,
|
|
856
|
+
config,
|
|
857
|
+
telemetry,
|
|
858
|
+
invokeAgentSpan,
|
|
859
|
+
);
|
|
785
860
|
|
|
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
|
-
);
|
|
861
|
+
toolResults.push(...executionResult.toolResults);
|
|
815
862
|
|
|
816
|
-
|
|
863
|
+
for (const result of toolResults) {
|
|
864
|
+
currentContext.messages.push(result);
|
|
865
|
+
newMessages.push(result);
|
|
866
|
+
}
|
|
867
|
+
} else if (toolCalls.length > 0) {
|
|
868
|
+
// Turn ended on a non-runnable reason (`length` truncation) or deadline was exceeded
|
|
869
|
+
// but left toolCall blocks behind. pair each with a placeholder result.
|
|
870
|
+
const skipReason = deadlinePassed ? "aborted" : message.stopReason === "length" ? "length" : "skipped";
|
|
871
|
+
const skipErrMsg = deadlinePassed ? "Deadline exceeded" : undefined;
|
|
872
|
+
for (const toolCall of toolCalls) {
|
|
873
|
+
const result = createAbortedToolResult(toolCall, stream, skipReason, skipErrMsg);
|
|
874
|
+
currentContext.messages.push(result);
|
|
875
|
+
newMessages.push(result);
|
|
876
|
+
toolResults.push(result);
|
|
877
|
+
recordSkippedTool(telemetry, {
|
|
878
|
+
toolCallId: toolCall.id,
|
|
879
|
+
toolName: toolCall.name,
|
|
880
|
+
status: deadlinePassed ? "aborted" : "skipped",
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (message.stopReason === "length" && toolResults.length > 0 && !deadlinePassed) {
|
|
884
|
+
hasMoreToolCalls = true;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
817
887
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
888
|
+
if (toolCalls.length > 0) {
|
|
889
|
+
pausedTurnContinuations = 0;
|
|
890
|
+
} else if (
|
|
891
|
+
!hasMoreToolCalls &&
|
|
892
|
+
message.stopReason === "stop" &&
|
|
893
|
+
message.stopDetails?.type === "pause_turn" &&
|
|
894
|
+
pausedTurnContinuations < MAX_PAUSED_TURN_CONTINUATIONS
|
|
895
|
+
) {
|
|
896
|
+
// Non-terminal stop: the provider ended the response but not the turn
|
|
897
|
+
// (e.g. Codex `end_turn: false` on a commentary-only progress update).
|
|
898
|
+
// Re-sample with the assistant message replayed so the model keeps
|
|
899
|
+
// working; the next round folds steering/asides in like any other
|
|
900
|
+
// mid-work turn.
|
|
901
|
+
pausedTurnContinuations++;
|
|
902
|
+
hasMoreToolCalls = true;
|
|
821
903
|
}
|
|
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
|
-
});
|
|
904
|
+
|
|
905
|
+
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
|
|
906
|
+
|
|
907
|
+
if (isDeadlineExceeded(config.deadline)) {
|
|
908
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
909
|
+
return;
|
|
841
910
|
}
|
|
842
|
-
|
|
843
|
-
|
|
911
|
+
// On external abort (user interrupt), leave the steering queue intact: the
|
|
912
|
+
// session aborts then continues, delivering the queue into a fresh run.
|
|
913
|
+
// Draining it here would inject the messages right before a model call that
|
|
914
|
+
// instantly aborts — message lands in history, agent never responds. The
|
|
915
|
+
// mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
|
|
916
|
+
// still owns every message until this dequeue.
|
|
917
|
+
const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
|
|
918
|
+
if (hasMoreToolCalls) {
|
|
919
|
+
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
920
|
+
const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
921
|
+
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
|
922
|
+
} else {
|
|
923
|
+
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
|
924
|
+
// asides for the outer drain below so a passive aside can't trigger an extra model
|
|
925
|
+
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
|
|
926
|
+
pendingMessages = steering;
|
|
844
927
|
}
|
|
845
928
|
}
|
|
846
929
|
|
|
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;
|
|
930
|
+
if (isDeadlineExceeded(config.deadline)) {
|
|
931
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
932
|
+
return;
|
|
862
933
|
}
|
|
863
934
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
//
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
|
|
935
|
+
// Agent would stop here. Drain non-interrupting asides + follow-up messages.
|
|
936
|
+
await config.onBeforeYield?.();
|
|
937
|
+
|
|
938
|
+
if (isDeadlineExceeded(config.deadline)) {
|
|
939
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// Skip queue drains when externally aborted (same stranding hazard as above).
|
|
943
|
+
// Re-poll steering too: a steer can land between the stop-boundary dequeue
|
|
944
|
+
// above and this yield point (e.g. queued while onBeforeYield ran). Without
|
|
945
|
+
// this poll it would strand in the queue until the next manual prompt.
|
|
946
|
+
const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
|
|
947
|
+
const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
948
|
+
const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || [];
|
|
949
|
+
if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
|
|
950
|
+
// Set as pending so the inner loop processes them before stopping.
|
|
951
|
+
pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages];
|
|
952
|
+
continue;
|
|
882
953
|
}
|
|
883
|
-
}
|
|
884
954
|
|
|
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;
|
|
955
|
+
// No more messages, exit
|
|
956
|
+
break;
|
|
898
957
|
}
|
|
899
958
|
|
|
900
|
-
|
|
901
|
-
|
|
959
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
960
|
+
} finally {
|
|
961
|
+
if (deadlineTimer) {
|
|
962
|
+
clearTimeout(deadlineTimer);
|
|
963
|
+
}
|
|
902
964
|
}
|
|
903
|
-
|
|
904
|
-
stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
|
|
905
|
-
stream.end(newMessages);
|
|
906
965
|
}
|
|
907
966
|
|
|
908
967
|
async function emitHarmonyAudit(
|
|
@@ -985,17 +1044,6 @@ async function streamAssistantResponse(
|
|
|
985
1044
|
|
|
986
1045
|
const streamFunction = streamFn || streamSimple;
|
|
987
1046
|
|
|
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
1047
|
const dynamicToolChoice = config.getToolChoice?.();
|
|
1000
1048
|
const dynamicReasoning = config.getReasoning?.();
|
|
1001
1049
|
const dynamicDisableReasoning = config.getDisableReasoning?.();
|
|
@@ -1006,7 +1054,6 @@ async function streamAssistantResponse(
|
|
|
1006
1054
|
? AbortSignal.any([signal, harmonyAbortController.signal])
|
|
1007
1055
|
: harmonyAbortController.signal
|
|
1008
1056
|
: signal;
|
|
1009
|
-
const repetitionAbortController = new AbortController();
|
|
1010
1057
|
// Owned tool calling: aborted by the stream wrapper when the model starts
|
|
1011
1058
|
// fabricating a `<tool_response>`, so the provider stops generating the rest of
|
|
1012
1059
|
// the hallucinated turn. Merged into the provider signal ONLY (not
|
|
@@ -1015,10 +1062,20 @@ async function streamAssistantResponse(
|
|
|
1015
1062
|
const promptToolAbortController = ownedDialect ? new AbortController() : undefined;
|
|
1016
1063
|
const providerAbortSignals: AbortSignal[] = [];
|
|
1017
1064
|
if (requestSignal) providerAbortSignals.push(requestSignal);
|
|
1018
|
-
providerAbortSignals.push(repetitionAbortController.signal);
|
|
1019
1065
|
if (promptToolAbortController) providerAbortSignals.push(promptToolAbortController.signal);
|
|
1020
1066
|
const finalRequestSignal =
|
|
1021
|
-
providerAbortSignals.length ===
|
|
1067
|
+
providerAbortSignals.length === 0
|
|
1068
|
+
? undefined
|
|
1069
|
+
: providerAbortSignals.length === 1
|
|
1070
|
+
? providerAbortSignals[0]!
|
|
1071
|
+
: AbortSignal.any(providerAbortSignals);
|
|
1072
|
+
const requestApiKey = (config.getApiKey ? await config.getApiKey(config.model) : undefined) ?? config.apiKey;
|
|
1073
|
+
const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
|
|
1074
|
+
const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
|
|
1075
|
+
|
|
1076
|
+
// Re-resolve metadata after credential selection so the per-request value
|
|
1077
|
+
// reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
|
|
1078
|
+
const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
|
|
1022
1079
|
const effectiveTemperature =
|
|
1023
1080
|
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
|
|
1024
1081
|
// Owned tool calling sends no native tools, so any tool_choice would error.
|
|
@@ -1069,19 +1126,7 @@ async function streamAssistantResponse(
|
|
|
1069
1126
|
return await runInActiveSpan(chatSpan, async () => {
|
|
1070
1127
|
let response = await streamFunction(config.model, llmContext, {
|
|
1071
1128
|
...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,
|
|
1129
|
+
apiKey,
|
|
1085
1130
|
metadata: resolvedMetadata,
|
|
1086
1131
|
toolChoice: effectiveToolChoice,
|
|
1087
1132
|
reasoning: effectiveReasoning,
|
|
@@ -1130,56 +1175,6 @@ async function streamAssistantResponse(
|
|
|
1130
1175
|
return aborted;
|
|
1131
1176
|
};
|
|
1132
1177
|
|
|
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
1178
|
// Set up a single abort race: register the abort listener once for the whole
|
|
1184
1179
|
// stream and reuse the same race promise for every iterator.next() instead of
|
|
1185
1180
|
// allocating Promise.withResolvers and add/removeEventListener per event.
|
|
@@ -1196,14 +1191,6 @@ async function streamAssistantResponse(
|
|
|
1196
1191
|
detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
|
|
1197
1192
|
}
|
|
1198
1193
|
|
|
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
1194
|
try {
|
|
1208
1195
|
while (true) {
|
|
1209
1196
|
let next: IteratorResult<AssistantMessageEvent>;
|
|
@@ -1288,27 +1275,6 @@ async function streamAssistantResponse(
|
|
|
1288
1275
|
assistantMessageEvent: snapshotAssistantMessageEvent(event),
|
|
1289
1276
|
message: snapshotAssistantMessage(partialMessage),
|
|
1290
1277
|
});
|
|
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
1278
|
}
|
|
1313
1279
|
break;
|
|
1314
1280
|
}
|
|
@@ -1944,97 +1910,3 @@ function createSkippedToolResult(): AgentToolResult<any> {
|
|
|
1944
1910
|
details: {},
|
|
1945
1911
|
};
|
|
1946
1912
|
}
|
|
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
|
-
}
|
package/src/agent.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { isPromise } from "node:util/types";
|
|
5
5
|
import {
|
|
6
|
-
type
|
|
6
|
+
type ApiKey,
|
|
7
7
|
type AssistantMessage,
|
|
8
8
|
type AssistantMessageEvent,
|
|
9
9
|
type Context,
|
|
@@ -131,6 +131,8 @@ export interface AgentOptions {
|
|
|
131
131
|
* Custom stream function (for proxy backends, etc.). Default uses streamSimple.
|
|
132
132
|
*/
|
|
133
133
|
streamFn?: StreamFn;
|
|
134
|
+
/** Absolute wall-clock deadline in Unix epoch milliseconds. */
|
|
135
|
+
deadline?: number;
|
|
134
136
|
|
|
135
137
|
/**
|
|
136
138
|
* Optional session identifier forwarded to LLM providers.
|
|
@@ -148,10 +150,10 @@ export interface AgentOptions {
|
|
|
148
150
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
149
151
|
|
|
150
152
|
/**
|
|
151
|
-
* Resolves an API key dynamically for each LLM call.
|
|
152
|
-
* Useful for expiring tokens
|
|
153
|
+
* Resolves an API key or resolver dynamically for each LLM call.
|
|
154
|
+
* Useful for expiring tokens and model-scoped credential routing.
|
|
153
155
|
*/
|
|
154
|
-
getApiKey?: (
|
|
156
|
+
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
155
157
|
|
|
156
158
|
/**
|
|
157
159
|
* Inspect or replace provider payloads before they are sent.
|
|
@@ -303,6 +305,7 @@ export class Agent {
|
|
|
303
305
|
#followUpMode: "all" | "one-at-a-time";
|
|
304
306
|
#interruptMode: "immediate" | "wait";
|
|
305
307
|
#sessionId?: string;
|
|
308
|
+
#deadline?: number;
|
|
306
309
|
#promptCacheKey?: string;
|
|
307
310
|
#metadata?: Record<string, unknown>;
|
|
308
311
|
#metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
|
|
@@ -344,7 +347,7 @@ export class Agent {
|
|
|
344
347
|
#cursorToolResultBuffer: CursorToolResultEntry[] = [];
|
|
345
348
|
|
|
346
349
|
streamFn: StreamFn;
|
|
347
|
-
getApiKey?: (
|
|
350
|
+
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
348
351
|
/**
|
|
349
352
|
* Hook invoked after tool arguments are validated and before execution.
|
|
350
353
|
* Reassign at any time to swap the implementation (e.g. on extension reload).
|
|
@@ -368,6 +371,7 @@ export class Agent {
|
|
|
368
371
|
this.#interruptMode = opts.interruptMode || "immediate";
|
|
369
372
|
this.streamFn = opts.streamFn || streamSimple;
|
|
370
373
|
this.#sessionId = opts.sessionId;
|
|
374
|
+
this.#deadline = opts.deadline;
|
|
371
375
|
this.#promptCacheKey = opts.promptCacheKey;
|
|
372
376
|
this.#providerSessionState = opts.providerSessionState;
|
|
373
377
|
this.#thinkingBudgets = opts.thinkingBudgets;
|
|
@@ -1014,6 +1018,7 @@ export class Agent {
|
|
|
1014
1018
|
hideThinkingSummary: this.#hideThinkingSummary,
|
|
1015
1019
|
interruptMode: this.#interruptMode,
|
|
1016
1020
|
sessionId: this.#sessionId,
|
|
1021
|
+
deadline: this.#deadline,
|
|
1017
1022
|
promptCacheKey: this.#promptCacheKey,
|
|
1018
1023
|
metadata: this.#metadataResolver ? undefined : this.#metadata,
|
|
1019
1024
|
metadataResolver: this.#metadataResolver,
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
2
|
+
ApiKey,
|
|
3
3
|
AssistantMessage,
|
|
4
4
|
AssistantMessageEvent,
|
|
5
5
|
AssistantMessageEventStream,
|
|
@@ -55,6 +55,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
55
55
|
*/
|
|
56
56
|
sessionId?: string;
|
|
57
57
|
|
|
58
|
+
/** Absolute wall-clock deadline in Unix epoch milliseconds. */
|
|
59
|
+
deadline?: number;
|
|
60
|
+
|
|
58
61
|
/**
|
|
59
62
|
* Optional resolver called per LLM request to produce request metadata.
|
|
60
63
|
* When set, the agent loop evaluates it **after** `getApiKey` resolves the
|
|
@@ -117,12 +120,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
117
120
|
transformProviderContext?: (context: Context, model: Model) => Context;
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
|
-
* Resolves
|
|
123
|
+
* Resolves the API key or resolver for the current model before each LLM call.
|
|
121
124
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
125
|
+
* Returning an ApiKeyResolver lets the stream retry policy refresh or rotate
|
|
126
|
+
* the model-scoped credential after auth/usage-limit errors.
|
|
124
127
|
*/
|
|
125
|
-
getApiKey?: (
|
|
128
|
+
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
126
129
|
|
|
127
130
|
/**
|
|
128
131
|
* Returns steering messages to inject into the conversation mid-run.
|