Skip to content

Commit 6e1cdb7

Browse files
committed
fix(kiro): bound the tool catalog so large MCP sessions still generate
truncateDescription() shortened each description but nothing capped the catalog as a whole, so a session with many MCP tools shipped an unbounded spec list and Kiro returned CONTENT_FILTERED before generating anything. The catalog is now capped at 48 tools and 96 KiB of converted JSON. Issue #719's own successful probe used 49 outbound tools at roughly 108 KiB, so the caps sit just under a size known to work, leaving a slot for Kiro's private completion tool and headroom for the enclosing request. These are not published limits and the constants say so. What gets dropped matters more than the number. Declaration order is preserved and only the oversized suffix is omitted, so the same session yields the same catalog every time. The omission is reported to the model with a count and the first omitted names, because a tool that silently disappears is indistinguishable to the model from a tool that never existed -- it will confidently tell the user the capability is unavailable. Better to say what was cut. Ablated by raising both caps out of reach: the count and byte tests both fail. Neither passes on per-description truncation alone, which was the trap worth avoiding here.
1 parent f2b61ee commit 6e1cdb7

3 files changed

Lines changed: 101 additions & 18 deletions

File tree

src/adapters/kiro-tools.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import type { OcxParsedRequest } from "../types";
1+
import type { OcxParsedRequest, OcxTool } from "../types";
22
import { namespacedToolName } from "../types";
33
import { normalizeKiroModelId } from "../providers/kiro-models";
44
import { createKiroToolNameRegistry, type KiroToolNameRegistry } from "./kiro-wire";
55

66
const MAX_KIRO_TOOL_DESCRIPTION_UNVERIFIED = 1024;
77
const MAX_KIRO_TOOL_DESCRIPTION_GPT_56_SOL = 9_216;
8+
// Issue #719's successful Kiro probe used 49 outbound tools at about 108 KiB. Leave one of those
9+
// slots for Kiro's private completion tool and headroom for the enclosing request/message fields.
10+
export const MAX_KIRO_TOOL_COUNT = 48;
11+
export const MAX_KIRO_TOOL_CATALOG_BYTES = 96_000;
12+
const textEncoder = new TextEncoder();
813

914
// JSON Schema validation/annotation keywords that Kiro's runtimeservice tool-spec validator
1015
// rejects ("ValidationException: Invalid tool use format."). Codex's built-in tools omit these,
@@ -147,6 +152,17 @@ function truncateDescription(description: string, limit: number): string {
147152
return `${description.slice(0, limit - 1)}…`;
148153
}
149154

155+
function serializedToolCatalogBytes(tools: readonly unknown[]): number {
156+
return textEncoder.encode(JSON.stringify(tools)).byteLength;
157+
}
158+
159+
function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], registry: KiroToolNameRegistry): string {
160+
const names = omitted.slice(0, 12).map(tool => registry.alias(namespacedToolName(tool.namespace, tool.name)));
161+
const remainder = omitted.length - names.length;
162+
const summary = `${names.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
163+
return `[opencodex] Kiro's outbound catalog budget allows ${kept} of ${kept + omitted.length} client tools this turn. Omitted and unavailable this turn: ${summary}.`;
164+
}
165+
150166
export function convertKiroToolContext(
151167
parsed: OcxParsedRequest,
152168
registry: KiroToolNameRegistry = createKiroToolNameRegistry(),
@@ -156,24 +172,39 @@ export function convertKiroToolContext(
156172
// Validate every listed name even when tool_choice:none emulates a tool-free turn.
157173
for (const tool of tools) registry.alias(namespacedToolName(tool.namespace, tool.name));
158174
const effectiveTools = parsed.options.toolChoice === "none" ? [] : tools;
175+
const convertedTools: unknown[] = [];
176+
let omittedAt = effectiveTools.length;
177+
for (const [index, tool] of effectiveTools.entries()) {
178+
const description = tool.description || `Tool: ${tool.name}`;
179+
// Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes
180+
// it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex
181+
// routes by. Kiro's runtimeservice rejects names with spaces or >64 chars, so normalize to a
182+
// safe form and remember the mapping; the response parser restores the original wire name.
183+
const wireName = namespacedToolName(tool.namespace, tool.name);
184+
const toolName = registry.alias(wireName);
185+
const converted = {
186+
toolSpecification: {
187+
name: toolName,
188+
description: truncateDescription(description, descriptionLimit),
189+
inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(tool.parameters ?? {})) },
190+
},
191+
};
192+
// Preserve declaration order and only omit a suffix. Ranking tools would make a catalog change
193+
// silently alter which capability disappears; this deterministic policy is paired with a
194+
// model-visible omission notice so unavailable tools are explicit rather than assumed absent.
195+
if (
196+
convertedTools.length >= MAX_KIRO_TOOL_COUNT
197+
|| serializedToolCatalogBytes([...convertedTools, converted]) > MAX_KIRO_TOOL_CATALOG_BYTES
198+
) {
199+
omittedAt = index;
200+
break;
201+
}
202+
convertedTools.push(converted);
203+
}
204+
const omittedTools = effectiveTools.slice(omittedAt);
159205
return {
160-
tools: effectiveTools.map(t => {
161-
const description = t.description || `Tool: ${t.name}`;
162-
// Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes
163-
// it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex
164-
// routes by. Kiro's runtimeservice rejects names with spaces or >64 chars, so normalize to a
165-
// safe form and remember the mapping; the response parser restores the original wire name.
166-
const wireName = namespacedToolName(t.namespace, t.name);
167-
const toolName = registry.alias(wireName);
168-
return {
169-
toolSpecification: {
170-
name: toolName,
171-
description: truncateDescription(description, descriptionLimit),
172-
inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(t.parameters ?? {})) },
173-
},
174-
};
175-
}),
176-
systemAdditions: [],
206+
tools: convertedTools,
207+
systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [],
177208
nameMap: registry.nameMap,
178209
registry,
179210
};

src/adapters/kiro.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@ export function buildKiroPayload(
429429
// Neutralize Codex's GPT-5 identity line so a routed Kiro model never misreports as GPT-5/OpenAI
430430
// and the proxy identity never leaks upstream.
431431
if (parsed.context.systemPrompt?.length) systemParts.push(neutralizeIdentity(parsed.context.systemPrompt.join("\n\n")));
432+
for (const addition of toolContext.systemAdditions) {
433+
const boundedAddition = boundedInjectedInstruction(addition, injectedChars);
434+
if (boundedAddition) systemParts.push(boundedAddition);
435+
}
432436
const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(kiroToolWireNames(kiroTools));
433437
const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined;
434438
if (boundedNudge) systemParts.push(boundedNudge);

tests/kiro-adapter.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { createKiroAdapter } from "../src/adapters/kiro";
77
import { KIRO_TOOL_RESULT_CARRIER_MESSAGE } from "../src/adapters/kiro-constants";
8+
import { MAX_KIRO_TOOL_CATALOG_BYTES, MAX_KIRO_TOOL_COUNT } from "../src/adapters/kiro-tools";
89
import { applyProviderConfigHints, buildCatalogEntries } from "../src/codex/catalog";
910
import { getValidAccessTokenSnapshot } from "../src/oauth";
1011
import { saveCredential } from "../src/oauth/store";
@@ -674,6 +675,53 @@ describe("kiro adapter — buildRequest", () => {
674675
expect(verifiedSpec.description.endsWith("…")).toBe(true);
675676
});
676677

678+
test("large catalogs retain the declared prefix within Kiro's count budget", async () => {
679+
// Each top-level description is below the existing per-description cap: this proves the
680+
// aggregate count budget, rather than that older truncation behavior, limits the catalog.
681+
const tools = Array.from({ length: MAX_KIRO_TOOL_COUNT + 20 }, (_, index) => ({
682+
name: `count_tool_${String(index).padStart(3, "0")}`,
683+
description: `Brief description ${index}`,
684+
parameters: { type: "object" },
685+
}));
686+
const current = JSON.parse((await createKiroAdapter(provider).buildRequest(
687+
parsedWith([{ role: "user", content: "hi" }], tools),
688+
)).body).conversationState.currentMessage.userInputMessage;
689+
const ordinary = current.userInputMessageContext.tools.slice(0, -1);
690+
691+
expect(ordinary).toHaveLength(MAX_KIRO_TOOL_COUNT);
692+
expect(ordinary.map((tool: { toolSpecification: { name: string } }) => tool.toolSpecification.name)).toEqual(
693+
tools.slice(0, MAX_KIRO_TOOL_COUNT).map(tool => tool.name),
694+
);
695+
expect(current.content).toContain(`Kiro's outbound catalog budget allows ${MAX_KIRO_TOOL_COUNT} of ${tools.length} client tools`);
696+
expect(current.content).toContain("count_tool_048");
697+
expect(current.content).toContain("Omitted and unavailable this turn");
698+
});
699+
700+
test("large catalogs retain the declared prefix within Kiro's serialized byte budget", async () => {
701+
// Top-level descriptions stay small, so existing description truncation cannot make this pass.
702+
// The repeated schema descriptions instead make the aggregate converted catalog exceed 96 KiB.
703+
const tools = Array.from({ length: 40 }, (_, index) => ({
704+
name: `byte_tool_${String(index).padStart(3, "0")}`,
705+
description: `Brief description ${index}`,
706+
parameters: {
707+
type: "object",
708+
properties: { payload: { type: "string", description: "x".repeat(8_000) } },
709+
},
710+
}));
711+
const current = JSON.parse((await createKiroAdapter(provider).buildRequest(
712+
parsedWith([{ role: "user", content: "hi" }], tools),
713+
)).body).conversationState.currentMessage.userInputMessage;
714+
const ordinary = current.userInputMessageContext.tools.slice(0, -1);
715+
const serializedBytes = new TextEncoder().encode(JSON.stringify(ordinary)).byteLength;
716+
717+
expect(ordinary.length).toBeLessThan(tools.length);
718+
expect(serializedBytes).toBeLessThanOrEqual(MAX_KIRO_TOOL_CATALOG_BYTES);
719+
expect(ordinary.map((tool: { toolSpecification: { name: string } }) => tool.toolSpecification.name)).toEqual(
720+
tools.slice(0, ordinary.length).map(tool => tool.name),
721+
);
722+
expect(current.content).toContain(`Kiro's outbound catalog budget allows ${ordinary.length} of ${tools.length} client tools`);
723+
});
724+
677725
test("historical tool calls stay structured when the current catalog is omitted", async () => {
678726
const messages = [
679727
{ role: "user", content: "run it" },

0 commit comments

Comments
 (0)