mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix(kiro): canonicalize tool history and route API keys correctly
Route API-key inference through Amazon Q first, enforce adjacent one-to-one tool use/result pairs after session replay, and treat payload-invalid HTTP 400 as terminal.
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
KIRO_TOOL_DESCRIPTION_MAX_LENGTH,
|
||||
KIRO_TOOL_ID_MAX_LENGTH,
|
||||
KIRO_TOOL_NAME_MAX_LENGTH,
|
||||
} from "../../config/kiroConstants.js";
|
||||
|
||||
const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g;
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) return "";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function appendText(target, extra) {
|
||||
if (!extra) return;
|
||||
target.content = target.content ? `${target.content}\n\n${extra}` : extra;
|
||||
}
|
||||
|
||||
function trimCodePoints(value, limit) {
|
||||
return [...String(value || "")].slice(0, limit).join("");
|
||||
}
|
||||
|
||||
function uniqueName(rawName, index, usedNames) {
|
||||
const cleaned = String(rawName || "")
|
||||
.trim()
|
||||
.replace(TOOL_NAME_PATTERN, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH);
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (usedNames.has(candidate)) {
|
||||
const tail = `_${suffix++}`;
|
||||
candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`;
|
||||
}
|
||||
usedNames.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function cleanSchemaValue(value) {
|
||||
if (Array.isArray(value)) return value.map(cleanSchemaValue);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
|
||||
const cleaned = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "additionalProperties") continue;
|
||||
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
||||
cleaned[key] = cleanSchemaValue(child);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function normalizeRootSchema(schema) {
|
||||
const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {});
|
||||
cleaned.type = "object";
|
||||
if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) {
|
||||
cleaned.properties = {};
|
||||
}
|
||||
if (Array.isArray(cleaned.required)) {
|
||||
cleaned.required = [...new Set(cleaned.required.filter(
|
||||
(name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name)
|
||||
))];
|
||||
if (cleaned.required.length === 0) delete cleaned.required;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */
|
||||
export function normalizeKiroToolSpecs(tools) {
|
||||
const specs = [];
|
||||
const nameMap = new Map();
|
||||
const usedNames = new Set();
|
||||
|
||||
for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) {
|
||||
if (!tool || typeof tool !== "object") continue;
|
||||
const rawName = tool.function?.name ?? tool.name;
|
||||
if (typeof rawName !== "string" || !rawName.trim()) continue;
|
||||
|
||||
// A repeated definition with the same source name describes the same tool.
|
||||
if (nameMap.has(rawName)) continue;
|
||||
const name = uniqueName(rawName, index, usedNames);
|
||||
nameMap.set(rawName, name);
|
||||
|
||||
const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`;
|
||||
const description = trimCodePoints(
|
||||
String(rawDescription || `Tool: ${rawName}`),
|
||||
KIRO_TOOL_DESCRIPTION_MAX_LENGTH
|
||||
);
|
||||
const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {};
|
||||
specs.push({
|
||||
toolSpecification: {
|
||||
name,
|
||||
description,
|
||||
inputSchema: { json: normalizeRootSchema(schema) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { specs, nameMap };
|
||||
}
|
||||
|
||||
function toolCallText(toolUse) {
|
||||
return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`;
|
||||
}
|
||||
|
||||
function toolResultText(toolResult) {
|
||||
const content = Array.isArray(toolResult?.content)
|
||||
? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n")
|
||||
: text(toolResult?.content);
|
||||
return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`;
|
||||
}
|
||||
|
||||
function mergeUser(target, source) {
|
||||
appendText(target, source.content);
|
||||
if (Array.isArray(source.images) && source.images.length > 0) {
|
||||
target.images = [...(target.images || []), ...source.images];
|
||||
}
|
||||
const results = source.userInputMessageContext?.toolResults;
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
target.userInputMessageContext ||= {};
|
||||
target.userInputMessageContext.toolResults = [
|
||||
...(target.userInputMessageContext.toolResults || []),
|
||||
...results,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAssistant(target, source) {
|
||||
appendText(target, source.content);
|
||||
if (Array.isArray(source.toolUses) && source.toolUses.length > 0) {
|
||||
target.toolUses = [...(target.toolUses || []), ...source.toolUses];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTurns(history, currentMessage, modelId) {
|
||||
const rawTurns = [...(Array.isArray(history) ? history : [])];
|
||||
if (currentMessage) rawTurns.push(currentMessage);
|
||||
const turns = [];
|
||||
|
||||
for (const raw of rawTurns) {
|
||||
const isUser = !!raw?.userInputMessage;
|
||||
const isAssistant = !!raw?.assistantResponseMessage;
|
||||
if (isUser === isAssistant) continue;
|
||||
|
||||
const turn = isUser
|
||||
? { userInputMessage: clone(raw.userInputMessage) }
|
||||
: { assistantResponseMessage: clone(raw.assistantResponseMessage) };
|
||||
const previous = turns[turns.length - 1];
|
||||
if (turn.userInputMessage && previous?.userInputMessage) {
|
||||
mergeUser(previous.userInputMessage, turn.userInputMessage);
|
||||
} else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) {
|
||||
mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage);
|
||||
} else {
|
||||
turns.push(turn);
|
||||
}
|
||||
}
|
||||
|
||||
if (turns[0]?.assistantResponseMessage) {
|
||||
turns.unshift({ userInputMessage: { content: "continue", modelId } });
|
||||
}
|
||||
if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) {
|
||||
turns.push({ userInputMessage: { content: "continue", modelId } });
|
||||
}
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.userInputMessage) {
|
||||
turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue";
|
||||
turn.userInputMessage.modelId ||= modelId;
|
||||
if (turn.userInputMessage.userInputMessageContext?.tools) {
|
||||
delete turn.userInputMessage.userInputMessageContext.tools;
|
||||
}
|
||||
} else {
|
||||
turn.assistantResponseMessage.content =
|
||||
text(turn.assistantResponseMessage.content).trim() || "...";
|
||||
}
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function rawId(value) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function reserveToolId(value, turnIndex, callIndex, name, usedIds) {
|
||||
const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`;
|
||||
const base = trimCodePoints(
|
||||
TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated,
|
||||
KIRO_TOOL_ID_MAX_LENGTH
|
||||
);
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
const tail = `_${suffix++}`;
|
||||
candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeToolInput(input) {
|
||||
if (input && typeof input === "object" && !Array.isArray(input)) return clone(input);
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return input == null ? {} : null;
|
||||
}
|
||||
|
||||
function normalizeToolResult(result) {
|
||||
const content = Array.isArray(result?.content)
|
||||
? result.content.map((part) => ({ text: text(part?.text ?? part) }))
|
||||
: [{ text: text(result?.content) }];
|
||||
return {
|
||||
toolUseId: rawId(result?.toolUseId),
|
||||
status: result?.status === "error" ? "error" : "success",
|
||||
content: content.length > 0 ? content : [{ text: "" }],
|
||||
};
|
||||
}
|
||||
|
||||
function flattenResults(userMessage, results) {
|
||||
for (const result of results) appendText(userMessage, toolResultText(result));
|
||||
}
|
||||
|
||||
function cleanUserContext(userMessage) {
|
||||
const context = userMessage.userInputMessageContext;
|
||||
if (!context) return;
|
||||
if (!context.toolResults?.length) delete context.toolResults;
|
||||
if (!context.tools?.length) delete context.tools;
|
||||
if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext;
|
||||
}
|
||||
|
||||
function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) {
|
||||
const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : [];
|
||||
const results = Array.isArray(user.userInputMessageContext?.toolResults)
|
||||
? user.userInputMessageContext.toolResults.map(normalizeToolResult)
|
||||
: [];
|
||||
if (calls.length === 0) {
|
||||
if (results.length > 0) {
|
||||
flattenResults(user, results);
|
||||
repairs.orphanResults += results.length;
|
||||
}
|
||||
if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults;
|
||||
cleanUserContext(user);
|
||||
return;
|
||||
}
|
||||
|
||||
const callQueues = new Map();
|
||||
const callRecords = calls.map((call, callIndex) => {
|
||||
const key = rawId(call?.toolUseId);
|
||||
const mappedName = nameMap.get(call?.name) || call?.name;
|
||||
const input = normalizeToolInput(call?.input);
|
||||
const record = { call, callIndex, key, mappedName, input, result: null };
|
||||
const queue = callQueues.get(key) || [];
|
||||
queue.push(record);
|
||||
callQueues.set(key, queue);
|
||||
return record;
|
||||
});
|
||||
|
||||
const orphanResults = [];
|
||||
for (const result of results) {
|
||||
const queue = callQueues.get(rawId(result.toolUseId));
|
||||
const record = queue?.find((candidate) => !candidate.result);
|
||||
if (record) record.result = result;
|
||||
else orphanResults.push(result);
|
||||
}
|
||||
|
||||
const keptCalls = [];
|
||||
const keptResults = [];
|
||||
for (const record of callRecords) {
|
||||
const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName);
|
||||
const valid = !!record.result && hasSpec && record.input !== null;
|
||||
if (!valid) {
|
||||
appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input }));
|
||||
repairs.missingResults += record.result ? 0 : 1;
|
||||
repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1;
|
||||
if (record.result) {
|
||||
flattenResults(user, [record.result]);
|
||||
repairs.orphanResults++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolUseId = reserveToolId(
|
||||
record.key,
|
||||
turnIndex,
|
||||
record.callIndex,
|
||||
record.mappedName,
|
||||
usedIds
|
||||
);
|
||||
keptCalls.push({
|
||||
toolUseId,
|
||||
name: record.mappedName,
|
||||
input: record.input,
|
||||
});
|
||||
keptResults.push({ ...record.result, toolUseId });
|
||||
}
|
||||
|
||||
if (orphanResults.length > 0) {
|
||||
flattenResults(user, orphanResults);
|
||||
repairs.orphanResults += orphanResults.length;
|
||||
}
|
||||
|
||||
if (keptCalls.length > 0) assistant.toolUses = keptCalls;
|
||||
else delete assistant.toolUses;
|
||||
user.userInputMessageContext ||= {};
|
||||
if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults;
|
||||
else delete user.userInputMessageContext.toolResults;
|
||||
cleanUserContext(user);
|
||||
}
|
||||
|
||||
/** Validate the final Kiro wire conversation without mutating it. */
|
||||
export function validateKiroConversation(history, currentMessage, toolSpecs = []) {
|
||||
const errors = [];
|
||||
const turns = [...(history || []), currentMessage].filter(Boolean);
|
||||
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
|
||||
const usedIds = new Set();
|
||||
|
||||
for (let index = 0; index < turns.length; index++) {
|
||||
const expectedUser = index % 2 === 0;
|
||||
const isUser = !!turns[index]?.userInputMessage;
|
||||
if (isUser !== expectedUser) errors.push(`role:${index}`);
|
||||
if (!isUser) {
|
||||
const calls = turns[index].assistantResponseMessage?.toolUses || [];
|
||||
const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || [];
|
||||
const callIds = calls.map((call) => call.toolUseId);
|
||||
const resultIds = results.map((result) => result.toolUseId);
|
||||
if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) {
|
||||
errors.push(`pair:${index}`);
|
||||
}
|
||||
for (const call of calls) {
|
||||
if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`);
|
||||
usedIds.add(call.toolUseId);
|
||||
if (!specNames.has(call.name)) errors.push(`spec:${index}`);
|
||||
}
|
||||
} else if (index === 0) {
|
||||
const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults;
|
||||
if (results?.length) errors.push("orphan:0");
|
||||
}
|
||||
}
|
||||
if (!currentMessage?.userInputMessage?.content) errors.push("current");
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function flattenAllStructuredTools(turns, repairs) {
|
||||
for (const turn of turns) {
|
||||
if (turn.assistantResponseMessage?.toolUses?.length) {
|
||||
for (const call of turn.assistantResponseMessage.toolUses) {
|
||||
appendText(turn.assistantResponseMessage, toolCallText(call));
|
||||
}
|
||||
repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length;
|
||||
delete turn.assistantResponseMessage.toolUses;
|
||||
}
|
||||
const user = turn.userInputMessage;
|
||||
const results = user?.userInputMessageContext?.toolResults;
|
||||
if (results?.length) {
|
||||
flattenResults(user, results);
|
||||
repairs.orphanResults += results.length;
|
||||
delete user.userInputMessageContext.toolResults;
|
||||
cleanUserContext(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a strict Kiro conversation: alternating turns, current user message,
|
||||
* adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage.
|
||||
*/
|
||||
export function canonicalizeKiroConversation({
|
||||
history,
|
||||
currentMessage,
|
||||
modelId,
|
||||
toolSpecs = [],
|
||||
nameMap = new Map(),
|
||||
} = {}) {
|
||||
const turns = normalizeTurns(history, currentMessage, modelId);
|
||||
const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 };
|
||||
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
|
||||
const usedIds = new Set();
|
||||
|
||||
for (let index = 0; index < turns.length; index += 2) {
|
||||
const user = turns[index].userInputMessage;
|
||||
if (index === 0) {
|
||||
const leadingResults = user.userInputMessageContext?.toolResults || [];
|
||||
if (leadingResults.length > 0) {
|
||||
flattenResults(user, leadingResults);
|
||||
repairs.orphanResults += leadingResults.length;
|
||||
delete user.userInputMessageContext.toolResults;
|
||||
cleanUserContext(user);
|
||||
}
|
||||
}
|
||||
const assistant = turns[index + 1]?.assistantResponseMessage;
|
||||
const nextUser = turns[index + 2]?.userInputMessage;
|
||||
if (assistant && nextUser) {
|
||||
reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCurrent = turns[turns.length - 1];
|
||||
finalCurrent.userInputMessage.userInputMessageContext ||= {};
|
||||
if (toolSpecs.length > 0) {
|
||||
finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs);
|
||||
}
|
||||
cleanUserContext(finalCurrent.userInputMessage);
|
||||
|
||||
let finalHistory = turns.slice(0, -1);
|
||||
let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
|
||||
if (!validation.valid) {
|
||||
flattenAllStructuredTools(turns, repairs);
|
||||
finalHistory = turns.slice(0, -1);
|
||||
validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
|
||||
}
|
||||
|
||||
return {
|
||||
history: finalHistory,
|
||||
currentMessage: finalCurrent,
|
||||
repairs,
|
||||
valid: validation.valid,
|
||||
errors: validation.errors,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user