Skip to content

Commit 2152f7a

Browse files
committed
Merge branch 'dev' into fix/756-alibaba-coding-china
2 parents 956d3b5 + 6e1cdb7 commit 2152f7a

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)