From 22779aa8fd38185a80f91deea24933b126d5d40e Mon Sep 17 00:00:00 2001 From: Gioxaa Date: Mon, 11 May 2026 12:00:12 +0700 Subject: [PATCH 1/2] fix(kiro): synthesize tools schema when history references tool_calls without body.tools --- open-sse/translator/request/openai-to-kiro.ts | 66 +++++++++++--- tests/unit/translator-openai-to-kiro.test.ts | 87 +++++++++++++++++++ 2 files changed, 141 insertions(+), 12 deletions(-) diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index ff3957aa38d..0a90f4538f2 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -55,6 +55,7 @@ function convertMessages(messages, tools, model) { let pendingToolResults = []; let pendingImages: Array<{ format: string; source: { bytes: string } }> = []; let currentRole = null; + let toolsAttached = false; const flushPending = () => { if (currentRole === "user") { @@ -88,8 +89,12 @@ function convertMessages(messages, tools, model) { userMsg.userInputMessage.images = pendingImages; } - // Add tools to first user message - if (tools && tools.length > 0 && history.length === 0) { + // Add tools to the first emitted user turn. We track a flag instead of + // relying on `history.length === 0` because the first few messages may + // be assistant turns (e.g. when role=undefined collapses to a prior + // assistant turn), in which case the first user flush would already see + // a non-empty history and lose the tools schema. + if (tools && tools.length > 0 && !toolsAttached) { if (!userMsg.userInputMessage.userInputMessageContext) { userMsg.userInputMessage.userInputMessageContext = {}; } @@ -126,6 +131,7 @@ function convertMessages(messages, tools, model) { if (toolDocs.length > 0) { userMsg._toolDocs = toolDocs.join("\n\n---\n\n"); } + toolsAttached = true; } history.push(userMsg); @@ -298,16 +304,19 @@ function convertMessages(messages, tools, model) { }; } - const firstHistoryItem = history[0]; - if ( - firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools && - !currentMessage?.userInputMessage?.userInputMessageContext?.tools - ) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; + // Promote the tools schema to currentMessage. Tools may have been attached + // to any user turn in history (e.g. when the first message was assistant or + // had an undefined role, the first user flush lands further down). Scan the + // whole history so we never lose the schema. + if (!currentMessage?.userInputMessage?.userInputMessageContext?.tools) { + const carrier = history.find((item) => item?.userInputMessage?.userInputMessageContext?.tools); + if (carrier?.userInputMessage?.userInputMessageContext?.tools) { + if (!currentMessage.userInputMessage.userInputMessageContext) { + currentMessage.userInputMessage.userInputMessageContext = {}; + } + currentMessage.userInputMessage.userInputMessageContext.tools = + carrier.userInputMessage.userInputMessageContext.tools; } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryItem.userInputMessage.userInputMessageContext.tools; } // Clean up history for Kiro API compatibility @@ -385,11 +394,44 @@ export function buildKiroPayload(model, body, stream, credentials) { "$1.$2" ); const messages = body.messages || []; - const tools = body.tools || []; + let tools = body.tools || []; const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000; const temperature = body.temperature; const topP = body.top_p; + // Kiro rejects history that references toolUses/toolResults without a tools + // schema in userInputMessageContext. When callers omit body.tools but the + // message history still contains assistant.tool_calls / role=tool turns, + // synthesize a minimal tool schema from the tool names present in history + // so Kiro accepts the request instead of returning `Improperly formed + // request`. This preserves tool-call history and is a no-op when body.tools + // is already populated. + if (tools.length === 0) { + const seen = new Set(); + const synthesized: Array> = []; + for (const msg of messages) { + if (msg?.role === "assistant" && Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + const name = tc?.function?.name || tc?.name; + if (typeof name === "string" && name && !seen.has(name)) { + seen.add(name); + synthesized.push({ + type: "function", + function: { + name, + description: `Tool: ${name}`, + parameters: { type: "object", properties: {}, required: [] }, + }, + }); + } + } + } + } + if (synthesized.length > 0) { + tools = synthesized; + } + } + const { history, currentMessage } = convertMessages(messages, tools, normalizedModel); const profileArn = credentials?.providerSpecificData?.profileArn || ""; diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index adf7d46b375..f938689657d 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -289,3 +289,90 @@ test("OpenAI -> Kiro merges adjacent user history turns after role normalization assert.equal(firstUser.content, "System rules\n\nFirst question"); assert.equal(history[1].assistantResponseMessage?.content, "Answer 1"); }); + +test("OpenAI -> Kiro synthesizes tools schema when body.tools is omitted but history has tool_calls", () => { + const result = buildKiroPayload( + "claude-opus-4.7", + { + messages: [ + { role: "user", content: "Start" }, + { + role: "assistant", + tool_calls: [ + { + id: "tooluse_1", + type: "function", + function: { name: "edit", arguments: '{"path":"x"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "tooluse_1", content: "ok" }, + { + role: "assistant", + tool_calls: [ + { + id: "tooluse_2", + type: "function", + function: { name: "bash", arguments: '{"cmd":"ls"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "tooluse_2", content: "listing" }, + { role: "user", content: "Continue" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + tools?: Array<{ toolSpecification: { name: string } }>; + }; + const tools = ctx?.tools; + assert.ok(tools, "synthesized tools schema should be attached to currentMessage"); + const names = tools.map((t) => t.toolSpecification.name).sort(); + assert.deepEqual(names, ["bash", "edit"]); +}); + +test("OpenAI -> Kiro does not override body.tools when caller already provides a schema", () => { + const result = buildKiroPayload( + "claude-opus-4.7", + { + messages: [ + { role: "user", content: "Start" }, + { + role: "assistant", + tool_calls: [ + { + id: "tooluse_1", + type: "function", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "tooluse_1", content: "ok" }, + { role: "user", content: "Continue" }, + ], + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Real description", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + tools?: Array<{ toolSpecification: { name: string; description: string } }>; + }; + const tools = ctx.tools; + assert.ok(tools); + assert.equal(tools.length, 1); + assert.equal(tools[0].toolSpecification.description, "Real description"); +}); From 29d1d733bd8610d27455d81790a9524a0f81867e Mon Sep 17 00:00:00 2001 From: Gioxaa Date: Mon, 11 May 2026 12:22:54 +0700 Subject: [PATCH 2/2] fix(kiro): also synthesize from Anthropic tool_use blocks and attach tools when history has no user turn --- open-sse/translator/request/openai-to-kiro.ts | 75 +++++++++++++++---- tests/unit/translator-openai-to-kiro.test.ts | 63 ++++++++++++++++ 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 0a90f4538f2..a558579f338 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -319,6 +319,38 @@ function convertMessages(messages, tools, model) { } } + // Fallback: if the schema was never attached to any user turn (e.g. the + // input contained no user messages and currentMessage is a synthesized + // "Continue" turn), attach the provided tools directly to currentMessage so + // Kiro still sees the schema it needs to validate assistant.toolUses in + // history. + if ( + !toolsAttached && + tools && + tools.length > 0 && + !currentMessage?.userInputMessage?.userInputMessageContext?.tools + ) { + if (!currentMessage.userInputMessage.userInputMessageContext) { + currentMessage.userInputMessage.userInputMessageContext = {}; + } + currentMessage.userInputMessage.userInputMessageContext.tools = tools.map((t) => { + const name = t.function?.name || t.name; + const description = t.function?.description || t.description || `Tool: ${name}`; + return { + toolSpecification: { + name, + description, + inputSchema: { + json: normalizeKiroToolSchema( + t.function?.parameters || t.parameters || t.input_schema || {} + ), + }, + }, + }; + }); + toolsAttached = true; + } + // Clean up history for Kiro API compatibility history.forEach((item) => { if (item.userInputMessage?.userInputMessageContext?.tools) { @@ -380,7 +412,7 @@ function convertMessages(messages, tools, model) { } } - return { history: mergedHistory, currentMessage }; + return { history: mergedHistory, currentMessage, toolsAttached }; } /** @@ -409,20 +441,31 @@ export function buildKiroPayload(model, body, stream, credentials) { if (tools.length === 0) { const seen = new Set(); const synthesized: Array> = []; + const pushName = (name: unknown) => { + if (typeof name === "string" && name && !seen.has(name)) { + seen.add(name); + synthesized.push({ + type: "function", + function: { + name, + description: `Tool: ${name}`, + parameters: { type: "object", properties: {}, required: [] }, + }, + }); + } + }; for (const msg of messages) { - if (msg?.role === "assistant" && Array.isArray(msg.tool_calls)) { + if (msg?.role !== "assistant") continue; + if (Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { - const name = tc?.function?.name || tc?.name; - if (typeof name === "string" && name && !seen.has(name)) { - seen.add(name); - synthesized.push({ - type: "function", - function: { - name, - description: `Tool: ${name}`, - parameters: { type: "object", properties: {}, required: [] }, - }, - }); + pushName(tc?.function?.name || tc?.name); + } + } + // Anthropic-style assistant blocks: content:[{type:"tool_use", name, ...}] + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block?.type === "tool_use") { + pushName(block.name); } } } @@ -432,7 +475,11 @@ export function buildKiroPayload(model, body, stream, credentials) { } } - const { history, currentMessage } = convertMessages(messages, tools, normalizedModel); + const { history, currentMessage, toolsAttached } = convertMessages( + messages, + tools, + normalizedModel + ); const profileArn = credentials?.providerSpecificData?.profileArn || ""; diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index f938689657d..6bccb7b1be2 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -376,3 +376,66 @@ test("OpenAI -> Kiro does not override body.tools when caller already provides a assert.equal(tools.length, 1); assert.equal(tools[0].toolSpecification.description, "Real description"); }); + +test("OpenAI -> Kiro synthesizes tools from Anthropic-style tool_use content blocks", () => { + const result = buildKiroPayload( + "claude-opus-4.7", + { + messages: [ + { role: "user", content: "Start" }, + { + role: "assistant", + content: [ + { type: "text", text: "Calling tools" }, + { type: "tool_use", id: "tu_1", name: "search", input: { q: "x" } }, + { type: "tool_use", id: "tu_2", name: "open_file", input: { path: "a" } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu_1", content: [{ type: "text", text: "hit" }] }, + { type: "tool_result", tool_use_id: "tu_2", content: [{ type: "text", text: "ok" }] }, + ], + }, + { role: "user", content: "continue" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + tools?: Array<{ toolSpecification: { name: string } }>; + }; + const tools = ctx?.tools; + assert.ok(tools, "tools should be synthesized from tool_use content blocks"); + const names = tools.map((t) => t.toolSpecification.name).sort(); + assert.deepEqual(names, ["open_file", "search"]); +}); + +test("OpenAI -> Kiro attaches tools to currentMessage when history has no user turn to carry them", () => { + const result = buildKiroPayload( + "claude-opus-4.7", + { + messages: [ + { + role: "assistant", + tool_calls: [ + { id: "tc_1", type: "function", function: { name: "edit", arguments: "{}" } }, + ], + }, + ], + }, + false, + null + ); + + const cm = result.conversationState.currentMessage.userInputMessage; + const ctx = cm.userInputMessageContext as { + tools?: Array<{ toolSpecification: { name: string } }>; + }; + assert.ok(ctx?.tools, "tools should be attached to currentMessage fallback"); + assert.equal(ctx.tools!.length, 1); + assert.equal(ctx.tools![0].toolSpecification.name, "edit"); +});