@yansigit/opencodex 2.33.0 → 2.35.0

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 (196) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +141 -23
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +17 -1
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  17. package/src/adapters/cursor/native-exec-network.ts +3 -5
  18. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  19. package/src/adapters/cursor/native-exec-shell.ts +116 -31
  20. package/src/adapters/cursor/native-exec.ts +38 -10
  21. package/src/adapters/cursor/protobuf-events.ts +28 -2
  22. package/src/adapters/cursor/protobuf-request.ts +93 -41
  23. package/src/adapters/cursor/request-builder.ts +39 -10
  24. package/src/adapters/cursor/tool-definitions.ts +27 -3
  25. package/src/adapters/cursor/tool-result-normalize.ts +51 -6
  26. package/src/adapters/cursor/types.ts +23 -4
  27. package/src/adapters/cursor.ts +170 -29
  28. package/src/adapters/google-aistudio-parser.ts +49 -0
  29. package/src/adapters/google-antigravity-replay.ts +105 -25
  30. package/src/adapters/google-antigravity-wire.ts +5 -0
  31. package/src/adapters/google-errors.ts +41 -12
  32. package/src/adapters/google-http.ts +12 -11
  33. package/src/adapters/google.ts +219 -36
  34. package/src/adapters/image.ts +1 -1
  35. package/src/adapters/kiro-constants.ts +15 -0
  36. package/src/adapters/kiro-tools.ts +43 -15
  37. package/src/adapters/kiro.ts +54 -9
  38. package/src/adapters/openai-chat.ts +286 -242
  39. package/src/adapters/openai-responses.ts +335 -24
  40. package/src/adapters/run-turn-queue.ts +36 -1
  41. package/src/adapters/tool-catalog-nudge.ts +2 -2
  42. package/src/adapters/xai-tool-schema.ts +436 -0
  43. package/src/bridge.ts +67 -26
  44. package/src/chat/inbound.ts +29 -1
  45. package/src/chat/outbound.ts +15 -7
  46. package/src/claude/agents-inject.ts +8 -1
  47. package/src/claude/outbound.ts +10 -8
  48. package/src/cli/account-api.ts +27 -7
  49. package/src/cli/account-extended.ts +10 -3
  50. package/src/cli/account.ts +29 -5
  51. package/src/cli/alias.ts +66 -0
  52. package/src/cli/claude.ts +26 -1
  53. package/src/cli/dispatch.ts +13 -1
  54. package/src/cli/help.ts +1 -0
  55. package/src/cli/index.ts +6 -1
  56. package/src/cli/init.ts +1 -0
  57. package/src/cli/models-runtime.ts +95 -0
  58. package/src/cli/models.ts +13 -7
  59. package/src/cli/provider-runtime.ts +16 -2
  60. package/src/cli/registry.ts +6 -1
  61. package/src/cli/telemetry-commands.ts +25 -0
  62. package/src/cli/v2.ts +34 -10
  63. package/src/codex/account-pause.ts +2 -1
  64. package/src/codex/account-priority.ts +3 -2
  65. package/src/codex/app-server-processes.ts +80 -6
  66. package/src/codex/auth-api.ts +48 -8
  67. package/src/codex/auth-context.ts +21 -18
  68. package/src/codex/catalog/aggregation.ts +6 -0
  69. package/src/codex/catalog/model-metadata.ts +13 -1
  70. package/src/codex/catalog/native-models.ts +5 -2
  71. package/src/codex/catalog/parsing.ts +16 -0
  72. package/src/codex/catalog/provider-fetch.ts +20 -3
  73. package/src/codex/catalog/sync.ts +127 -2
  74. package/src/codex/catalog.ts +1 -1
  75. package/src/codex/codex-write-lock.ts +3 -1
  76. package/src/codex/convergence-types.ts +1 -1
  77. package/src/codex/convergence.ts +22 -2
  78. package/src/codex/desired-state.ts +2 -2
  79. package/src/codex/desktop-app-restart.ts +18 -5
  80. package/src/codex/inject-coordination.ts +83 -0
  81. package/src/codex/inject.ts +14 -1
  82. package/src/codex/log-guard/inspect.ts +22 -4
  83. package/src/codex/model-entitlements.ts +9 -2
  84. package/src/codex/prompt-layers.ts +371 -25
  85. package/src/codex/prompt-text-probe.ts +238 -0
  86. package/src/codex/quota.ts +123 -18
  87. package/src/codex/routing.ts +9 -0
  88. package/src/codex/subagent-model-fallback.ts +198 -27
  89. package/src/codex/transition-state.ts +107 -8
  90. package/src/combos/types.ts +10 -0
  91. package/src/compatibility/openai-responses.ts +33 -1
  92. package/src/config/autonomous-remediation.ts +21 -0
  93. package/src/config/provider-validation.ts +14 -0
  94. package/src/config/rebase-provenance.ts +68 -0
  95. package/src/config.ts +191 -17
  96. package/src/generated/compatibility-version.json +279 -159
  97. package/src/generated/model-metadata.ts +3 -0
  98. package/src/images/loop.ts +5 -4
  99. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  100. package/src/lab/fabric/producer-child.ts +1 -1
  101. package/src/lib/config-ownership.ts +20 -0
  102. package/src/lib/errors.ts +11 -2
  103. package/src/lib/package-tree-integrity.ts +101 -0
  104. package/src/oauth/aistudio-credentials.ts +65 -0
  105. package/src/oauth/aistudio-native-daemon.ts +116 -0
  106. package/src/oauth/aistudio-session-sync.ts +95 -0
  107. package/src/oauth/generic-account-failover.ts +231 -0
  108. package/src/oauth/google-aistudio-auth.ts +98 -0
  109. package/src/oauth/index.ts +57 -5
  110. package/src/oauth/key-providers.ts +18 -1
  111. package/src/oauth/kiro.ts +45 -0
  112. package/src/oauth/login-cli.ts +65 -1
  113. package/src/oauth/types.ts +15 -0
  114. package/src/providers/codex-capacity.ts +5 -2
  115. package/src/providers/command-code-efforts.ts +38 -6
  116. package/src/providers/context-cap.ts +4 -3
  117. package/src/providers/default-aliases.ts +65 -0
  118. package/src/providers/derive.ts +29 -1
  119. package/src/providers/fastwire.ts +7 -1
  120. package/src/providers/model-presets.ts +119 -0
  121. package/src/providers/new-model-policy.ts +146 -0
  122. package/src/providers/provider-id-rewrite.ts +2 -1
  123. package/src/providers/quota.ts +157 -46
  124. package/src/providers/registry.ts +184 -71
  125. package/src/providers/slug-codec.ts +52 -0
  126. package/src/responses/code-mode-helper-compat.ts +50 -0
  127. package/src/responses/custom-tool-compat.ts +34 -10
  128. package/src/responses/parser.ts +4 -0
  129. package/src/responses/schema.ts +5 -1
  130. package/src/responses/thought-signature-replay.ts +17 -0
  131. package/src/router.ts +43 -2
  132. package/src/routing/account-pool/cooldown.ts +8 -0
  133. package/src/routing/account-pool/index.ts +1 -0
  134. package/src/routing/analytics.ts +1 -0
  135. package/src/routing/quota.ts +10 -0
  136. package/src/server/auth-cors.ts +24 -0
  137. package/src/server/chat-completions.ts +26 -16
  138. package/src/server/chat-native-sse.ts +3 -3
  139. package/src/server/chat-native.ts +30 -11
  140. package/src/server/claude-messages.ts +1 -1
  141. package/src/server/effort-policy.ts +16 -0
  142. package/src/server/index.ts +180 -14
  143. package/src/server/lifecycle.ts +52 -1
  144. package/src/server/management/agent-settings-routes.ts +31 -15
  145. package/src/server/management/codex-prompt-routes.ts +570 -0
  146. package/src/server/management/combo-routes.ts +2 -1
  147. package/src/server/management/config-routes.ts +27 -9
  148. package/src/server/management/context.ts +9 -0
  149. package/src/server/management/logs-usage-routes.ts +11 -5
  150. package/src/server/management/model-routes.ts +266 -0
  151. package/src/server/management/oauth-account-routes.ts +13 -3
  152. package/src/server/management/provider-routes.ts +137 -3
  153. package/src/server/management/routing-profile-routes.ts +2 -2
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/port-reclaim.ts +19 -1
  156. package/src/server/relay-eager.ts +147 -20
  157. package/src/server/relay.ts +251 -19
  158. package/src/server/request-log-conversation.ts +33 -0
  159. package/src/server/request-log.ts +48 -21
  160. package/src/server/responses/collaboration.ts +42 -5
  161. package/src/server/responses/combo-stream-preflight.ts +10 -3
  162. package/src/server/responses/core.ts +575 -140
  163. package/src/server/responses/empty-completion-guard.ts +35 -0
  164. package/src/server/responses/fetch-helpers.ts +14 -6
  165. package/src/server/responses/input-admission.ts +3 -1
  166. package/src/server/responses/passthrough-error.ts +33 -9
  167. package/src/server/responses/policy-fallback.ts +1 -1
  168. package/src/server/responses/responses-field-backfill.ts +105 -13
  169. package/src/server/responses/ws-upstream.ts +35 -5
  170. package/src/server/responses-custom-tool-repair.ts +52 -7
  171. package/src/server/responses-terminal-repair.ts +25 -4
  172. package/src/server/sse-frame-buffer.ts +31 -4
  173. package/src/server/ws-bridge.ts +14 -2
  174. package/src/smoke/fingerprint-cache.ts +133 -0
  175. package/src/smoke/live-scenarios.ts +33 -0
  176. package/src/smoke/runner.ts +119 -0
  177. package/src/telemetry/dispatcher.ts +44 -0
  178. package/src/telemetry/fingerprint.ts +24 -0
  179. package/src/telemetry/hook.ts +43 -0
  180. package/src/telemetry/ledger.ts +54 -0
  181. package/src/telemetry/types.ts +23 -0
  182. package/src/types/config.ts +66 -14
  183. package/src/types/provider.ts +79 -1
  184. package/src/types/request.ts +18 -10
  185. package/src/types/tools.ts +30 -11
  186. package/src/types.ts +1 -0
  187. package/src/usage/command-code-manifest.ts +116 -0
  188. package/src/usage/cost.ts +2 -2
  189. package/src/usage/expected-prices.ts +126 -24
  190. package/src/usage/log.ts +18 -8
  191. package/src/usage/summary.ts +34 -12
  192. package/src/web-search/exa-executor.ts +40 -9
  193. package/src/web-search/index.ts +16 -8
  194. package/src/web-search/loop.ts +5 -4
  195. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
  196. package/gui/dist/assets/index-DrSQdTRd.css +0 -1
@@ -39,7 +39,7 @@ function requestForHost(request: AdapterRequest, host: string): AdapterRequest {
39
39
  return { ...request, url: target.toString() };
40
40
  }
41
41
 
42
- type CcaSseProbe = "empty" | "candidate" | "unavailable" | "quota_exhausted" | "geo_blocked" | "terminal";
42
+ type CcaSseProbe = "empty" | "candidate" | "unavailable" | "quota_exhausted" | "rate_limit" | "geo_blocked" | "terminal";
43
43
 
44
44
  function probeCcaSseEvent(bytes: Uint8Array): CcaSseProbe {
45
45
  const text = new TextDecoder().decode(bytes);
@@ -67,6 +67,7 @@ function probeCcaSseEvent(bytes: Uint8Array): CcaSseProbe {
67
67
  }
68
68
  const serialized = JSON.stringify(frame);
69
69
  if (isQuotaExhaustedBody(serialized)) return "quota_exhausted";
70
+ if (/rate[- ]limit|too many requests|per[- ]minute|requests per minute|concurrent request/i.test(serialized)) return "rate_limit";
70
71
  if (isAntigravityGeoBlockedBody(serialized)) return "geo_blocked";
71
72
  return "terminal";
72
73
  }
@@ -209,15 +210,15 @@ async function prepareCcaSseResponse(
209
210
  if (probe === "unavailable") {
210
211
  return failoverOrPassthrough();
211
212
  }
212
- if (probe === "quota_exhausted" || probe === "geo_blocked") {
213
+ if (probe === "quota_exhausted" || probe === "rate_limit" || probe === "geo_blocked") {
213
214
  if (accountId) {
214
215
  recordAntigravitySyntheticFailure(accountId, {
215
- code: probe === "quota_exhausted" ? 429 : 403,
216
- status: probe === "quota_exhausted" ? "RESOURCE_EXHAUSTED" : "PERMISSION_DENIED",
217
- message: probe === "quota_exhausted" ? "quota exceeded" : "user location is not supported",
216
+ code: probe === "geo_blocked" ? 403 : 429,
217
+ status: probe === "geo_blocked" ? "PERMISSION_DENIED" : "RESOURCE_EXHAUSTED",
218
+ message: probe === "quota_exhausted" ? "quota exceeded" : probe === "rate_limit" ? "rate limit exceeded" : "user location is not supported",
218
219
  });
219
220
  }
220
- const status = probe === "quota_exhausted" ? 429 : 403;
221
+ const status = probe === "geo_blocked" ? 403 : 429;
221
222
  return passthrough(undefined, status);
222
223
  }
223
224
  }
@@ -244,15 +245,15 @@ async function prepareCcaSseResponse(
244
245
  }
245
246
  return failoverOrPassthrough();
246
247
  }
247
- if (probe === "quota_exhausted" || probe === "geo_blocked") {
248
+ if (probe === "quota_exhausted" || probe === "rate_limit" || probe === "geo_blocked") {
248
249
  if (accountId) {
249
250
  recordAntigravitySyntheticFailure(accountId, {
250
- code: probe === "quota_exhausted" ? 429 : 403,
251
- status: probe === "quota_exhausted" ? "RESOURCE_EXHAUSTED" : "PERMISSION_DENIED",
252
- message: probe === "quota_exhausted" ? "quota exceeded" : "user location is not supported",
251
+ code: probe === "geo_blocked" ? 403 : 429,
252
+ status: probe === "geo_blocked" ? "PERMISSION_DENIED" : "RESOURCE_EXHAUSTED",
253
+ message: probe === "quota_exhausted" ? "quota exceeded" : probe === "rate_limit" ? "rate limit exceeded" : "user location is not supported",
253
254
  });
254
255
  }
255
- const status = probe === "quota_exhausted" ? 429 : 403;
256
+ const status = probe === "geo_blocked" ? 403 : 429;
256
257
  return passthrough(overflow, status);
257
258
  }
258
259
  if (probe === "terminal") return passthrough(overflow);
@@ -37,7 +37,7 @@ import {
37
37
  } from "../web-search/gemini-executor";
38
38
  import type { WebSearchSource } from "../web-search/parse";
39
39
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
40
- import { lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
40
+ import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
41
41
  import {
42
42
  isTranslatorBudgetExceededError,
43
43
  releaseTranslatedEvent,
@@ -48,6 +48,9 @@ import {
48
48
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
49
49
  import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort";
50
50
  import { normalizeAntigravityProviderError } from "../oauth/antigravity-routing";
51
+ import { buildAiStudioHeaders, parseGoogleCookieJar } from "../oauth/google-aistudio-auth";
52
+ import { resolveAiStudioCredentials } from "../oauth/aistudio-credentials";
53
+ import { parseMakerSuiteChunk } from "./google-aistudio-parser";
51
54
 
52
55
  const INLINE_ERROR_URL_USERINFO = /https?:\/\/[^\s"'<>]*@/gi;
53
56
 
@@ -71,6 +74,43 @@ const GOOGLE_BREVITY_INSTRUCTION = [
71
74
  "- This applies only to intermediate progress text. Your final answer after the work is done is exempt: write it in full and at whatever length the task requires.",
72
75
  ].join("\n");
73
76
 
77
+ /**
78
+ * Documented output ceiling for a Google-surface model, or `undefined` when the id is not
79
+ * recognized.
80
+ *
81
+ * Unknown ids return `undefined` deliberately. An earlier revision returned a 16,384 floor for
82
+ * anything unmatched, which silently truncated aliases, gateway ids, and any model added after
83
+ * this table was written — the operator asked for N tokens and got 16,384 with no signal. A cap
84
+ * we cannot justify is worse than no cap: `structure/02_config-and-codex-home.md` is explicit
85
+ * that an explicit request value wins, so an unrecognized model passes through untouched and the
86
+ * upstream remains the authority on its own limit.
87
+ *
88
+ * Matching is prefix/family based rather than substring based for the same reason: `includes("pro")`
89
+ * matched any id containing "pro" (`my-prototype-model`), and `includes("oss")` matched any id
90
+ * containing "oss" (`crossover-v2`).
91
+ */
92
+ export function maxOutputTokensForGoogleModel(modelId: string): number | undefined {
93
+ const lower = modelId.toLowerCase().trim();
94
+ if (lower.startsWith("gemini")) {
95
+ // Pro tops out one token below the flash/other Gemini ceiling; both are documented values.
96
+ return /(^|[-.])pro([-.]|$)/.test(lower) ? 65535 : 65536;
97
+ }
98
+ if (lower.startsWith("claude")) return 64000;
99
+ if (lower.startsWith("gpt-oss")) return 32768;
100
+ return undefined;
101
+ }
102
+
103
+ export function clampGoogleMaxOutputTokens(
104
+ modelId: string,
105
+ requestedTokens?: number,
106
+ ): number | undefined {
107
+ if (requestedTokens === undefined || requestedTokens <= 0) return undefined;
108
+ const modelMax = maxOutputTokensForGoogleModel(modelId);
109
+ // Unknown model: honour the request as-is rather than inventing a ceiling for it.
110
+ if (modelMax === undefined) return requestedTokens;
111
+ return Math.min(requestedTokens, modelMax);
112
+ }
113
+
74
114
  /**
75
115
  * Some Google direct deployments expose current Gemini Flash generations with a `-tiered`
76
116
  * wire suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). Keep the picker-visible id
@@ -177,7 +217,7 @@ function geminiTextPart(text: unknown): { text: string } | undefined {
177
217
  */
178
218
  function geminiToolResultText(content: string | OcxContentPart[]): string {
179
219
  if (typeof content === "string") return content || GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
180
- const hasContent = content.some(p => p.type === "image" || (typeof p.text === "string" && p.text.length > 0));
220
+ const hasContent = content.some(p => p.type !== "text" || p.text.length > 0);
181
221
  return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
182
222
  }
183
223
 
@@ -221,7 +261,7 @@ function messagesToGeminiFormat(
221
261
  parsed: OcxParsedRequest,
222
262
  identityModelId: string,
223
263
  repairToolPairs: boolean,
224
- ): { systemInstruction?: unknown; contents: unknown[] } {
264
+ ): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } {
225
265
  // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
226
266
  // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
227
267
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice);
@@ -233,6 +273,7 @@ function messagesToGeminiFormat(
233
273
  const systemInstruction = { parts: [{ text: systemText }] };
234
274
 
235
275
  const contents: unknown[] = [];
276
+ const replayedCallIds: string[] = [];
236
277
  let userMergeBarrier = false;
237
278
  const appendContent = (next: { role: string; parts: unknown[] }): void => {
238
279
  appendGeminiContent(contents, next, !userMergeBarrier);
@@ -267,6 +308,13 @@ function messagesToGeminiFormat(
267
308
  parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[image: ${p.imageUrl}]` });
268
309
  continue;
269
310
  }
311
+ if (p.type === "video") {
312
+ const data = parseDataUrl(p.videoUrl);
313
+ // Gemini accepts inline video bytes in the same Part union as images. Arbitrary
314
+ // remote URLs are not valid fileData references, so retain only a short marker.
315
+ parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` });
316
+ continue;
317
+ }
270
318
  // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part.
271
319
  const textPart = geminiTextPart(p.text);
272
320
  if (textPart) parts.push(textPart);
@@ -314,7 +362,10 @@ function messagesToGeminiFormat(
314
362
  const signature = tc.providerMetadata?.google?.thoughtSignature
315
363
  ?? tc.thoughtSignature
316
364
  ?? lookupReplayThoughtSignature(tc.id, parsed._reasoningReplayScope);
317
- if (isLikelyRealThoughtSignature(signature)) part.thoughtSignature = signature;
365
+ if (isLikelyRealThoughtSignature(signature)) {
366
+ part.thoughtSignature = signature;
367
+ replayedCallIds.push(tc.id);
368
+ }
318
369
  parts.push(part);
319
370
  }
320
371
  }
@@ -371,7 +422,7 @@ function messagesToGeminiFormat(
371
422
  }
372
423
  }
373
424
 
374
- return { systemInstruction, contents };
425
+ return { systemInstruction, contents, replayedCallIds };
375
426
  }
376
427
 
377
428
  function toolsToGeminiFormat(
@@ -540,6 +591,21 @@ function googlePartThoughtSignature(part: GoogleResponsePart): string | undefine
540
591
  return typeof nested === "string" && nested.length > 0 ? nested : undefined;
541
592
  }
542
593
 
594
+ function ensureThoughtSignatureBypassSentinel(contents: unknown[], modelId?: string): void {
595
+ if (modelId && !/gemini-(?:3.7|2.5)|thinking/i.test(modelId)) return;
596
+ for (const c of contents as { role?: string; parts?: unknown[] }[]) {
597
+ if (c?.role !== "model" || !Array.isArray(c.parts)) continue;
598
+ for (const p of c.parts) {
599
+ if (p && typeof p === "object") {
600
+ const partObj = p as Record<string, unknown>;
601
+ if (partObj.functionCall && !partObj.thoughtSignature && !partObj.thought_signature) {
602
+ partObj.thoughtSignature = ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL;
603
+ }
604
+ }
605
+ }
606
+ }
607
+ }
608
+
543
609
  /**
544
610
  * Carry a Gemini thought signature with the exact function-call part that produced it. Google
545
611
  * validates the signature against that specific part, so it must ride the individual tool call
@@ -725,6 +791,47 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
725
791
  const truncationErrorMessage = provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist"
726
792
  ? vertexTruncationErrorMessage
727
793
  : googleTruncationErrorMessage;
794
+ let lastInjectedCallIds: string[] = [];
795
+ let lastReasoningReplayScope: OcxParsedRequest["_reasoningReplayScope"];
796
+
797
+ // Conservative batch invalidation: upstream Gemini/Antigravity errors (e.g.
798
+ // "Function call is missing a thought_signature in functionCall parts") do not specify which
799
+ // specific call_id was rejected. When a request containing replayed signatures is rejected,
800
+ // we evict all callIds injected in that turn (lastInjectedCallIds) from the durable store
801
+ // and clear the session replay cache, preventing poisoned-signature loops while allowing
802
+ // subsequent turns to re-accumulate valid signatures. Unrelated calls from other turns remain intact.
803
+ //
804
+ // Memory-cache clearing stays broad (any invalid-argument/signature error can poison the
805
+ // session replay cache), but durable-store eviction is intentionally narrower: it only runs
806
+ // when the error text explicitly mentions a signature, so a generic tool-schema
807
+ // INVALID_ARGUMENT does not destroy valid durable signatures.
808
+ function handleSignatureRejection(errorMessage?: string) {
809
+ const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
810
+ const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
811
+ const text = errorMessage ?? "";
812
+ const isInvalidArgument = /invalid_argument|invalid argument/i.test(text);
813
+ const isSignatureError = /signature|thought_signature|thoughtSignature/i.test(text);
814
+ // The in-memory Antigravity replay cache only exists for CCA/Vertex, so clearing it stays
815
+ // scoped to those modes (replayModel/replaySession are undefined elsewhere anyway).
816
+ if (
817
+ (provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
818
+ && replayModel && replaySession && (isInvalidArgument || isSignatureError)
819
+ ) {
820
+ clearAntigravityReplay(replayModel, replaySession);
821
+ }
822
+ // The DURABLE store is not mode-scoped: signatures are remembered through
823
+ // rememberAndSerializeExtraContent and read back by lookupReplayThoughtSignature on every
824
+ // Google mode, including AI Studio. Gating eviction on CCA/Vertex therefore left AI Studio
825
+ // with rejected signatures cached forever, replaying them into every subsequent turn — the
826
+ // store poisons itself and the request keeps failing. Eviction follows the same scope the
827
+ // write does.
828
+ if (isSignatureError) {
829
+ for (const callId of lastInjectedCallIds) {
830
+ forgetThoughtSignatureForReplay(callId, lastReasoningReplayScope);
831
+ }
832
+ }
833
+ }
834
+
728
835
  return {
729
836
  name: "google",
730
837
  validateRequest(parsed: OcxParsedRequest) {
@@ -766,11 +873,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
766
873
  : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false);
767
874
  // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation.
768
875
  const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId;
769
- const { systemInstruction, contents } = messagesToGeminiFormat(
876
+ const { systemInstruction, contents, replayedCallIds } = messagesToGeminiFormat(
770
877
  parsed,
771
878
  identityModelId,
772
879
  provider.googleMode === "cloud-code-assist",
773
880
  );
881
+ lastInjectedCallIds = [...replayedCallIds];
882
+ lastReasoningReplayScope = parsed._reasoningReplayScope;
774
883
  const tools = toolsToGeminiFormat(parsed, routedModelId);
775
884
 
776
885
  const body: Record<string, unknown> = { contents };
@@ -785,7 +894,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
785
894
  if (googleOptions?.cachedContent) body.cachedContent = googleOptions.cachedContent;
786
895
 
787
896
  const generationConfig: Record<string, unknown> = {};
788
- if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens;
897
+ const clampedMaxOutputTokens = clampGoogleMaxOutputTokens(identityModelId, parsed.options.maxOutputTokens);
898
+ if (clampedMaxOutputTokens !== undefined) generationConfig.maxOutputTokens = clampedMaxOutputTokens;
789
899
  if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature;
790
900
  if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP;
791
901
  if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences;
@@ -882,19 +992,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
882
992
  const strippedModelTail = /claude/i.test(wireModelId) ? stripTrailingClaudePrefill(contents) : false;
883
993
  if (antigravityUsesReplayCache(wireModelId)) {
884
994
  applyAntigravityReplay(wireModelId, sessionId, contents);
885
- // If any functionCall still lacks a thoughtSignature on Gemini Antigravity,
886
- // supply the bypass sentinel so Antigravity does not reject the turn with HTTP 400.
887
- for (const c of contents as { role?: string; parts?: unknown[] }[]) {
888
- if (c?.role !== "model" || !Array.isArray(c.parts)) continue;
889
- for (const p of c.parts) {
890
- if (p && typeof p === "object") {
891
- const partObj = p as Record<string, unknown>;
892
- if (partObj.functionCall && !partObj.thoughtSignature && !partObj.thought_signature) {
893
- partObj.thoughtSignature = ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL;
894
- }
895
- }
896
- }
897
- }
995
+ ensureThoughtSignatureBypassSentinel(contents);
898
996
  } else {
899
997
  sanitizeAntigravityClaudeSignatures(contents);
900
998
  }
@@ -970,6 +1068,23 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
970
1068
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
971
1069
  }
972
1070
 
1071
+ if (provider.googleMode === "ai-studio-web") {
1072
+ const base = (provider.baseUrl || "https://alkalimakersuite-pa.clients6.google.com").replace(/\/+$/, "");
1073
+ const url = `${base}/v1internal:${method}${streamParam}`;
1074
+ const credentials = resolveAiStudioCredentials(provider);
1075
+ if (credentials.kind !== "ready") throw new Error(credentials.reason);
1076
+ const jar = parseGoogleCookieJar(credentials.cookieHeader);
1077
+ const aiStudioHeaders = await buildAiStudioHeaders(jar, "https://aistudio.google.com");
1078
+ Object.assign(headers, aiStudioHeaders);
1079
+ const compiled = compileGoogleWireBody({ ...body, model: routedModelId });
1080
+ restoreGoogleToolName = compiled.restoreToolName;
1081
+ if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) {
1082
+ ensureThoughtSignatureBypassSentinel((compiled.body as { contents: unknown[] }).contents, routedModelId);
1083
+ }
1084
+ emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding);
1085
+ return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
1086
+ }
1087
+
973
1088
  // ai-studio (default): Generative Language API + x-goog-api-key.
974
1089
  const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`;
975
1090
  const apiKey = provider.apiKey?.trim();
@@ -978,6 +1093,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
978
1093
 
979
1094
  const compiled = compileGoogleWireBody(body);
980
1095
  restoreGoogleToolName = compiled.restoreToolName;
1096
+ if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) {
1097
+ ensureThoughtSignatureBypassSentinel((compiled.body as { contents: unknown[] }).contents, routedModelId);
1098
+ }
981
1099
  emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding);
982
1100
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
983
1101
  },
@@ -986,6 +1104,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
986
1104
  const emitInTurnGroundingSources = emitInTurnGroundingSourcesQueue.shift() ?? false;
987
1105
  const filterCcaSearchSuggestionHtml =
988
1106
  provider.googleMode === "cloud-code-assist" && emitInTurnGroundingSources;
1107
+ if (provider.googleMode === "ai-studio-web" && (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400))) {
1108
+ try { await response.body?.cancel(); } catch { /* ignore */ }
1109
+ yield { type: "error", message: "Google AI Studio session expired — re-authentication required" };
1110
+ return;
1111
+ }
989
1112
  if (!response.body) {
990
1113
  yield { type: "error", message: "No response body" };
991
1114
  return;
@@ -998,6 +1121,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
998
1121
  const reader = response.body.getReader();
999
1122
  const decoder = new TextDecoder();
1000
1123
  const budgetEncoder = new TextEncoder();
1124
+ const contentType = response.headers.get("content-type") ?? "";
1125
+ const isHtmlContentType = contentType.toLowerCase().includes("text/html");
1126
+ const isHtmlRedirect = (text: string) => {
1127
+ const lower = text.trim().toLowerCase();
1128
+ return lower.startsWith("<!doctype") || lower.includes("accounts.google.com/v3/signin");
1129
+ };
1130
+ const reauthError = "Google AI Studio session expired — re-authentication required";
1131
+ if (isHtmlContentType) {
1132
+ yield { type: "error", message: reauthError };
1133
+ return;
1134
+ }
1001
1135
  let buffer = "";
1002
1136
  let bufferBytes = 0;
1003
1137
  // Raw unterminated-line bytes, independent of TextDecoder's pending UTF-8
@@ -1059,15 +1193,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1059
1193
  if (provider.googleMode === "cloud-code-assist" && error) observeProviderError?.(error);
1060
1194
  const err = { ...(error ?? {}), message: error?.message ?? safeMessage ?? "upstream error" };
1061
1195
  // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale.
1062
- // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig.
1063
- const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
1064
- const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
1065
- if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
1066
- && replayModel && replaySession
1067
- && !/missing.*thought_signature/i.test(err?.message ?? "")
1068
- && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) {
1069
- clearAntigravityReplay(replayModel, replaySession);
1070
- }
1196
+ // Drop the cache entry and durable store entry for rejected calls so the next turn
1197
+ // starts clean instead of re-injecting a bad sig.
1198
+ handleSignatureRejection(err?.message);
1071
1199
  yield {
1072
1200
  type: "error",
1073
1201
  ...(error?.status !== undefined ? { status: error.status } : {}),
@@ -1256,6 +1384,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1256
1384
  }
1257
1385
  incompleteLineBytes = lineScan.residual;
1258
1386
  const nextBuffer = buffer + decoder.decode(value, { stream: true });
1387
+ if (isHtmlRedirect(nextBuffer)) {
1388
+ yield { type: "error", message: reauthError };
1389
+ try { await reader.cancel(); } catch { /* ignore */ }
1390
+ return;
1391
+ }
1259
1392
  const nextBufferBytes = budgetEncoder.encode(nextBuffer).byteLength;
1260
1393
  const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" });
1261
1394
  buffer = nextBuffer;
@@ -1280,6 +1413,16 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1280
1413
  if (result === "content") sawContentEvent = true;
1281
1414
  continue;
1282
1415
  }
1416
+ if (provider.googleMode === "ai-studio-web") {
1417
+ const parsed = parseMakerSuiteChunk(line);
1418
+ if (parsed.text) {
1419
+ sawAnyFrame = true;
1420
+ sawTerminalSignal = true;
1421
+ sawContentEvent = true;
1422
+ yield { type: "text_delta", text: parsed.text };
1423
+ continue;
1424
+ }
1425
+ }
1283
1426
  sawLiveness = true;
1284
1427
  if (line.startsWith(":") || !line.trim()) continue;
1285
1428
  debugDroppedFrame("google", line);
@@ -1292,7 +1435,28 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1292
1435
  if (residual.startsWith(":")) {
1293
1436
  yield { type: "heartbeat" };
1294
1437
  } else if (!residual.startsWith("data:")) {
1295
- yield { type: "error", message: "upstream stream ended with an incomplete SSE frame — possible truncation" };
1438
+ try {
1439
+ const parsedErr = JSON.parse(residual);
1440
+ if (parsedErr.error?.message) {
1441
+ yield { type: "error", message: parsedErr.error.message };
1442
+ return;
1443
+ }
1444
+ } catch (err) {
1445
+ void err;
1446
+ }
1447
+ if (provider.googleMode === "ai-studio-web") {
1448
+ const parsed = parseMakerSuiteChunk(residual);
1449
+ if (parsed.text) {
1450
+ yield { type: "text_delta", text: parsed.text };
1451
+ yield { type: "done" };
1452
+ return;
1453
+ }
1454
+ }
1455
+ if (isHtmlContentType || isHtmlRedirect(residual)) {
1456
+ yield { type: "error", message: reauthError };
1457
+ return;
1458
+ }
1459
+ yield { type: "error", message: `upstream non-SSE response: ${residual.slice(0, 300)}` };
1296
1460
  return;
1297
1461
  } else if ((yield* handleDataLine(residual)) === "terminate") return;
1298
1462
  }
@@ -1344,7 +1508,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1344
1508
  // buffered adapter entry point, so collect the exact same events parseStream emits
1345
1509
  // instead of maintaining a second CCA JSON parser.
1346
1510
  const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
1347
- if (provider.googleMode === "cloud-code-assist" && isSse) {
1511
+ if ((provider.googleMode === "cloud-code-assist" && isSse) || provider.googleMode === "ai-studio-web") {
1348
1512
  const events: AdapterEvent[] = [];
1349
1513
  let previousTail: AdapterEvent | undefined;
1350
1514
  try {
@@ -1417,7 +1581,18 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1417
1581
  let raw: Record<string, unknown>;
1418
1582
  let rawBytes = 0;
1419
1583
  try {
1420
- raw = JSON.parse(rawText) as Record<string, unknown>;
1584
+ const parsedRaw: unknown = JSON.parse(rawText);
1585
+ // `JSON.parse("null")` returns null instead of throwing, so the catch below cannot see it
1586
+ // and the `raw.error` read crashed the turn — #1219 at the buffered body root, which #1240
1587
+ // never reached because that audit swept SSE frame parsers only. There is no next frame to
1588
+ // recover into here, so unlike a stream frame this fails closed, matching the
1589
+ // unparseable-body branch just below and the buffered candidate guards added in #2232.
1590
+ if (!isGoogleRecord(parsedRaw)) {
1591
+ budget.releaseRetained(rawTextBytes, { kind: "retained_collectors" });
1592
+ const valueType = googleStructuralValueType(parsedRaw);
1593
+ return [{ type: "error", message: `google response was not a JSON object (${valueType})` }];
1594
+ }
1595
+ raw = parsedRaw;
1421
1596
  rawBytes = new TextEncoder().encode(JSON.stringify(raw)).byteLength;
1422
1597
  const rawReservation = budget.reserveTransient(rawBytes, { kind: "retained_collectors" });
1423
1598
  rawReservation.commitRetained();
@@ -1441,16 +1616,24 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1441
1616
  message: safeMessage,
1442
1617
  });
1443
1618
  if (error) observeProviderError?.(error);
1619
+ const message = error?.message ?? safeMessage ?? "upstream error";
1620
+ handleSignatureRejection(message);
1444
1621
  return finish([{
1445
1622
  type: "error",
1446
1623
  ...(error?.status !== undefined ? { status: error.status } : {}),
1447
1624
  ...(error?.code ? { code: error.code } : {}),
1448
- message: error?.message ?? safeMessage ?? "upstream error",
1625
+ message,
1449
1626
  }]);
1450
1627
  }
1451
- const json = (provider.googleMode === "cloud-code-assist" && raw.response && typeof raw.response === "object" && !Array.isArray(raw.response))
1452
- ? (raw.response as Record<string, unknown>)
1453
- : raw;
1628
+ // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it.
1629
+ let json = raw;
1630
+ if (provider.googleMode === "cloud-code-assist") {
1631
+ const wrapped = raw.response;
1632
+ if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) {
1633
+ return finish([{ type: "error", message: "google-antigravity response missing response wrapper" }]);
1634
+ }
1635
+ json = wrapped as Record<string, unknown>;
1636
+ }
1454
1637
  const events: AdapterEvent[] = [];
1455
1638
 
1456
1639
  const rawCandidates: unknown = json.candidates;
@@ -18,6 +18,6 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string }
18
18
  */
19
19
  export function contentPartsToText(content: string | OcxContentPart[]): string {
20
20
  if (typeof content === "string") return content;
21
- const text = content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
21
+ const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join("");
22
22
  return text || "[image]";
23
23
  }
@@ -1,4 +1,19 @@
1
1
  export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
2
+
3
+ /**
4
+ * Request-scoped CodeWhisperer service profile for AWS Builder ID accounts.
5
+ *
6
+ * Builder ID is a personal identity with no AWS account behind it, so AWS never mints an
7
+ * account-scoped `profile/<id>` ARN for it. The Kiro CLI resolves this the same way: it carries
8
+ * this fixed service profile on Builder ID requests. The embedded account id is Amazon's own, not
9
+ * the user's, which is why sending it is not the same as synthesizing an account identity.
10
+ *
11
+ * Request-scoped is load-bearing. This value must never be persisted into `KiroOAuthMetadata`,
12
+ * never seed region inference (it is `us-east-1` and would pin every Builder ID account there),
13
+ * and never participate in account matching.
14
+ */
15
+ export const KIRO_BUILDER_ID_SERVICE_PROFILE_ARN =
16
+ "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX";
2
17
  export const KIRO_CONTINUATION_MESSAGE =
3
18
  "Continue from the prior conversation. Do not quote or mention this instruction.";
4
19
  export const KIRO_COMPLETION_RETRY_MESSAGE =
@@ -1,6 +1,7 @@
1
1
  import type { OcxParsedRequest, OcxTool } from "../types";
2
2
  import { namespacedToolName } from "../types";
3
3
  import { normalizeKiroModelId } from "../providers/kiro-models";
4
+ import { isCodexCodeModeExecTool } from "./tool-catalog-nudge";
4
5
  import { createKiroToolNameRegistry, type KiroToolNameRegistry } from "./kiro-wire";
5
6
 
6
7
  const MAX_KIRO_TOOL_DESCRIPTION_UNVERIFIED = 1024;
@@ -103,7 +104,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
103
104
  // Seed with the root's own properties/required so a schema like
104
105
  // { type:"object", properties:{path}, required:["path"], oneOf:[...] } keeps them.
105
106
  if (obj.properties && typeof obj.properties === "object") {
106
- Object.assign(props, sanitizeKiroSchema(obj.properties) as Record<string, unknown>);
107
+ Object.assign(props, sanitizeSchemaMap(obj.properties) as Record<string, unknown>);
107
108
  }
108
109
  if (Array.isArray(obj.required)) {
109
110
  for (const r of obj.required) if (typeof r === "string") required.add(r);
@@ -118,7 +119,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
118
119
  if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
119
120
  const v = variant as Record<string, unknown>;
120
121
  if (v.properties && typeof v.properties === "object") {
121
- Object.assign(props, sanitizeKiroSchema(v.properties) as Record<string, unknown>);
122
+ Object.assign(props, sanitizeSchemaMap(v.properties) as Record<string, unknown>);
122
123
  }
123
124
  if (mergeRequired && Array.isArray(v.required)) {
124
125
  for (const r of v.required) if (typeof r === "string") required.add(r);
@@ -174,8 +175,14 @@ function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], reg
174
175
 
175
176
  function boundedCatalogPriority(tool: OcxTool): number {
176
177
  if (tool.loadedFromToolSearch) return 0;
177
- if (tool.toolSearch) return 1;
178
- return 2;
178
+ // Codex code mode reaches shell, file edits, apply_patch and every MCP helper ONLY as nested
179
+ // `tools.<name>(...)` calls inside this one tool. Dropping it does not shrink the catalog, it
180
+ // makes the rest of the catalog uncallable -- so it outranks the search gateway and filler.
181
+ // It stays BEHIND `loadedFromToolSearch` because those are tools the model asked for by name
182
+ // this turn (#2475). Cursor pins its execution path the same way (request-builder.ts, #399).
183
+ if (isCodexCodeModeExecTool(tool)) return 1;
184
+ if (tool.toolSearch) return 2;
185
+ return 3;
179
186
  }
180
187
 
181
188
  export function convertKiroToolContext(
@@ -209,19 +216,40 @@ export function convertKiroToolContext(
209
216
  const candidates = exceedsBudget
210
217
  ? convertedEntries.toSorted((a, b) => boundedCatalogPriority(a.tool) - boundedCatalogPriority(b.tool) || a.index - b.index)
211
218
  : convertedEntries;
212
- const convertedTools: unknown[] = [];
213
- let omittedAt = candidates.length;
214
- for (const [index, entry] of candidates.entries()) {
219
+ // Reserve a seat for the code-mode execution path before filling the rest.
220
+ //
221
+ // Priority alone cannot save it. `loadedFromToolSearch` tools outrank it and arrive unbounded
222
+ // (the Responses parser pushes every `tool_search_output` spec), so a session that accumulated
223
+ // MAX_KIRO_TOOL_COUNT loaded tools would exhaust the budget before reaching tier 1 and drop the
224
+ // one tool through which all of them are actually callable.
225
+ //
226
+ // Reservation rather than eviction: this lowers the room the fill loop sees, so it admits one
227
+ // fewer tool. It never removes a tool that already fit, which is what keeps #2475's loaded-result
228
+ // guarantee intact -- Cursor's `evictNonExecutionPath` exempts only the execution path and could
229
+ // evict a loaded tool instead.
230
+ const reserved = candidates.find(entry => isCodexCodeModeExecTool(entry.tool));
231
+ const admitted = new Set<number>();
232
+ const filled: unknown[] = [];
233
+ for (const entry of candidates) {
234
+ if (entry === reserved) continue;
235
+ // Measure the projected FINAL array: the byte budget is computed over the serialized array, so
236
+ // subtracting a standalone size would misjudge it by the separators JSON adds between entries.
237
+ const projected = reserved ? [...filled, entry.converted, reserved.converted] : [...filled, entry.converted];
215
238
  if (
216
- convertedTools.length >= MAX_KIRO_TOOL_COUNT
217
- || serializedToolCatalogBytes([...convertedTools, entry.converted]) > MAX_KIRO_TOOL_CATALOG_BYTES
218
- ) {
219
- omittedAt = index;
220
- break;
221
- }
222
- convertedTools.push(entry.converted);
239
+ projected.length > MAX_KIRO_TOOL_COUNT
240
+ || serializedToolCatalogBytes(projected) > MAX_KIRO_TOOL_CATALOG_BYTES
241
+ ) break;
242
+ filled.push(entry.converted);
243
+ admitted.add(entry.index);
223
244
  }
224
- const omittedTools = candidates.slice(omittedAt).map(entry => entry.tool);
245
+ if (reserved) admitted.add(reserved.index);
246
+ // Rebuild in sorted-candidate order so the wire order stays loaded -> exec -> gateway -> filler.
247
+ // Pushing the reserved entry after the loop would place it last instead.
248
+ const convertedTools = candidates.filter(entry => admitted.has(entry.index)).map(entry => entry.converted);
249
+ // Derive omissions by set difference. The old `candidates.slice(omittedAt)` assumed every
250
+ // candidate after the first rejection was omitted, which stops being true once one of them was
251
+ // reserved and admitted: the notice would name `exec` unavailable while it is on the wire.
252
+ const omittedTools = candidates.filter(entry => !admitted.has(entry.index)).map(entry => entry.tool);
225
253
  return {
226
254
  tools: convertedTools,
227
255
  systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [],