@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 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 (provider) => refreshToken(),
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 */ }),
@@ -1,4 +1,4 @@
1
- import { type ApiKeyResolveContext, 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";
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 (e.g., GitHub Copilot OAuth).
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?: (provider: string, ctx?: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
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?: (provider: string, ctx?: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
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).
@@ -1,4 +1,4 @@
1
- import type { ApiKeyResolveContext, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
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 an API key dynamically for each LLM call.
92
+ * Resolves the API key or resolver for the current model before each LLM call.
91
93
  *
92
- * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
93
- * during long-running tool execution phases.
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?: (provider: string, ctx?: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
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",
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.4",
39
- "@oh-my-pi/pi-catalog": "16.0.4",
40
- "@oh-my-pi/pi-natives": "16.0.4",
41
- "@oh-my-pi/pi-utils": "16.0.4",
42
- "@oh-my-pi/snapcompact": "16.0.4",
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 { logger, sanitizeText } from "@oh-my-pi/pi-utils";
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 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
- }
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
- // 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);
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
- // Refresh prompt/tool context from live state before each model call
703
- if (config.syncContextBeforeModelCall) {
704
- await config.syncContextBeforeModelCall(currentContext);
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
- // 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
- );
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
- 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
- );
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 emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
745
- harmonyRetryAttempt++;
746
- continue;
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
- 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
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 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
- });
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
- stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
782
- stream.end(newMessages);
783
- return;
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
- // 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
- );
861
+ toolResults.push(...executionResult.toolResults);
815
862
 
816
- toolResults.push(...executionResult.toolResults);
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
- for (const result of toolResults) {
819
- currentContext.messages.push(result);
820
- newMessages.push(result);
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
- } 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
- });
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
- if (message.stopReason === "length" && toolResults.length > 0) {
843
- hasMoreToolCalls = true;
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 (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;
930
+ if (isDeadlineExceeded(config.deadline)) {
931
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
932
+ return;
862
933
  }
863
934
 
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;
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
- // 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;
955
+ // No more messages, exit
956
+ break;
898
957
  }
899
958
 
900
- // No more messages, exit
901
- break;
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 === 1 ? providerAbortSignals[0]! : AbortSignal.any(providerAbortSignals);
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
- // 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,
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 ApiKeyResolveContext,
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 (e.g., GitHub Copilot OAuth).
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?: (provider: string, ctx?: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
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?: (provider: string, ctx?: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
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
- ApiKeyResolveContext,
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 an API key dynamically for each LLM call.
123
+ * Resolves the API key or resolver for the current model before each LLM call.
121
124
  *
122
- * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
123
- * during long-running tool execution phases.
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?: (provider: string, ctx?: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
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.