mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(cursor): stabilize tool result handoff for Cursor tool calls (#275)
- Stop double-translation in Cursor executor path (preserved tool_results) - Normalize OpenAI+Anthropic tool flows into Cursor-compatible shapes - escapeXml() prevents tag injection in tool_result blocks - Set-based finalizedIds for O(1) deduplication (replaces O(n) .find) - Byte guard (0x7b) before payload.toString on non-JSON frames - Convert console.log noise to debugLog() (prod clean) Fixes #274. Reviewed-by: Antigravity
This commit is contained in:
@@ -29,7 +29,6 @@ import {
|
||||
} from "../utils/cursorProtobuf.ts";
|
||||
import { estimateUsage } from "../utils/usageTracking.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { buildCursorRequest } from "../translator/request/openai-to-cursor.ts";
|
||||
import crypto from "crypto";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import zlib from "zlib";
|
||||
@@ -59,13 +58,18 @@ const COMPRESS_FLAG = {
|
||||
GZIP_BOTH: 0x03,
|
||||
};
|
||||
|
||||
const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1";
|
||||
const debugLog = (...args: unknown[]) => {
|
||||
if (CURSOR_STREAM_DEBUG) console.log(...args);
|
||||
};
|
||||
|
||||
function decompressPayload(payload, flags) {
|
||||
// Check if payload is JSON error (starts with {"error")
|
||||
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith('{"error"')) {
|
||||
console.log(`[DECOMPRESS] Detected JSON error, skipping decompression`);
|
||||
debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`);
|
||||
return payload;
|
||||
}
|
||||
} catch {}
|
||||
@@ -89,14 +93,14 @@ function decompressPayload(payload, flags) {
|
||||
try {
|
||||
return zlib.inflateRawSync(payload);
|
||||
} catch (rawErr) {
|
||||
console.log(
|
||||
debugLog(
|
||||
`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}`
|
||||
);
|
||||
console.log(
|
||||
debugLog(
|
||||
`[DECOMPRESS ERROR] First 50 bytes (hex):`,
|
||||
payload.slice(0, 50).toString("hex")
|
||||
);
|
||||
console.log(
|
||||
debugLog(
|
||||
`[DECOMPRESS ERROR] First 50 bytes (utf8):`,
|
||||
payload
|
||||
.slice(0, 50)
|
||||
@@ -136,6 +140,41 @@ function createErrorResponse(jsonError) {
|
||||
);
|
||||
}
|
||||
|
||||
function parseCursorJsonErrorFrame(text: string) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isToolBoundaryAbort(jsonError: any, toolCallCount: number) {
|
||||
if (!jsonError || toolCallCount <= 0) return false;
|
||||
const code = jsonError?.error?.code || "";
|
||||
const debugError = jsonError?.error?.details?.[0]?.debug?.error || "";
|
||||
const title = jsonError?.error?.details?.[0]?.debug?.details?.title || "";
|
||||
const detail = jsonError?.error?.details?.[0]?.debug?.details?.detail || "";
|
||||
const message = `${title} ${detail}`.toLowerCase();
|
||||
const isAbortedCode = code === "aborted" || debugError === "ERROR_USER_ABORTED_REQUEST";
|
||||
return isAbortedCode && message.includes("tool call ended before result was received");
|
||||
}
|
||||
|
||||
function mergeToolCallDelta(existing, incoming) {
|
||||
const mergedName = incoming?.function?.name || existing?.function?.name || "";
|
||||
const existingArgs = existing?.function?.arguments || "";
|
||||
const deltaArgs = incoming?.function?.arguments || "";
|
||||
return {
|
||||
id: incoming.id || existing.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: mergedName,
|
||||
arguments: `${existingArgs}${deltaArgs}`,
|
||||
},
|
||||
isLast: Boolean(existing?.isLast || incoming?.isLast),
|
||||
index: existing?.index ?? incoming?.index ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
type CursorHttpResponse = {
|
||||
status: number;
|
||||
headers: Record<string, unknown>;
|
||||
@@ -230,10 +269,10 @@ export class CursorExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
// Call translator to convert OpenAI format to Cursor format
|
||||
const translatedBody = buildCursorRequest(model, body, stream, credentials);
|
||||
const messages = translatedBody.messages || [];
|
||||
const tools = translatedBody.tools || body.tools || [];
|
||||
// Messages are already translated by chatCore (claude→openai→cursor)
|
||||
// Do NOT call buildCursorRequest again — double-translation drops tool_results
|
||||
const messages = body.messages || [];
|
||||
const tools = body.tools || [];
|
||||
const reasoningEffort = body.reasoning_effort || null;
|
||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||
}
|
||||
@@ -379,13 +418,14 @@ export class CursorExecutor extends BaseExecutor {
|
||||
let totalContent = "";
|
||||
const toolCalls = [];
|
||||
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||
const finalizedIds = new Set<string>();
|
||||
let frameCount = 0;
|
||||
|
||||
console.log(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
|
||||
debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) {
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
|
||||
);
|
||||
break;
|
||||
@@ -394,12 +434,12 @@ export class CursorExecutor extends BaseExecutor {
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
|
||||
);
|
||||
|
||||
if (offset + 5 + length > buffer.length) {
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
|
||||
);
|
||||
break;
|
||||
@@ -411,21 +451,37 @@ export class CursorExecutor extends BaseExecutor {
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
if (!payload) {
|
||||
console.log(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
|
||||
debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
// Check for JSON error frames (byte guard: skip toString on non-JSON frames)
|
||||
if (payload.length > 0 && payload[0] === 0x7b) {
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.includes('"error"')) {
|
||||
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||
);
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||
debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||
|
||||
if (result.error) {
|
||||
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||
debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||
// If we already have content, treat error as stream termination
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
@@ -457,6 +513,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
// Push to final array when isLast is true
|
||||
if (tc.isLast) {
|
||||
const finalToolCall = toolCallsMap.get(tc.id);
|
||||
finalizedIds.add(tc.id);
|
||||
toolCalls.push({
|
||||
id: finalToolCall.id,
|
||||
type: finalToolCall.type,
|
||||
@@ -471,15 +528,15 @@ export class CursorExecutor extends BaseExecutor {
|
||||
if (result.text) totalContent += result.text;
|
||||
}
|
||||
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}`
|
||||
);
|
||||
|
||||
// Finalize all remaining tool calls in map (in case stream ended without isLast=true)
|
||||
for (const [id, tc] of toolCallsMap.entries()) {
|
||||
// Check if already in final array
|
||||
if (!toolCalls.find((t) => t.id === id)) {
|
||||
console.log(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
|
||||
if (!finalizedIds.has(id)) {
|
||||
debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
@@ -491,7 +548,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`);
|
||||
debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`);
|
||||
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
@@ -534,13 +591,15 @@ export class CursorExecutor extends BaseExecutor {
|
||||
let totalContent = "";
|
||||
const toolCalls = [];
|
||||
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||
const finalizedIds = new Set<string>();
|
||||
const emittedToolCallIds = new Set<string>();
|
||||
let frameCount = 0;
|
||||
|
||||
console.log(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
|
||||
debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) {
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
|
||||
);
|
||||
break;
|
||||
@@ -549,12 +608,12 @@ export class CursorExecutor extends BaseExecutor {
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
|
||||
);
|
||||
|
||||
if (offset + 5 + length > buffer.length) {
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
|
||||
);
|
||||
break;
|
||||
@@ -566,21 +625,37 @@ export class CursorExecutor extends BaseExecutor {
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
if (!payload) {
|
||||
console.log(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
|
||||
debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
// Check for JSON error frames (byte-guard: only decode if starts with '{')
|
||||
if (payload[0] === 0x7b) {
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.includes('"error"')) {
|
||||
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||
);
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||
debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||
|
||||
if (result.error) {
|
||||
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||
debugLog(`[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||
// If we already have content, treat error as stream termination
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
@@ -626,6 +701,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
|
||||
// Stream the delta arguments
|
||||
if (tc.function.arguments) {
|
||||
emittedToolCallIds.add(tc.id);
|
||||
chunks.push(
|
||||
`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
@@ -657,10 +733,12 @@ export class CursorExecutor extends BaseExecutor {
|
||||
} else {
|
||||
// New tool call - assign index and add to map
|
||||
const toolCallIndex = toolCalls.length;
|
||||
finalizedIds.add(tc.id);
|
||||
toolCalls.push({ ...tc, index: toolCallIndex });
|
||||
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
||||
|
||||
// Stream initial tool call with name
|
||||
emittedToolCallIds.add(tc.id);
|
||||
chunks.push(
|
||||
`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
@@ -714,10 +792,58 @@ export class CursorExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}`
|
||||
);
|
||||
|
||||
// Finalize all remaining tool calls in map (stream may have ended without isLast=true)
|
||||
for (const [id, tc] of toolCallsMap.entries()) {
|
||||
if (!finalizedIds.has(id)) {
|
||||
debugLog(`[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
|
||||
const toolCallIndex = toolCalls.length;
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
index: toolCallIndex,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
});
|
||||
|
||||
// Emit SSE chunk for the finalized tool call if not already emitted
|
||||
if (!emittedToolCallIds.has(tc.id)) {
|
||||
chunks.push(
|
||||
`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: toolCallIndex,
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length === 0 && toolCalls.length === 0) {
|
||||
chunks.push(
|
||||
`data: ${JSON.stringify({
|
||||
|
||||
@@ -1,19 +1,84 @@
|
||||
/**
|
||||
* OpenAI to Cursor Request Translator
|
||||
* Converts OpenAI messages to Cursor simple format
|
||||
* Converts OpenAI messages to Cursor ask/agent format.
|
||||
*
|
||||
* Important: Cursor can loop when tool outputs are sent via protobuf tool_results
|
||||
* with partial schema mismatches. For stability, tool outputs are represented as
|
||||
* structured text blocks in user messages.
|
||||
*/
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
|
||||
/**
|
||||
* Convert OpenAI messages to Cursor format with native tool_results support
|
||||
* - system → user with [System Instructions] prefix
|
||||
* - tool → accumulate into tool_results array for next user/assistant message
|
||||
* - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively)
|
||||
*/
|
||||
type TextPart = { type?: string; text?: string };
|
||||
type ToolUsePart = { type?: string; id?: string; name?: string; input?: unknown };
|
||||
type ToolResultPart = { type?: string; tool_use_id?: string; content?: unknown };
|
||||
|
||||
function normalizeToolCallId(id: unknown): string {
|
||||
return typeof id === "string" ? id.split("\n")[0] : "";
|
||||
}
|
||||
|
||||
function extractContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((part): part is TextPart => {
|
||||
if (!part || typeof part !== "object") return false;
|
||||
const maybe = part as TextPart;
|
||||
return maybe.type === "text" && typeof maybe.text === "string";
|
||||
})
|
||||
.map((part) => part.text as string)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function sanitizeToolResultText(text: string): string {
|
||||
// Strip non-printable control chars that can produce backend request errors.
|
||||
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string {
|
||||
const cleanResult = sanitizeToolResultText(resultText || "");
|
||||
return [
|
||||
"<tool_result>",
|
||||
`<tool_name>${escapeXml(toolName || "tool")}</tool_name>`,
|
||||
`<tool_call_id>${escapeXml(toolCallId || "")}</tool_call_id>`,
|
||||
`<result>${escapeXml(cleanResult)}</result>`,
|
||||
"</tool_result>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function convertMessages(messages) {
|
||||
const result = [];
|
||||
let pendingToolResults = [];
|
||||
// Build a map of tool_call_id -> tool name from assistant tool calls.
|
||||
const toolCallMetaMap = new Map();
|
||||
const rememberToolMeta = (toolCallId: string, toolName: string) => {
|
||||
if (!toolCallId) return;
|
||||
const name = toolName || "tool";
|
||||
toolCallMetaMap.set(toolCallId, { name });
|
||||
const normalized = normalizeToolCallId(toolCallId);
|
||||
if (normalized && normalized !== toolCallId) {
|
||||
toolCallMetaMap.set(normalized, { name });
|
||||
}
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
rememberToolMeta(tc.id || "", tc.function?.name || "tool");
|
||||
}
|
||||
}
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as ToolUsePart[]) {
|
||||
if (part?.type !== "tool_use") continue;
|
||||
rememberToolMeta(part.id || "", part.name || "tool");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
@@ -21,83 +86,89 @@ function convertMessages(messages) {
|
||||
if (msg.role === "system") {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `[System Instructions]\n${msg.content}`,
|
||||
content: `[System Instructions]\n${extractContent(msg.content)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
let toolContent = "";
|
||||
if (typeof msg.content === "string") {
|
||||
toolContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
toolContent += part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolName = msg.name || "tool";
|
||||
const toolContent = extractContent(msg.content);
|
||||
const toolCallId = msg.tool_call_id || "";
|
||||
|
||||
// Accumulate tool result
|
||||
pendingToolResults.push({
|
||||
tool_call_id: toolCallId,
|
||||
name: toolName,
|
||||
index: pendingToolResults.length,
|
||||
raw_args: toolContent,
|
||||
const toolMeta = toolCallMetaMap.get(toolCallId) || {};
|
||||
const toolName = msg.name || toolMeta.name || "tool";
|
||||
result.push({
|
||||
role: "user",
|
||||
content: buildToolResultBlock(toolName, toolCallId, toolContent),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user" || msg.role === "assistant") {
|
||||
let content = "";
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
content += part.text;
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
const parts: string[] = [];
|
||||
for (const block of msg.content as Array<TextPart | ToolResultPart>) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (block.type === "text") {
|
||||
if (typeof (block as TextPart).text === "string") {
|
||||
parts.push((block as TextPart).text || "");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
const tr = block as ToolResultPart;
|
||||
const toolCallId = tr.tool_use_id || "";
|
||||
const toolMeta =
|
||||
toolCallMetaMap.get(toolCallId) ||
|
||||
toolCallMetaMap.get(normalizeToolCallId(toolCallId));
|
||||
const toolName = toolMeta?.name || "tool";
|
||||
const toolContent = extractContent(tr.content);
|
||||
parts.push(buildToolResultBlock(toolName, toolCallId, toolContent));
|
||||
}
|
||||
}
|
||||
const joined = parts.filter(Boolean).join("\n");
|
||||
if (joined) result.push({ role: "user", content: joined });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep tool_calls structure for assistant messages
|
||||
const content = extractContent(msg.content);
|
||||
|
||||
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
const assistantMsg: {
|
||||
role: string;
|
||||
content?: string;
|
||||
tool_calls?: unknown;
|
||||
tool_results?: Array<Record<string, unknown>>;
|
||||
} = { role: "assistant" };
|
||||
if (content) {
|
||||
assistantMsg.content = content;
|
||||
}
|
||||
assistantMsg.tool_calls = msg.tool_calls;
|
||||
|
||||
// Attach pending tool results to assistant message with tool_calls
|
||||
if (pendingToolResults.length > 0) {
|
||||
assistantMsg.tool_results = pendingToolResults;
|
||||
pendingToolResults = [];
|
||||
}
|
||||
|
||||
} = { role: "assistant", content: content || "" };
|
||||
assistantMsg.tool_calls = msg.tool_calls.map((tc) => {
|
||||
const { index, ...rest } = tc || {};
|
||||
return rest;
|
||||
});
|
||||
result.push(assistantMsg);
|
||||
} else if (content || pendingToolResults.length > 0) {
|
||||
const msgObj: {
|
||||
role: string;
|
||||
content: string;
|
||||
tool_results?: Array<Record<string, unknown>>;
|
||||
} = { role: msg.role, content: content || "" };
|
||||
} else if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
const extractedToolCalls = (msg.content as ToolUsePart[])
|
||||
.filter((b) => b?.type === "tool_use")
|
||||
.map((b) => ({
|
||||
id: b.id || "",
|
||||
type: "function",
|
||||
function: {
|
||||
name: b.name || "tool",
|
||||
arguments: JSON.stringify(b.input || {}),
|
||||
},
|
||||
}))
|
||||
.filter((tc) => tc.id);
|
||||
|
||||
// Attach pending tool results to this message
|
||||
if (pendingToolResults.length > 0) {
|
||||
msgObj.tool_results = pendingToolResults;
|
||||
pendingToolResults = [];
|
||||
if (extractedToolCalls.length > 0) {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: content || "",
|
||||
tool_calls: extractedToolCalls,
|
||||
});
|
||||
} else if (content) {
|
||||
result.push({ role: "assistant", content });
|
||||
}
|
||||
} else {
|
||||
if (content) {
|
||||
result.push({ role: msg.role, content });
|
||||
}
|
||||
|
||||
result.push(msgObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import zlib from "zlib";
|
||||
|
||||
const DEBUG = true;
|
||||
const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1";
|
||||
const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Schema version — bump when updating field definitions.
|
||||
@@ -28,6 +29,7 @@ const ROLE = { USER: 1, ASSISTANT: 2 };
|
||||
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
|
||||
|
||||
const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 };
|
||||
const CLIENT_SIDE_TOOL_V2 = { MCP: 19 };
|
||||
|
||||
const FIELD = {
|
||||
// StreamUnifiedChatRequestWithTools (top level)
|
||||
@@ -74,6 +76,28 @@ const FIELD = {
|
||||
TOOL_RESULT_INDEX: 3,
|
||||
TOOL_RESULT_RAW_ARGS: 5,
|
||||
TOOL_RESULT_RESULT: 8,
|
||||
TOOL_RESULT_TOOL_CALL: 11,
|
||||
TOOL_RESULT_MODEL_CALL_ID: 12,
|
||||
|
||||
// ClientSideToolV2Result (nested inside ToolResult.result)
|
||||
CLIENT_RESULT_TOOL: 1,
|
||||
CLIENT_RESULT_MCP_RESULT: 28,
|
||||
CLIENT_RESULT_TOOL_CALL_ID: 35,
|
||||
CLIENT_RESULT_MODEL_CALL_ID: 48,
|
||||
CLIENT_RESULT_TOOL_INDEX: 49,
|
||||
|
||||
// MCPResult (nested inside ClientSideToolV2Result.mcp_result)
|
||||
MCP_RESULT_SELECTED_TOOL: 1,
|
||||
MCP_RESULT_RESULT: 2,
|
||||
|
||||
// ClientSideToolV2Call (nested inside ToolResult.tool_call)
|
||||
CLIENT_CALL_TOOL: 1,
|
||||
CLIENT_CALL_MCP_PARAMS: 27,
|
||||
CLIENT_CALL_TOOL_CALL_ID: 3,
|
||||
CLIENT_CALL_NAME: 9,
|
||||
CLIENT_CALL_RAW_ARGS: 10,
|
||||
CLIENT_CALL_TOOL_INDEX: 48,
|
||||
CLIENT_CALL_MODEL_CALL_ID: 49,
|
||||
|
||||
// Model
|
||||
MODEL_NAME: 1,
|
||||
@@ -120,6 +144,7 @@ const FIELD = {
|
||||
TOOL_NAME: 9,
|
||||
TOOL_RAW_ARGS: 10,
|
||||
TOOL_IS_LAST: 11,
|
||||
TOOL_IS_LAST_ALT: 15,
|
||||
TOOL_MCP_PARAMS: 27,
|
||||
|
||||
// MCPParams
|
||||
@@ -202,16 +227,149 @@ function concatArrays(...arrays) {
|
||||
// ==================== MESSAGE ENCODING ====================
|
||||
|
||||
export function encodeToolResult(toolResult) {
|
||||
const toolCallId = toolResult.tool_call_id || "";
|
||||
const toolName = toolResult.name || "";
|
||||
const toolIndex = toolResult.index || 0;
|
||||
const { toolCallId, modelCallId } = parseToolCallId(toolResult.tool_call_id || "");
|
||||
const rawToolName = toolResult.name || "";
|
||||
const toolName = formatCursorToolName(rawToolName);
|
||||
const { selectedTool, serverName } = parseCursorToolName(toolName);
|
||||
const toolIndex = toolResult.index > 0 ? toolResult.index : 1;
|
||||
const rawArgs = toolResult.raw_args || "{}";
|
||||
const resultContent = toolResult.result || "";
|
||||
const encodedResultMessage = encodeClientSideToolResult(
|
||||
toolCallId,
|
||||
modelCallId,
|
||||
selectedTool,
|
||||
toolIndex,
|
||||
resultContent
|
||||
);
|
||||
const encodedToolCallMessage = encodeClientSideToolCall(
|
||||
toolCallId,
|
||||
modelCallId,
|
||||
toolName,
|
||||
selectedTool,
|
||||
serverName,
|
||||
rawArgs,
|
||||
toolIndex
|
||||
);
|
||||
|
||||
return concatArrays(
|
||||
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
|
||||
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
|
||||
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
|
||||
...(modelCallId
|
||||
? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)]
|
||||
: []),
|
||||
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
||||
...(encodedResultMessage
|
||||
? [encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN, encodedResultMessage)]
|
||||
: []),
|
||||
encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN, encodedToolCallMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function parseToolCallId(toolCallIdRaw) {
|
||||
if (typeof toolCallIdRaw !== "string" || toolCallIdRaw.length === 0) {
|
||||
return { toolCallId: "", modelCallId: null };
|
||||
}
|
||||
const delimiter = "\nmc_";
|
||||
const idx = toolCallIdRaw.indexOf(delimiter);
|
||||
if (idx >= 0) {
|
||||
return {
|
||||
toolCallId: toolCallIdRaw.slice(0, idx),
|
||||
modelCallId: toolCallIdRaw.slice(idx + delimiter.length),
|
||||
};
|
||||
}
|
||||
return { toolCallId: toolCallIdRaw, modelCallId: null };
|
||||
}
|
||||
|
||||
function formatCursorToolName(rawName) {
|
||||
const base = typeof rawName === "string" && rawName.length > 0 ? rawName : "tool";
|
||||
|
||||
if (base.startsWith("mcp__")) {
|
||||
const rest = base.slice("mcp__".length);
|
||||
const splitIdx = rest.indexOf("__");
|
||||
if (splitIdx >= 0) {
|
||||
const server = rest.slice(0, splitIdx) || "custom";
|
||||
const name = rest.slice(splitIdx + 2) || "tool";
|
||||
return `mcp_${server}_${name}`;
|
||||
}
|
||||
return `mcp_custom_${rest || "tool"}`;
|
||||
}
|
||||
|
||||
if (base.startsWith("mcp_")) return base;
|
||||
return `mcp_custom_${base}`;
|
||||
}
|
||||
|
||||
function parseCursorToolName(formattedName) {
|
||||
if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) {
|
||||
return { serverName: "custom", selectedTool: formattedName || "tool" };
|
||||
}
|
||||
|
||||
const tail = formattedName.slice("mcp_".length);
|
||||
const splitIdx = tail.indexOf("_");
|
||||
if (splitIdx < 0) {
|
||||
return { serverName: "custom", selectedTool: tail || "tool" };
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: tail.slice(0, splitIdx) || "custom",
|
||||
selectedTool: tail.slice(splitIdx + 1) || "tool",
|
||||
};
|
||||
}
|
||||
|
||||
function encodeClientSideToolResult(toolCallId, modelCallId, toolName, toolIndex, resultContent) {
|
||||
const outputText = typeof resultContent === "string" ? resultContent : "";
|
||||
const selectedTool = typeof toolName === "string" && toolName.length > 0 ? toolName : "tool";
|
||||
|
||||
const mcpResult = concatArrays(
|
||||
encodeField(FIELD.MCP_RESULT_SELECTED_TOOL, WIRE_TYPE.LEN, selectedTool),
|
||||
encodeField(FIELD.MCP_RESULT_RESULT, WIRE_TYPE.LEN, outputText)
|
||||
);
|
||||
|
||||
return concatArrays(
|
||||
encodeField(FIELD.CLIENT_RESULT_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2.MCP),
|
||||
encodeField(FIELD.CLIENT_RESULT_MCP_RESULT, WIRE_TYPE.LEN, mcpResult),
|
||||
...(toolCallId
|
||||
? [encodeField(FIELD.CLIENT_RESULT_TOOL_CALL_ID, WIRE_TYPE.LEN, toolCallId)]
|
||||
: []),
|
||||
...(modelCallId
|
||||
? [encodeField(FIELD.CLIENT_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)]
|
||||
: []),
|
||||
encodeField(FIELD.CLIENT_RESULT_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex)
|
||||
);
|
||||
}
|
||||
|
||||
function encodeMcpParamsForCall(toolName, rawArgs, serverName) {
|
||||
const tool = concatArrays(
|
||||
encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName || "tool"),
|
||||
encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, rawArgs || "{}"),
|
||||
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, serverName || "custom")
|
||||
);
|
||||
return encodeField(FIELD.MCP_TOOLS_LIST, WIRE_TYPE.LEN, tool);
|
||||
}
|
||||
|
||||
function encodeClientSideToolCall(
|
||||
toolCallId,
|
||||
modelCallId,
|
||||
toolName,
|
||||
selectedTool,
|
||||
serverName,
|
||||
rawArgs,
|
||||
toolIndex
|
||||
) {
|
||||
return concatArrays(
|
||||
encodeField(FIELD.CLIENT_CALL_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2.MCP),
|
||||
encodeField(
|
||||
FIELD.CLIENT_CALL_MCP_PARAMS,
|
||||
WIRE_TYPE.LEN,
|
||||
encodeMcpParamsForCall(selectedTool, rawArgs, serverName)
|
||||
),
|
||||
...(toolCallId ? [encodeField(FIELD.CLIENT_CALL_TOOL_CALL_ID, WIRE_TYPE.LEN, toolCallId)] : []),
|
||||
encodeField(FIELD.CLIENT_CALL_NAME, WIRE_TYPE.LEN, toolName || "tool"),
|
||||
encodeField(FIELD.CLIENT_CALL_RAW_ARGS, WIRE_TYPE.LEN, rawArgs || "{}"),
|
||||
encodeField(FIELD.CLIENT_CALL_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1),
|
||||
...(modelCallId
|
||||
? [encodeField(FIELD.CLIENT_CALL_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)]
|
||||
: [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,13 +469,70 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort =
|
||||
const isAgentic = hasTools;
|
||||
const formattedMessages = [];
|
||||
const messageIds = [];
|
||||
const normalizedMessages = [];
|
||||
|
||||
// Prepare messages
|
||||
// Guardrail: split mixed assistant payload into separate assistant messages.
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0;
|
||||
const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0;
|
||||
|
||||
if (msg?.role === "assistant" && hasToolCalls && hasToolResults) {
|
||||
log(
|
||||
"ENCODE",
|
||||
`normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})`
|
||||
);
|
||||
|
||||
// Keep assistant tool call message without embedded results
|
||||
normalizedMessages.push({
|
||||
...msg,
|
||||
tool_results: [],
|
||||
});
|
||||
|
||||
// Avoid inserting duplicate assistant tool-result message if next one already matches
|
||||
const nextMsg = messages[i + 1];
|
||||
const nextHasToolResults =
|
||||
nextMsg?.role === "assistant" &&
|
||||
Array.isArray(nextMsg?.tool_results) &&
|
||||
nextMsg.tool_results.length > 0;
|
||||
const currentIds = new Set(
|
||||
msg.tool_results.map((tr) => tr?.tool_call_id).filter((id) => typeof id === "string")
|
||||
);
|
||||
const nextIds = new Set(
|
||||
(nextMsg?.tool_results || [])
|
||||
.map((tr) => tr?.tool_call_id)
|
||||
.filter((id) => typeof id === "string")
|
||||
);
|
||||
let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size;
|
||||
if (sameIds) {
|
||||
for (const id of currentIds) {
|
||||
if (!nextIds.has(id)) {
|
||||
sameIds = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(nextHasToolResults && sameIds)) {
|
||||
normalizedMessages.push({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_results: msg.tool_results,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedMessages.push(msg);
|
||||
}
|
||||
|
||||
// Prepare messages
|
||||
for (let i = 0; i < normalizedMessages.length; i++) {
|
||||
const msg = normalizedMessages[i];
|
||||
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
|
||||
const msgId = uuidv4();
|
||||
const isLast = i === messages.length - 1;
|
||||
const isLast = i === normalizedMessages.length - 1;
|
||||
|
||||
formattedMessages.push({
|
||||
content: msg.content,
|
||||
@@ -535,18 +750,19 @@ function extractToolCall(toolCallData) {
|
||||
|
||||
// Extract tool call ID
|
||||
if (toolCall.has(FIELD.TOOL_ID)) {
|
||||
const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value);
|
||||
toolCallId = fullId.split("\n")[0]; // Cursor returns multi-line ID, take first line
|
||||
toolCallId = textDecoder.decode(toolCall.get(FIELD.TOOL_ID)[0].value);
|
||||
}
|
||||
|
||||
// Extract tool name
|
||||
if (toolCall.has(FIELD.TOOL_NAME)) {
|
||||
toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value);
|
||||
toolName = textDecoder.decode(toolCall.get(FIELD.TOOL_NAME)[0].value);
|
||||
}
|
||||
|
||||
// Extract is_last flag
|
||||
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
|
||||
isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0;
|
||||
} else if (toolCall.has(FIELD.TOOL_IS_LAST_ALT)) {
|
||||
isLast = toolCall.get(FIELD.TOOL_IS_LAST_ALT)[0].value !== 0;
|
||||
}
|
||||
|
||||
// Extract MCP params - nested real tool info
|
||||
@@ -558,11 +774,11 @@ function extractToolCall(toolCallData) {
|
||||
const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value);
|
||||
|
||||
if (tool.has(FIELD.MCP_NESTED_NAME)) {
|
||||
toolName = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value);
|
||||
toolName = textDecoder.decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value);
|
||||
}
|
||||
|
||||
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
|
||||
rawArgs = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value);
|
||||
rawArgs = textDecoder.decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -572,7 +788,7 @@ function extractToolCall(toolCallData) {
|
||||
|
||||
// Fallback to raw_args
|
||||
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
|
||||
rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value);
|
||||
rawArgs = textDecoder.decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value);
|
||||
}
|
||||
|
||||
if (toolCallId && toolName) {
|
||||
@@ -597,7 +813,7 @@ function extractTextAndThinking(responseData) {
|
||||
|
||||
// Extract text
|
||||
if (nested.has(FIELD.RESPONSE_TEXT)) {
|
||||
text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)[0].value);
|
||||
text = textDecoder.decode(nested.get(FIELD.RESPONSE_TEXT)[0].value);
|
||||
}
|
||||
|
||||
// Extract thinking
|
||||
@@ -605,7 +821,7 @@ function extractTextAndThinking(responseData) {
|
||||
try {
|
||||
const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value);
|
||||
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
|
||||
thinking = new TextDecoder().decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value);
|
||||
thinking = textDecoder.decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value);
|
||||
}
|
||||
} catch (err) {
|
||||
log("EXTRACT", `Thinking parse error: ${err.message}`);
|
||||
|
||||
Reference in New Issue
Block a user