@gajae-code/coding-agent 0.15.2 → 0.15.3

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/dist/types/config/autorouting-tier-map.d.ts +0 -9
  3. package/dist/types/config/file-lock.d.ts +7 -1
  4. package/dist/types/config/keybindings.d.ts +13 -3
  5. package/dist/types/config/model-registry.d.ts +5 -0
  6. package/dist/types/config/model-resolver.d.ts +1 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -1
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/coordinator-mcp/question-state.d.ts +42 -2
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  11. package/dist/types/main.d.ts +1 -1
  12. package/dist/types/modes/action-registry.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -1
  14. package/dist/types/modes/components/welcome.d.ts +2 -0
  15. package/dist/types/modes/controllers/input-controller.d.ts +16 -1
  16. package/dist/types/modes/controllers/runtime-mcp-command-controller.d.ts +42 -0
  17. package/dist/types/modes/interactive-mode.d.ts +7 -0
  18. package/dist/types/modes/shared/oauth-url-copy.d.ts +9 -0
  19. package/dist/types/modes/types.d.ts +3 -0
  20. package/dist/types/modes/ui-language.d.ts +2 -1
  21. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  22. package/dist/types/sdk/broker/session-index.d.ts +18 -0
  23. package/dist/types/sdk/bus/index.d.ts +4 -1
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  26. package/dist/types/sdk/cli/session-cli.d.ts +1 -1
  27. package/dist/types/sdk/lifecycle/service.d.ts +32 -5
  28. package/dist/types/sdk/session.d.ts +5 -0
  29. package/dist/types/session/agent-session.d.ts +4 -0
  30. package/dist/types/session/fallback-chain-controller.d.ts +8 -0
  31. package/dist/types/slash-commands/helpers/aside.d.ts +54 -0
  32. package/dist/types/tools/ask.d.ts +8 -0
  33. package/dist/types/utils/clipboard.d.ts +9 -1
  34. package/package.json +7 -7
  35. package/scripts/generate-sdk-adapter-parity-manifest.ts +5 -2
  36. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  37. package/src/commands/sdk.ts +2 -2
  38. package/src/config/autorouting-tier-map.ts +0 -9
  39. package/src/config/file-lock.ts +15 -1
  40. package/src/config/keybindings.ts +13 -3
  41. package/src/config/model-registry.ts +132 -21
  42. package/src/config/model-resolver.ts +2 -1
  43. package/src/config/models-config-schema.ts +2 -2
  44. package/src/config/settings-schema.ts +13 -1
  45. package/src/coordinator/contract.ts +1 -0
  46. package/src/coordinator-mcp/question-state.ts +235 -4
  47. package/src/coordinator-mcp/server.ts +402 -2
  48. package/src/dap/client.ts +19 -8
  49. package/src/gjc-runtime/deep-interview-stage.ts +20 -1
  50. package/src/gjc-runtime/deep-interview-state.ts +3 -1
  51. package/src/gjc-runtime/session-state-lock.ts +11 -2
  52. package/src/internal-urls/docs-index.generated.ts +8 -7
  53. package/src/lsp/client.ts +39 -4
  54. package/src/main.ts +42 -11
  55. package/src/modes/action-registry.ts +7 -0
  56. package/src/modes/components/iterm-pet-transport.ts +4 -2
  57. package/src/modes/components/runtime-mcp-add-wizard.ts +38 -1
  58. package/src/modes/components/settings-selector.ts +1 -0
  59. package/src/modes/components/tool-status-header.ts +149 -41
  60. package/src/modes/components/welcome.ts +8 -0
  61. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  62. package/src/modes/controllers/input-controller.ts +141 -7
  63. package/src/modes/controllers/plan-mode-controller.ts +12 -4
  64. package/src/modes/controllers/runtime-mcp-command-controller.ts +164 -20
  65. package/src/modes/controllers/selector-controller.ts +11 -2
  66. package/src/modes/interactive-mode.ts +88 -1
  67. package/src/modes/shared/oauth-url-copy.ts +33 -0
  68. package/src/modes/types.ts +3 -0
  69. package/src/modes/ui-language.ts +44 -3
  70. package/src/runtime-mcp/oauth-flow.ts +19 -8
  71. package/src/runtime-mcp/transports/stdio.ts +27 -13
  72. package/src/sdk/acp/adapter.ts +29 -2
  73. package/src/sdk/broker/broker.ts +26 -6
  74. package/src/sdk/broker/lifecycle-ledger.ts +28 -1
  75. package/src/sdk/broker/lifecycle.ts +664 -5
  76. package/src/sdk/broker/session-index.ts +246 -0
  77. package/src/sdk/broker/transport.ts +1 -0
  78. package/src/sdk/bus/index.ts +135 -38
  79. package/src/sdk/bus/natives-augment.d.ts +20 -1
  80. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  81. package/src/sdk/bus/telegram-daemon.ts +3 -0
  82. package/src/sdk/cli/session-cli.ts +34 -7
  83. package/src/sdk/lifecycle/service.ts +166 -11
  84. package/src/sdk/mcp/server.ts +2 -1
  85. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  86. package/src/sdk/protocol/operation-registry.ts +2 -1
  87. package/src/sdk/session.ts +91 -7
  88. package/src/session/agent-session.ts +403 -56
  89. package/src/session/fallback-chain-controller.ts +17 -0
  90. package/src/setup/frictionless-onboarding.ts +4 -4
  91. package/src/slash-commands/aside-command.test.ts +366 -0
  92. package/src/slash-commands/builtin-registry.ts +19 -0
  93. package/src/slash-commands/helpers/aside.ts +299 -0
  94. package/src/tools/ask.ts +8 -0
  95. package/src/utils/clipboard.ts +37 -28
@@ -900,6 +900,8 @@ export interface AgentSessionConfig {
900
900
  initialMCPToolSelectionIsExplicit?: boolean;
901
901
  /** Whether a discoverable built-in selection was explicitly supplied to the constructor, including an empty selection. */
902
902
  initialDiscoveredBuiltinToolSelectionIsExplicit?: boolean;
903
+ /** Whether the caller explicitly disabled every built-in tool for this session. */
904
+ explicitEmptyToolSelection?: boolean;
903
905
 
904
906
  /** Whether constructor-provided MCP selections should be persisted immediately. */
905
907
  persistInitialMCPToolSelection?: boolean;
@@ -2142,7 +2144,8 @@ const AGENT_CONTINUE_BUSY_MAX_RESCHEDULES = 50;
2142
2144
  * in-loop resample budget, so without this cap a deterministic escaper loops
2143
2145
  * forever: the fallback chain never sees a charge to exhaust on. Matching the
2144
2146
  * agent loop's own per-loop budget (MAX_ESCAPED_NONASCII_RESAMPLES + 1 wire
2145
- * attempts) keeps one steering retry plus one blind retry before the run ends.
2147
+ * attempts) gives every eligible model two steered retries before one policy
2148
+ * charge advances the chain.
2146
2149
  */
2147
2150
  const MAX_ESCAPED_NONASCII_MANAGED_RETRIES = 2;
2148
2151
 
@@ -2481,8 +2484,11 @@ export class AgentSession {
2481
2484
  #retryPromise: Promise<void> | undefined = undefined;
2482
2485
  #retryResolve: (() => void) | undefined = undefined;
2483
2486
  #defaultFallbackController: FallbackChainController | undefined;
2487
+ #fallbackTransitionGeneration = 0;
2484
2488
  /** Managed escaped-non-ASCII retries issued for the current logical run. Bounded so a deterministic escaper cannot loop forever through un-charged fallback retries. */
2485
2489
  #escapedNonAsciiManagedRetries = 0;
2490
+ /** Concrete models that already exhausted escaped-argument recovery this turn. */
2491
+ #escapedNonAsciiExhaustedModelKeys = new Set<string>();
2486
2492
  #overflowMaintenanceAttempts = 0;
2487
2493
  #defaultFallbackExhaustedLastTurn = false;
2488
2494
  #fallbackInvocationId = 0;
@@ -2660,6 +2666,7 @@ export class AgentSession {
2660
2666
  /** Constructor authority applies only while this AgentSession instance remains alive. */
2661
2667
  #constructorMCPToolSelection: string[] | undefined;
2662
2668
  #constructorDiscoveredBuiltinToolSelection: string[] | undefined;
2669
+ #explicitEmptyToolSelection = false;
2663
2670
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
2664
2671
  #memoryGuardClaimsLease: MemoryGuardClaimsLease | undefined;
2665
2672
 
@@ -3827,6 +3834,7 @@ export class AgentSession {
3827
3834
  this.#networkPrewarmService = config.networkPrewarmService;
3828
3835
  this.#onWorkspaceTreeReady = config.onWorkspaceTreeReady;
3829
3836
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
3837
+ this.#explicitEmptyToolSelection = config.explicitEmptyToolSelection === true;
3830
3838
  const configuredDiscoveryMode = config.settings.get("tools.discoveryMode");
3831
3839
  this.#discoveryMode =
3832
3840
  config.discoveryMode ??
@@ -6373,6 +6381,8 @@ export class AgentSession {
6373
6381
  releasePredecessor();
6374
6382
  if (startsQueuedSuccessor) {
6375
6383
  this.#defaultFallbackChain().resetAttemptBudget();
6384
+ this.#escapedNonAsciiManagedRetries = 0;
6385
+ this.#escapedNonAsciiExhaustedModelKeys.clear();
6376
6386
  this.#overflowMaintenanceAttempts = 0;
6377
6387
  }
6378
6388
  },
@@ -8271,6 +8281,11 @@ export class AgentSession {
8271
8281
  return this.agent.state.tools.map(t => t.name);
8272
8282
  }
8273
8283
 
8284
+ /** Whether this session explicitly requested zero built-in tools. */
8285
+ isExplicitEmptyToolSelection(): boolean {
8286
+ return this.#explicitEmptyToolSelection;
8287
+ }
8288
+
8274
8289
  /** Whether the edit tool is registered in this session. */
8275
8290
  get hasEditTool(): boolean {
8276
8291
  return this.#toolRegistry.has("edit");
@@ -8902,9 +8917,11 @@ export class AgentSession {
8902
8917
  ? this.#filterSelectableMCPToolNames(sessionContext.selectedMCPToolNames)
8903
8918
  : (constructorMCPToolNames ?? this.#getConfiguredDefaultSelectedMCPToolNames());
8904
8919
  const constructorDiscoveredBuiltinToolNames = this.#resolveConstructorDiscoveredBuiltinToolSelection();
8905
- const restoredDiscoveredBuiltinToolNames = sessionContext.hasPersistedDiscoveredBuiltinToolSelection
8906
- ? this.#selectRestorableDiscoveredBuiltinToolNames(sessionContext.selectedDiscoveredBuiltinToolNames ?? [])
8907
- : (constructorDiscoveredBuiltinToolNames ?? []);
8920
+ const restoredDiscoveredBuiltinToolNames = this.#explicitEmptyToolSelection
8921
+ ? []
8922
+ : sessionContext.hasPersistedDiscoveredBuiltinToolSelection
8923
+ ? this.#selectRestorableDiscoveredBuiltinToolNames(sessionContext.selectedDiscoveredBuiltinToolNames ?? [])
8924
+ : (constructorDiscoveredBuiltinToolNames ?? []);
8908
8925
  this.#selectedDiscoveredToolNames = new Set(restoredDiscoveredBuiltinToolNames);
8909
8926
  await this.#applyActiveToolsByName(
8910
8927
  [...nextActiveNonMCPToolNames, ...restoredMCPToolNames, ...restoredDiscoveredBuiltinToolNames],
@@ -9845,6 +9862,7 @@ export class AgentSession {
9845
9862
  }
9846
9863
 
9847
9864
  async #attachAskToolIfWorkflowActive(): Promise<void> {
9865
+ if (this.#explicitEmptyToolSelection) return;
9848
9866
  const sessionId = this.sessionManager.getSessionId();
9849
9867
  const inMemoryActiveSkill =
9850
9868
  this.#activeSkillState && (!this.#activeSkillState.sessionId || this.#activeSkillState.sessionId === sessionId)
@@ -9877,6 +9895,7 @@ export class AgentSession {
9877
9895
  }
9878
9896
 
9879
9897
  #attachAskTool(): void {
9898
+ if (this.#explicitEmptyToolSelection) return;
9880
9899
  const askTool = this.#toolRegistry.get("ask");
9881
9900
  if (!askTool || this.getActiveToolNames().includes(askTool.name)) return;
9882
9901
  this.#setGuardedAgentTools([...this.agent.state.tools, askTool]);
@@ -9959,6 +9978,7 @@ export class AgentSession {
9959
9978
  }
9960
9979
 
9961
9980
  async #activatePendingGjcGoalModeRequest(): Promise<boolean> {
9981
+ if (this.#explicitEmptyToolSelection) return false;
9962
9982
  if (!this.settings.get("goal.enabled")) return false;
9963
9983
  const pendingGoal = await consumePendingGoalModeRequest(
9964
9984
  this.sessionManager.getCwd(),
@@ -13850,9 +13870,13 @@ export class AgentSession {
13850
13870
  role,
13851
13871
  this.#formatRoleModelValue(role, model, options?.selector, options?.thinkingLevel),
13852
13872
  );
13853
- if (role === "default") {
13873
+ // Only an explicit user selection starts a new fallback epoch. Internal
13874
+ // fallback switches must preserve the exhausted-model set while advancing.
13875
+ if (role === "default" && (options?.cause ?? "user-selection") === "user-selection") {
13876
+ this.#fallbackTransitionGeneration++;
13854
13877
  this.#defaultFallbackController = undefined;
13855
13878
  this.#defaultFallbackExhaustedLastTurn = false;
13879
+ this.#escapedNonAsciiExhaustedModelKeys.clear();
13856
13880
  }
13857
13881
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
13858
13882
 
@@ -13906,7 +13930,12 @@ export class AgentSession {
13906
13930
  ? this.#modelRegistry.getModelProfile?.(this.#activeModelProfile)
13907
13931
  : undefined,
13908
13932
  });
13909
- if (eagerTasks && this.#toolRegistry.has("task") && !this.getActiveToolNames().includes("task")) {
13933
+ if (
13934
+ !this.#explicitEmptyToolSelection &&
13935
+ eagerTasks &&
13936
+ this.#toolRegistry.has("task") &&
13937
+ !this.getActiveToolNames().includes("task")
13938
+ ) {
13910
13939
  await this.activateDiscoveredTools(["task"]);
13911
13940
  }
13912
13941
  await this.refreshBaseSystemPrompt();
@@ -18586,6 +18615,7 @@ export class AgentSession {
18586
18615
  },
18587
18616
  onManagedAttemptAccepted: () => {
18588
18617
  controller.resetAttemptBudget();
18618
+ this.#escapedNonAsciiManagedRetries = 0;
18589
18619
  this.#overflowMaintenanceAttempts = 0;
18590
18620
  },
18591
18621
  onManagedAttemptOutcome: outcome => this.#handleManagedAttemptOutcome(outcome),
@@ -18596,6 +18626,7 @@ export class AgentSession {
18596
18626
  // A fresh user turn gets a fresh escaped-non-ASCII retry budget: the
18597
18627
  // defect is per-turn wire luck, not a sticky model property.
18598
18628
  this.#escapedNonAsciiManagedRetries = 0;
18629
+ this.#escapedNonAsciiExhaustedModelKeys.clear();
18599
18630
  const controller = this.#defaultFallbackChain();
18600
18631
  if (this.#defaultFallbackExhaustedLastTurn) {
18601
18632
  this.#defaultFallbackExhaustedLastTurn = false;
@@ -18690,6 +18721,11 @@ export class AgentSession {
18690
18721
  }
18691
18722
 
18692
18723
  async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
18724
+ const activePromptHandle = this.activePromptHandle;
18725
+ const cancellationSignal = activePromptHandle
18726
+ ? this.#runCancellationDomains.lookup(activePromptHandle)?.signal
18727
+ : undefined;
18728
+ if (cancellationSignal?.aborted) return { type: "terminal", terminal: { stopReason: "cancelled" } };
18693
18729
  if (outcome.type === "run_terminal") {
18694
18730
  this.#defaultFallbackChain().resetAttemptBudget();
18695
18731
  return { type: "terminal", terminal: { stopReason: outcome.reason } };
@@ -18699,34 +18735,148 @@ export class AgentSession {
18699
18735
  // evidence: never charge the attempt, advance the chain, or suppress the
18700
18736
  // selector. The loop already removed the defective turn from history and
18701
18737
  // bounded its own resample budget, so this decision just re-issues the
18702
- // same request on the same model. The re-issue carries the transient
18703
- // steering instruction exactly once (when the discarded attempt did not
18704
- // already have one), so a deterministic escaper has a reason to change
18705
- // its spelling; the instruction never lands in durable history.
18738
+ // same request on the same model. Every bounded re-issue carries the
18739
+ // transient steering instruction, so a deterministic escaper has a reason
18740
+ // to change its spelling; the instruction never lands in durable history.
18706
18741
  //
18707
18742
  // The retries are un-charged by design, so a deterministic escaper
18708
18743
  // would otherwise loop forever: each continuation is a fresh loop with
18709
- // a fresh in-loop resample budget, and the fallback chain never sees a
18710
- // charge to exhaust on. Bound them per logical run and fail closed to
18711
- // the terminal exhaustion message once the budget is spent — the same
18712
- // fail-closed answer the unmanaged path gives via the per-call
18713
- // rejection. New user turns reset the budget in #resetDefaultFallbackForNewTurn.
18744
+ // a fresh in-loop resample budget. After that bounded recovery budget is
18745
+ // spent, charge the active model once and advance to the next eligible
18746
+ // fallback entry. Only the final eligible model returns the terminal
18747
+ // fail-closed answer.
18714
18748
  this.#escapedNonAsciiManagedRetries += 1;
18715
18749
  if (this.#escapedNonAsciiManagedRetries > MAX_ESCAPED_NONASCII_MANAGED_RETRIES) {
18750
+ const errorMessage =
18751
+ `Managed fallback retried the escaped non-ASCII tool-call turn ${MAX_ESCAPED_NONASCII_MANAGED_RETRIES} times without a literal-UTF-8 re-issue; ` +
18752
+ `giving up on this model so the next eligible fallback can re-issue with literal UTF-8.`;
18753
+ const controller = this.#defaultFallbackChain();
18754
+ const fallbackFrom = controller.currentSelector();
18755
+ const previousModel = this.model;
18756
+ const previousThinkingLevel = this.thinkingLevel;
18757
+ if (this.model) {
18758
+ this.#escapedNonAsciiExhaustedModelKeys.add(`${this.model.provider}\u0000${this.model.id}`);
18759
+ }
18760
+ controller.discardStartedAttempt();
18761
+ const controllerStateBeforeFailure = controller.snapshotRuntimeState();
18762
+ const advanced = controller.recordEscapedArgumentsFailure(errorMessage, false);
18763
+ this.#escapedNonAsciiManagedRetries = 0;
18764
+ const abortEpoch = this.#abortAdmissionEpoch;
18765
+ if (advanced) {
18766
+ const transitionGeneration = ++this.#fallbackTransitionGeneration;
18767
+ return {
18768
+ type: "retry",
18769
+ continuation: async ownership => {
18770
+ const restoreOwnedTransition = async () => {
18771
+ if (this.#fallbackTransitionGeneration !== transitionGeneration) return;
18772
+ this.#fallbackTransitionGeneration++;
18773
+ if (!this.agent.state.isStreaming)
18774
+ await this.#restoreDefaultFallbackTransition(
18775
+ controller,
18776
+ controllerStateBeforeFailure,
18777
+ previousModel,
18778
+ previousThinkingLevel,
18779
+ );
18780
+ };
18781
+ const continuationCancelled =
18782
+ !ownership.isCurrent() ||
18783
+ ownership.lease.signal.aborted ||
18784
+ cancellationSignal?.aborted ||
18785
+ this.#abortAdmissionEpoch !== abortEpoch ||
18786
+ this.#fallbackTransitionGeneration !== transitionGeneration;
18787
+ if (continuationCancelled) {
18788
+ await restoreOwnedTransition();
18789
+ return;
18790
+ }
18791
+ if (!controller.advance()) {
18792
+ this.#fallbackTransitionGeneration++;
18793
+ this.#defaultFallbackExhaustedLastTurn = true;
18794
+ this.agent.requestRunTerminal(ownership.handle.logicalRunId, {
18795
+ stopReason: "exhausted",
18796
+ messages: [
18797
+ this.#managedFallbackExhaustionMessage(
18798
+ outcome.message,
18799
+ `Managed fallback exhausted every eligible model after escaped non-ASCII tool-call retries; the run fails closed. ${errorMessage}`,
18800
+ ),
18801
+ ],
18802
+ });
18803
+ return;
18804
+ }
18805
+ const switched = await this.#advanceDefaultFallback(
18806
+ controller,
18807
+ "escaped_non_ascii",
18808
+ 1,
18809
+ cancellationSignal,
18810
+ abortEpoch,
18811
+ controllerStateBeforeFailure,
18812
+ previousModel,
18813
+ previousThinkingLevel,
18814
+ { emitSwitchEvent: false },
18815
+ transitionGeneration,
18816
+ );
18817
+ if (!switched) {
18818
+ if (
18819
+ this.#abortAdmissionEpoch !== abortEpoch ||
18820
+ cancellationSignal?.aborted ||
18821
+ this.#fallbackTransitionGeneration !== transitionGeneration
18822
+ ) {
18823
+ await restoreOwnedTransition();
18824
+ return;
18825
+ }
18826
+ this.#fallbackTransitionGeneration++;
18827
+ this.#defaultFallbackExhaustedLastTurn = true;
18828
+ this.agent.requestRunTerminal(ownership.handle.logicalRunId, {
18829
+ stopReason: "exhausted",
18830
+ messages: [
18831
+ this.#managedFallbackExhaustionMessage(
18832
+ outcome.message,
18833
+ `Managed fallback exhausted every eligible model after escaped non-ASCII tool-call retries; the run fails closed. ${errorMessage}`,
18834
+ ),
18835
+ ],
18836
+ });
18837
+ return;
18838
+ }
18839
+ const continuation = this.agent.continue({
18840
+ ...this.#managedFallbackPromptOptions(),
18841
+ transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage(),
18842
+ });
18843
+ if (
18844
+ cancellationSignal?.aborted ||
18845
+ this.#abortAdmissionEpoch !== abortEpoch ||
18846
+ this.#fallbackTransitionGeneration !== transitionGeneration
18847
+ ) {
18848
+ await restoreOwnedTransition();
18849
+ return;
18850
+ }
18851
+ this.#emitFallbackSwitchEvent(
18852
+ controller,
18853
+ fallbackFrom,
18854
+ controller.currentSelector(),
18855
+ "escaped_non_ascii",
18856
+ 1,
18857
+ );
18858
+ await continuation;
18859
+ this.#fallbackTransitionGeneration++;
18860
+ },
18861
+ };
18862
+ }
18863
+ controller.advance();
18864
+ if (this.#abortAdmissionEpoch !== abortEpoch || cancellationSignal?.aborted)
18865
+ return { type: "terminal", terminal: { stopReason: "cancelled" } };
18866
+ this.#defaultFallbackExhaustedLastTurn = true;
18716
18867
  return this.#managedFallbackExhaustionDecision(
18717
18868
  outcome.message,
18718
- `Managed fallback retried the escaped non-ASCII tool-call turn ${MAX_ESCAPED_NONASCII_MANAGED_RETRIES} times without a literal-UTF-8 re-issue; giving up so the run fails closed instead of looping.`,
18869
+ `Managed fallback exhausted every eligible model after escaped non-ASCII tool-call retries; the run fails closed. ${errorMessage}`,
18719
18870
  );
18720
18871
  }
18721
18872
  this.#defaultFallbackChain().discardStartedAttempt();
18722
- const steering = outcome.steeringPending === true;
18723
18873
  return {
18724
18874
  type: "retry",
18725
18875
  continuation: async ownership => {
18726
18876
  if (!ownership.isCurrent() || ownership.lease.signal.aborted) return;
18727
18877
  await this.agent.continue({
18728
18878
  ...this.#managedFallbackPromptOptions(),
18729
- ...(steering ? { transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage() } : {}),
18879
+ transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage(),
18730
18880
  });
18731
18881
  },
18732
18882
  };
@@ -18798,6 +18948,7 @@ export class AgentSession {
18798
18948
  return {
18799
18949
  ...discarded,
18800
18950
  content: [{ type: "text", text: "" }],
18951
+ providerPayload: undefined,
18801
18952
  stopReason: "error",
18802
18953
  errorMessage,
18803
18954
  timestamp: Date.now(),
@@ -18842,45 +18993,162 @@ export class AgentSession {
18842
18993
  return undefined;
18843
18994
  }
18844
18995
 
18996
+ async #restoreDefaultFallbackTransition(
18997
+ controller: FallbackChainController,
18998
+ rollbackState: FallbackChainRuntimeState | undefined,
18999
+ previousModel: Model | undefined,
19000
+ previousThinkingLevel: ThinkingLevel | undefined,
19001
+ ): Promise<void> {
19002
+ if (rollbackState) controller.restoreRuntimeState(rollbackState);
19003
+ if (previousModel && this.model !== previousModel) {
19004
+ const previousEditMode = this.#resolveActiveEditMode();
19005
+ this.#setModelAuthoritatively(previousModel, "fallback-switch");
19006
+ this.#thinkingLevel = previousThinkingLevel;
19007
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
19008
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
19009
+ }
19010
+ if (previousModel && this.model === previousModel && this.thinkingLevel !== previousThinkingLevel) {
19011
+ this.#thinkingLevel = previousThinkingLevel;
19012
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
19013
+ }
19014
+ }
19015
+
18845
19016
  async #advanceDefaultFallback(
18846
19017
  controller: FallbackChainController,
18847
19018
  reason: string,
18848
19019
  attemptsUsed: number,
19020
+ signal?: AbortSignal,
19021
+ abortEpoch?: number,
19022
+ rollbackState?: FallbackChainRuntimeState,
19023
+ previousModel?: Model,
19024
+ previousThinkingLevel?: ThinkingLevel,
19025
+ options?: { emitSwitchEvent?: boolean },
19026
+ transitionGeneration?: number,
18849
19027
  ): Promise<boolean> {
19028
+ const promptAbortSignal = this.#promptPreflightAbortController.signal;
19029
+ const cancellationSignal = signal ? AbortSignal.any([signal, promptAbortSignal]) : promptAbortSignal;
19030
+ const transitionStillOwned = () =>
19031
+ transitionGeneration === undefined || this.#fallbackTransitionGeneration === transitionGeneration;
19032
+ const restoreFallbackTransition = () =>
19033
+ transitionStillOwned()
19034
+ ? this.#restoreDefaultFallbackTransition(controller, rollbackState, previousModel, previousThinkingLevel)
19035
+ : Promise.resolve();
19036
+ const awaitWithCancellation = async <T>(pending: Promise<T>) => {
19037
+ const cancellation = Promise.withResolvers<"aborted">();
19038
+ const onAbort = () => cancellation.resolve("aborted");
19039
+ cancellationSignal.addEventListener("abort", onAbort, { once: true });
19040
+ try {
19041
+ return await Promise.race([
19042
+ pending.then(value => ({ kind: "resolved" as const, value })),
19043
+ cancellation.promise.then(() => ({ kind: "aborted" as const })),
19044
+ ]);
19045
+ } finally {
19046
+ cancellationSignal.removeEventListener("abort", onAbort);
19047
+ }
19048
+ };
19049
+ const rollbackCancelled = async (): Promise<boolean> => {
19050
+ await restoreFallbackTransition();
19051
+ return false;
19052
+ };
19053
+ if (
19054
+ cancellationSignal.aborted ||
19055
+ (abortEpoch !== undefined && this.#abortAdmissionEpoch !== abortEpoch) ||
19056
+ !transitionStillOwned()
19057
+ )
19058
+ return await rollbackCancelled();
18850
19059
  while (!controller.isExhausted()) {
18851
19060
  const selector = controller.currentSelector();
18852
19061
  if (!selector) return false;
18853
19062
  const profileAliasIntent = this.#persistedModelProfileAliasIntent("default");
18854
- const resolved = profileAliasIntent
18855
- ? await resolveModelChainWithAuth(
18856
- [selector],
18857
- this.#modelRegistry,
18858
- this.settings,
18859
- this.credentialSessionId,
18860
- {
18861
- managedFallback: true,
18862
- ...profileAliasIntent,
18863
- canonicalSessionId: this.agent.providerSessionId ?? this.sessionId,
19063
+ const profileResolution = profileAliasIntent
19064
+ ? await (async () => {
19065
+ const canonicalSessionId = this.agent.providerSessionId ?? this.sessionId;
19066
+ const previousCanonicalVariant = this.#modelRegistry.getSessionCanonicalVariant(canonicalSessionId);
19067
+ const restoreCanonicalVariant = () => {
19068
+ if (previousCanonicalVariant !== undefined) {
19069
+ this.#modelRegistry.restoreSessionCanonicalVariant(
19070
+ canonicalSessionId,
19071
+ previousCanonicalVariant,
19072
+ );
19073
+ } else {
19074
+ this.#modelRegistry.clearCanonicalVariant(canonicalSessionId);
19075
+ }
19076
+ };
19077
+ try {
19078
+ const pendingResolution = resolveModelChainWithAuth(
19079
+ [selector],
19080
+ this.#modelRegistry,
19081
+ this.settings,
19082
+ this.credentialSessionId,
19083
+ {
19084
+ managedFallback: true,
19085
+ ...profileAliasIntent,
19086
+ canonicalSessionId,
19087
+ credentialSessionId: this.credentialSessionId,
19088
+ signal: cancellationSignal,
19089
+ },
19090
+ );
19091
+ return await awaitWithCancellation(
19092
+ pendingResolution.finally(() => {
19093
+ if (cancellationSignal.aborted) restoreCanonicalVariant();
19094
+ }),
19095
+ );
19096
+ } catch (error) {
19097
+ if (cancellationSignal.aborted) restoreCanonicalVariant();
19098
+ await restoreFallbackTransition();
19099
+ throw error;
19100
+ }
19101
+ })()
19102
+ : undefined;
19103
+ if (profileResolution?.kind === "aborted") return await rollbackCancelled();
19104
+ const resolved =
19105
+ profileResolution?.kind === "resolved"
19106
+ ? profileResolution.value
19107
+ : resolveModelRoleValue(selector, this.#modelRegistry.getAvailable(), {
19108
+ settings: this.settings,
19109
+ matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
19110
+ modelRegistry: this.#modelRegistry,
19111
+ sessionId: this.agent.providerSessionId ?? this.sessionId,
18864
19112
  credentialSessionId: this.credentialSessionId,
18865
- },
18866
- )
18867
- : resolveModelRoleValue(selector, this.#modelRegistry.getAvailable(), {
18868
- settings: this.settings,
18869
- matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
18870
- modelRegistry: this.#modelRegistry,
18871
- sessionId: this.agent.providerSessionId ?? this.sessionId,
18872
- credentialSessionId: this.credentialSessionId,
18873
- });
19113
+ });
19114
+ if (
19115
+ cancellationSignal.aborted ||
19116
+ (abortEpoch !== undefined && this.#abortAdmissionEpoch !== abortEpoch) ||
19117
+ !transitionStillOwned()
19118
+ )
19119
+ return await rollbackCancelled();
18874
19120
  if (!resolved.model) {
18875
19121
  controller.onResolutionSkip("unknown_model");
18876
19122
  continue;
18877
19123
  }
19124
+ const resolvedModel = resolved.model;
19125
+ if (this.#escapedNonAsciiExhaustedModelKeys.has(`${resolved.model.provider}\u0000${resolved.model.id}`)) {
19126
+ controller.onResolutionSkip("escaped_non_ascii_model_exhausted");
19127
+ continue;
19128
+ }
18878
19129
  const managedCursorUnavailable = managedCursorFallbackUnavailableReason(resolved.model, selector);
18879
19130
  if (managedCursorUnavailable) {
18880
19131
  controller.onResolutionSkip(managedCursorUnavailable);
18881
19132
  continue;
18882
19133
  }
18883
- const key = await this.#modelRegistry.getApiKey(resolved.model, this.credentialSessionId);
19134
+ const keyResult = await (async () => {
19135
+ try {
19136
+ return await awaitWithCancellation(
19137
+ this.#modelRegistry.getApiKey(resolvedModel, this.credentialSessionId),
19138
+ );
19139
+ } catch (error) {
19140
+ await rollbackCancelled();
19141
+ throw error;
19142
+ }
19143
+ })();
19144
+ if (keyResult.kind === "aborted") return await rollbackCancelled();
19145
+ const key = keyResult.value;
19146
+ if (
19147
+ cancellationSignal.aborted ||
19148
+ (abortEpoch !== undefined && this.#abortAdmissionEpoch !== abortEpoch) ||
19149
+ !transitionStillOwned()
19150
+ )
19151
+ return await rollbackCancelled();
18884
19152
  if (!isAuthenticated(key) && key !== kNoAuth) {
18885
19153
  controller.onResolutionSkip("unauthenticated");
18886
19154
  continue;
@@ -18889,33 +19157,64 @@ export class AgentSession {
18889
19157
  controller.tried.at(-1)?.selector ?? controller.chain.entries[controller.activeIndex - 1] ?? selector;
18890
19158
  const to = selector;
18891
19159
  const previousEditMode = this.#resolveActiveEditMode();
19160
+ this.#escapedNonAsciiManagedRetries = 0;
18892
19161
  this.#setModelAuthoritatively(resolved.model, "fallback-switch");
18893
- this.setThinkingLevel(
19162
+ this.#thinkingLevel = resolveThinkingLevelForModel(
19163
+ this.model,
18894
19164
  this.#thinkingLevelForResolvedFallback(resolved.explicitThinkingLevel, resolved.thinkingLevel),
18895
19165
  );
19166
+ this.agent.setThinkingLevel(toReasoningEffort(this.#thinkingLevel));
18896
19167
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
18897
- if (from !== to) {
18898
- this.#emit({
18899
- type: "model_fallback_switched",
18900
- eventId: crypto.randomUUID(),
18901
- from,
18902
- to,
18903
- reason,
18904
- role:
18905
- controller.chain.origin === "subagent"
18906
- ? (controller.chain.identity ?? controller.chain.role)
18907
- : controller.chain.role,
18908
- scope: controller.chain.origin === "subagent" ? "subagent-call" : "session",
18909
- activeIndex: controller.activeIndex,
18910
- chainLength: controller.chain.entries.length,
18911
- attemptsUsed,
18912
- });
19168
+ if (
19169
+ cancellationSignal.aborted ||
19170
+ (abortEpoch !== undefined && this.#abortAdmissionEpoch !== abortEpoch) ||
19171
+ !transitionStillOwned()
19172
+ ) {
19173
+ return await rollbackCancelled();
18913
19174
  }
19175
+ if (options?.emitSwitchEvent ?? true)
19176
+ this.#emitFallbackSwitchEvent(controller, from, to, reason, attemptsUsed);
19177
+ if (
19178
+ cancellationSignal.aborted ||
19179
+ (abortEpoch !== undefined && this.#abortAdmissionEpoch !== abortEpoch) ||
19180
+ !transitionStillOwned()
19181
+ )
19182
+ return await rollbackCancelled();
18914
19183
  return true;
18915
19184
  }
18916
19185
  return false;
18917
19186
  }
18918
19187
 
19188
+ #emitFallbackSwitchEvent(
19189
+ controller: FallbackChainController,
19190
+ from: string | undefined,
19191
+ to: string | undefined,
19192
+ reason: string,
19193
+ attemptsUsed: number,
19194
+ ): void {
19195
+ if (!from || !to || from === to) return;
19196
+ const event: Extract<AgentSessionEvent, { type: "model_fallback_switched" }> = {
19197
+ type: "model_fallback_switched",
19198
+ eventId: crypto.randomUUID(),
19199
+ from,
19200
+ to,
19201
+ reason,
19202
+ role:
19203
+ controller.chain.origin === "subagent"
19204
+ ? (controller.chain.identity ?? controller.chain.role)
19205
+ : controller.chain.role,
19206
+ scope: controller.chain.origin === "subagent" ? "subagent-call" : "session",
19207
+ activeIndex: controller.activeIndex,
19208
+ chainLength: controller.chain.entries.length,
19209
+ attemptsUsed,
19210
+ };
19211
+ if (this.#eventListeners.length === 0) {
19212
+ this.#pendingFallbackSwitches.push(event);
19213
+ } else {
19214
+ this.#emit(event);
19215
+ }
19216
+ }
19217
+
18919
19218
  #emitResolutionFallbackSwitch(controller: FallbackChainController): void {
18920
19219
  if (controller.activeIndex <= 0) return;
18921
19220
  const to = controller.currentSelector();
@@ -19292,6 +19591,12 @@ export class AgentSession {
19292
19591
  }
19293
19592
 
19294
19593
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
19594
+ const activePromptHandle = this.activePromptHandle;
19595
+ const cancellationSignal =
19596
+ ownership?.domain.signal ??
19597
+ (activePromptHandle ? this.#runCancellationDomains.lookup(activePromptHandle)?.signal : undefined);
19598
+ if (ownership && (!ownership.isCurrent() || cancellationSignal?.aborted)) return;
19599
+ const abortEpoch = this.#abortAdmissionEpoch;
19295
19600
  let quotaPoolExhausted = false;
19296
19601
  if (managedFallback && !credentialRotated && !providerRetryCeilingReached) {
19297
19602
  const mark = await this.#markFailedCredential(trigger);
@@ -19300,13 +19605,55 @@ export class AgentSession {
19300
19605
  }
19301
19606
  let advanced = outcome !== "advance";
19302
19607
  let resolutionError: unknown;
19608
+ let controllerStateBeforeAdvance: FallbackChainRuntimeState | undefined;
19609
+ let previousModel: Model | undefined;
19610
+ let previousThinkingLevel: ThinkingLevel | undefined;
19303
19611
  if (outcome === "advance") {
19612
+ controllerStateBeforeAdvance = controller.snapshotRuntimeState();
19613
+ const failedEntryIndex = controller.activeIndex - 1;
19614
+ if (failedEntryIndex >= 0 && failedEntryIndex < controller.chain.entries.length) {
19615
+ controllerStateBeforeAdvance.activeIndex = failedEntryIndex;
19616
+ controllerStateBeforeAdvance.attemptsUsed = 0;
19617
+ controllerStateBeforeAdvance.totalAttemptsUsed = Math.max(
19618
+ 0,
19619
+ controllerStateBeforeAdvance.totalAttemptsUsed - 1,
19620
+ );
19621
+ controllerStateBeforeAdvance.attemptStarted = false;
19622
+ controllerStateBeforeAdvance.exhaustedForTurn = false;
19623
+ controllerStateBeforeAdvance.tried = controllerStateBeforeAdvance.tried.slice(0, -1);
19624
+ }
19625
+ previousModel = this.model;
19626
+ previousThinkingLevel = this.thinkingLevel;
19304
19627
  try {
19305
- advanced = await this.#advanceDefaultFallback(controller, trigger.class, attemptsUsed);
19628
+ advanced = await this.#advanceDefaultFallback(
19629
+ controller,
19630
+ trigger.class,
19631
+ attemptsUsed,
19632
+ cancellationSignal,
19633
+ abortEpoch,
19634
+ controllerStateBeforeAdvance,
19635
+ previousModel,
19636
+ previousThinkingLevel,
19637
+ );
19306
19638
  } catch (error) {
19307
19639
  resolutionError = error;
19308
19640
  }
19309
19641
  }
19642
+ if (ownership && (!ownership.isCurrent() || cancellationSignal?.aborted)) {
19643
+ if (this.#abortAdmissionEpoch === abortEpoch) {
19644
+ await this.#restoreDefaultFallbackTransition(
19645
+ controller,
19646
+ controllerStateBeforeAdvance,
19647
+ previousModel,
19648
+ previousThinkingLevel,
19649
+ );
19650
+ }
19651
+ return;
19652
+ }
19653
+ if (this.#abortAdmissionEpoch !== abortEpoch) {
19654
+ controller.resetForNewTurn();
19655
+ return;
19656
+ }
19310
19657
  if (!advanced) {
19311
19658
  let errorMessage = resolutionError
19312
19659
  ? `${this.#fallbackExhaustionError(controller)}; resolution failed: ${resolutionError instanceof Error ? resolutionError.message : String(resolutionError)}`
@@ -155,6 +155,23 @@ export class FallbackChainController {
155
155
  return this.advance() ? "advance" : "exhausted";
156
156
  }
157
157
 
158
+ /**
159
+ * Record one bounded escaped-argument exhaustion for the active model and
160
+ * advance immediately. The discarded wire attempts are intentionally not
161
+ * provider charges; this single policy charge prevents the same model from
162
+ * being retried forever while still allowing every eligible fallback model
163
+ * its own literal-UTF-8 recovery budget.
164
+ */
165
+ recordEscapedArgumentsFailure(reason: string, advance = true): boolean {
166
+ const selector = this.currentSelector();
167
+ if (!selector || this.exhaustedForTurn) return false;
168
+ this.#attemptStarted = false;
169
+ this.attemptsUsed = this.maxAttempts;
170
+ this.#totalAttemptsUsed += 1;
171
+ this.tried.push({ selector, triggerClass: "unknown", reason });
172
+ return advance ? this.advance() : this.activeIndex + 1 < this.chain.entries.length;
173
+ }
174
+
158
175
  advance(): boolean {
159
176
  if (this.exhaustedForTurn) return false;
160
177
  this.activeIndex += 1;