diff --git a/src/adapters/kiro-tools.ts b/src/adapters/kiro-tools.ts index 6aa8bb2a426..960a534387d 100644 --- a/src/adapters/kiro-tools.ts +++ b/src/adapters/kiro-tools.ts @@ -172,6 +172,12 @@ function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], reg return `[opencodex] Kiro's outbound catalog budget allows ${kept} of ${kept + omitted.length} client tools this turn. Omitted and unavailable this turn: ${summary}.`; } +function boundedCatalogPriority(tool: OcxTool): number { + if (tool.loadedFromToolSearch) return 0; + if (tool.toolSearch) return 1; + return 2; +} + export function convertKiroToolContext( parsed: OcxParsedRequest, registry: KiroToolNameRegistry = createKiroToolNameRegistry(), @@ -181,9 +187,7 @@ export function convertKiroToolContext( // Validate every listed name even when tool_choice:none emulates a tool-free turn. for (const tool of tools) registry.alias(namespacedToolName(tool.namespace, tool.name)); const effectiveTools = parsed.options.toolChoice === "none" ? [] : tools; - const convertedTools: unknown[] = []; - let omittedAt = effectiveTools.length; - for (const [index, tool] of effectiveTools.entries()) { + const convertedEntries = effectiveTools.map((tool, index) => { const description = tool.description || `Tool: ${tool.name}`; // Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes // it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex @@ -198,19 +202,26 @@ export function convertKiroToolContext( inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(tool.parameters ?? {})) }, }, }; - // Preserve declaration order and only omit a suffix. Ranking tools would make a catalog change - // silently alter which capability disappears; this deterministic policy is paired with a - // model-visible omission notice so unavailable tools are explicit rather than assumed absent. + return { tool, index, converted }; + }); + const exceedsBudget = convertedEntries.length > MAX_KIRO_TOOL_COUNT + || serializedToolCatalogBytes(convertedEntries.map(entry => entry.converted)) > MAX_KIRO_TOOL_CATALOG_BYTES; + const candidates = exceedsBudget + ? convertedEntries.toSorted((a, b) => boundedCatalogPriority(a.tool) - boundedCatalogPriority(b.tool) || a.index - b.index) + : convertedEntries; + const convertedTools: unknown[] = []; + let omittedAt = candidates.length; + for (const [index, entry] of candidates.entries()) { if ( convertedTools.length >= MAX_KIRO_TOOL_COUNT - || serializedToolCatalogBytes([...convertedTools, converted]) > MAX_KIRO_TOOL_CATALOG_BYTES + || serializedToolCatalogBytes([...convertedTools, entry.converted]) > MAX_KIRO_TOOL_CATALOG_BYTES ) { omittedAt = index; break; } - convertedTools.push(converted); + convertedTools.push(entry.converted); } - const omittedTools = effectiveTools.slice(omittedAt); + const omittedTools = candidates.slice(omittedAt).map(entry => entry.tool); return { tools: convertedTools, systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [], diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index 770de3d8ded..9483ec9b3e1 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -724,6 +724,41 @@ describe("kiro adapter — buildRequest", () => { expect(current.content).toContain("Omitted and unavailable this turn"); }); + test("large catalogs prioritize tool-search discoveries and the search gateway", async () => { + const ordinaryTools = Array.from({ length: MAX_KIRO_TOOL_COUNT + 20 }, (_, index) => ({ + name: `ordinary_tool_${String(index).padStart(3, "0")}`, + description: `Ordinary tool ${index}`, + parameters: { type: "object" }, + })); + const searchGateway = { + name: "tool_search", + description: "Search deferred tools", + parameters: { type: "object" }, + toolSearch: true, + }; + const loadedTool = { + name: "codex_app__send_message_to_thread", + description: "Send a message to a task", + parameters: { type: "object" }, + loadedFromToolSearch: true, + }; + const tools = [...ordinaryTools, searchGateway, loadedTool]; + + const current = JSON.parse((await createKiroAdapter(provider).buildRequest( + parsedWith([{ role: "user", content: "hi" }], tools), + )).body).conversationState.currentMessage.userInputMessage; + const ordinary = current.userInputMessageContext.tools.slice(0, -1); + const names = ordinary.map((tool: { toolSpecification: { name: string } }) => tool.toolSpecification.name); + const omissionNotice = current.content.split("\n\n", 1)[0]; + + expect(ordinary).toHaveLength(MAX_KIRO_TOOL_COUNT); + expect(names.slice(0, 2)).toEqual([loadedTool.name, searchGateway.name]); + expect(names.slice(2)).toEqual(ordinaryTools.slice(0, MAX_KIRO_TOOL_COUNT - 2).map(tool => tool.name)); + expect(omissionNotice).toContain("ordinary_tool_046"); + expect(omissionNotice).not.toContain(loadedTool.name); + expect(omissionNotice).not.toContain(searchGateway.name); + }); + test("large catalogs retain the declared prefix within Kiro's serialized byte budget", async () => { // Top-level descriptions stay small, so existing description truncation cannot make this pass. // The repeated schema descriptions instead make the aggregate converted catalog exceed 96 KiB.