mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
chore(wip): snapshot working Cursor tool-result flow
Save the current functioning state that stops the protobuf wire-type crash and tool-call loop while we do a follow-up cleanup pass. Made-with: Cursor
This commit is contained in:
119
bug_index.md
Normal file
119
bug_index.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Bug: Wire Type 4 Protobuf Error on Cursor API
|
||||
|
||||
## Symptom
|
||||
|
||||
- First request to Cursor (3 msgs, 34 tools) → WORKS
|
||||
- Second request (5 msgs, 34 tools, ~140KB) → FAILS with "wire type 4" protobuf parse error
|
||||
- Error comes FROM Cursor's server rejecting our encoded protobuf request
|
||||
|
||||
## What "wire type 4" means
|
||||
|
||||
- Valid protobuf wire types: 0=VARINT, 1=FIXED64, 2=LEN, 5=FIXED32
|
||||
- Wire type 4 = "end group" (deprecated, never valid in modern protobuf)
|
||||
- This means **corrupted protobuf data** — Cursor server reads data bytes as tag bytes
|
||||
- Root cause: a LENGTH-DELIMITED field has wrong length prefix, causing parse misalignment
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Client (Claude format, 5 msgs)
|
||||
→ chatCore.translateRequest()
|
||||
→ claude-to-openai.ts (claudeToOpenAIRequest)
|
||||
→ openai-to-cursor.ts (buildCursorRequest)
|
||||
→ cursor.ts transformRequest()
|
||||
→ cursorProtobuf.ts generateCursorBody() → protobuf binary
|
||||
→ HTTP to Cursor API → "wire type 4" error
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `open-sse/translator/request/claude-to-openai.ts` — Claude→OpenAI message conversion
|
||||
- `open-sse/translator/request/openai-to-cursor.ts` — OpenAI→Cursor message conversion
|
||||
- `open-sse/utils/cursorProtobuf.ts` — Protobuf binary encoder
|
||||
- `open-sse/executors/cursor.ts` — Cursor executor (HTTP + protobuf framing)
|
||||
|
||||
## Investigation Done (Session 1)
|
||||
|
||||
1. **Content type is NOT the issue** — `encodeField()` handles undefined/null/array gracefully (→ empty `0a 00`)
|
||||
2. **openai-to-cursor always normalizes content to string** (lines 77-87)
|
||||
3. **tool_calls on assistant msgs are silently ignored** by protobuf encoder (no encoding logic for them)
|
||||
4. **Debug logging was added** to `encodeRequest()` and `encodeMessage()` to trace message shapes
|
||||
5. **All basic varint/field encoding tests pass** — no corruption in primitives
|
||||
|
||||
## Investigation Done (Session 2 — current)
|
||||
|
||||
6. **Round-trip test PASSES** — a synthetic 4-msg + 34-tools + tool_results request encodes and decodes back perfectly
|
||||
7. **Large payload test PASSES** — 100KB tool result content, frame length matches perfectly (106,810 bytes)
|
||||
8. **Varint encoding tests PASS** — values up to 268M encode/decode correctly
|
||||
9. **Null bytes in content work** — no corruption
|
||||
10. **Missing content on assistant msgs works** — produces valid empty field
|
||||
|
||||
## CRITICAL CONCLUSION
|
||||
|
||||
The encoding logic itself is **correct for all tested patterns**. The bug is NOT in the encoder primitives.
|
||||
The issue MUST be in one of:
|
||||
|
||||
### HIGH PRIORITY — What to investigate next
|
||||
|
||||
#### H1: The REAL production data has something we haven't tested
|
||||
|
||||
The user mentioned the log was already provided in a previous session. The 140,267 byte request
|
||||
with 34 tools and 5 messages has specific data that triggers the bug. We need to:
|
||||
|
||||
- **Capture the exact translated messages** before protobuf encoding (add JSON.stringify dump)
|
||||
- **Capture the hex dump** of the protobuf output
|
||||
- **Send the hex dump to a protobuf decoder** to find where parsing breaks
|
||||
|
||||
#### H2: assistant message with tool_calls AND content=undefined
|
||||
|
||||
In openai-to-cursor.ts line 96-97, when assistant has tool_calls but empty content:
|
||||
|
||||
```js
|
||||
const assistantMsg = { role: "assistant" }; // NO content property
|
||||
if (content) {
|
||||
assistantMsg.content = content;
|
||||
} // content="" is falsy → skipped
|
||||
```
|
||||
|
||||
Result: `msg.content` is `undefined`. In `encodeMessage`, this produces `encodeField(1, LEN, undefined)` → `0a 00`.
|
||||
This is technically valid protobuf (empty string field), BUT Cursor's server might require the content field to be present as a non-empty string for assistant messages. The FIRST request (3 msgs) might not have an assistant message at all, while the SECOND (5 msgs) does.
|
||||
|
||||
**FIX TO TRY**: Always set `content: ""` on assistant messages in openai-to-cursor.ts.
|
||||
|
||||
#### H3: tool_calls being passed to protobuf encoder as stray properties
|
||||
|
||||
The Cursor message in the translated body has `{ role, content, tool_calls, tool_results }`.
|
||||
The protobuf encoder only uses `content`, `role`, and `tool_results`. The `tool_calls` array
|
||||
is silently ignored. BUT if Cursor's real protocol expects tool calls to be encoded differently
|
||||
(maybe as part of the content or as a separate protobuf field), we might be sending incomplete data.
|
||||
|
||||
#### H4: Encoding `encodeVarint` with value from `>>>` unsigned shift on large numbers
|
||||
|
||||
`encodeVarint` uses `value >>>= 7` which treats value as unsigned 32-bit. If a tag or length
|
||||
exceeds 2^31, the unsigned shift behavior could produce wrong bytes. However, tests show this
|
||||
works correctly up to 268M, and our payloads are ~140KB, so this is unlikely.
|
||||
|
||||
## Recommended Fix (try first — LOW RISK)
|
||||
|
||||
In `openai-to-cursor.ts`, always ensure assistant messages have `content: ""`:
|
||||
|
||||
```js
|
||||
// Line 96-99, change from:
|
||||
const assistantMsg = { role: "assistant" };
|
||||
if (content) {
|
||||
assistantMsg.content = content;
|
||||
}
|
||||
|
||||
// To:
|
||||
const assistantMsg = { role: "assistant", content: content || "" };
|
||||
```
|
||||
|
||||
## How to debug further
|
||||
|
||||
Add this to `cursor.ts` `transformRequest()` BEFORE `generateCursorBody()`:
|
||||
|
||||
```js
|
||||
console.log("[CURSOR_DEBUG] Messages to encode:", JSON.stringify(messages, null, 2).slice(0, 2000));
|
||||
```
|
||||
|
||||
Then reproduce the error and check logs for the exact message shapes.
|
||||
@@ -230,10 +230,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);
|
||||
}
|
||||
@@ -415,9 +415,18 @@ export class CursorExecutor extends BaseExecutor {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error frames
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||
console.log(
|
||||
`[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||
);
|
||||
// If we already have content, treat error as stream termination (not fatal)
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
@@ -426,6 +435,12 @@ export class CursorExecutor extends BaseExecutor {
|
||||
console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||
|
||||
if (result.error) {
|
||||
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||
console.log(`[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: {
|
||||
@@ -570,9 +585,19 @@ export class CursorExecutor extends BaseExecutor {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error frames
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||
// Log the full error for debugging
|
||||
console.log(
|
||||
`[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||
);
|
||||
// If we already have content, treat error as stream termination (not fatal)
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
@@ -581,6 +606,14 @@ export class CursorExecutor extends BaseExecutor {
|
||||
console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||
|
||||
if (result.error) {
|
||||
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||
console.log(
|
||||
`[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: {
|
||||
@@ -718,6 +751,56 @@ export class CursorExecutor extends BaseExecutor {
|
||||
`[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 (!toolCalls.find((t) => t.id === id)) {
|
||||
console.log(
|
||||
`[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 (!chunks.some((c) => c.includes(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,80 @@
|
||||
/**
|
||||
* 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 buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string {
|
||||
const cleanResult = sanitizeToolResultText(resultText || "");
|
||||
return [
|
||||
"<tool_result>",
|
||||
`<tool_name>${toolName || "tool"}</tool_name>`,
|
||||
`<tool_call_id>${toolCallId || ""}</tool_call_id>`,
|
||||
`<result>${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 +82,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,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)
|
||||
@@ -65,6 +66,7 @@ const FIELD = {
|
||||
MSG_ID: 13,
|
||||
MSG_TOOL_RESULTS: 18,
|
||||
MSG_IS_AGENTIC: 29,
|
||||
MSG_SERVER_BUBBLE_ID: 32,
|
||||
MSG_UNIFIED_MODE: 47,
|
||||
MSG_SUPPORTED_TOOLS: 51,
|
||||
|
||||
@@ -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
|
||||
@@ -167,6 +192,13 @@ export function encodeField(fieldNum, wireType, value) {
|
||||
const tagBytes = encodeVarint(tag);
|
||||
|
||||
if (wireType === WIRE_TYPE.VARINT) {
|
||||
// Validate: VARINT value must be a number
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
log(
|
||||
"ENCODE",
|
||||
`⚠️ VARINT field=${fieldNum} invalid value: type=${typeof value}, value=${value}`
|
||||
);
|
||||
}
|
||||
const valueBytes = encodeVarint(value);
|
||||
return concatArrays(tagBytes, valueBytes);
|
||||
}
|
||||
@@ -202,16 +234,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)]
|
||||
: [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,6 +389,20 @@ export function encodeMessage(
|
||||
hasTools = false,
|
||||
toolResults = []
|
||||
) {
|
||||
// Debug: validate content type before encoding
|
||||
if (
|
||||
content !== null &&
|
||||
content !== undefined &&
|
||||
typeof content !== "string" &&
|
||||
!(content instanceof Uint8Array) &&
|
||||
!Buffer.isBuffer(content)
|
||||
) {
|
||||
log(
|
||||
"ENCODE",
|
||||
`⚠️ MSG_CONTENT unexpected type: ${typeof content}, isArray=${Array.isArray(content)}, value=${JSON.stringify(content).slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
|
||||
return concatArrays(
|
||||
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
|
||||
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
|
||||
@@ -311,13 +490,86 @@ 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")
|
||||
);
|
||||
const sameIds =
|
||||
currentIds.size > 0 &&
|
||||
currentIds.size === nextIds.size &&
|
||||
[...currentIds].every((id) => nextIds.has(id));
|
||||
|
||||
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;
|
||||
|
||||
// Debug: log message shape for diagnosis
|
||||
const toolResultsCount = Array.isArray(msg.tool_results) ? msg.tool_results.length : 0;
|
||||
const contentStr = typeof msg.content === "string" ? msg.content : "";
|
||||
const contentPreview = contentStr
|
||||
.slice(0, 120)
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/[^\x20-\x7E]/g, "?");
|
||||
log(
|
||||
"ENCODE",
|
||||
`msg[${i}] role=${msg.role} contentType=${typeof msg.content} contentLen=${contentStr.length} contentPreview="${contentPreview}" contentIsArray=${Array.isArray(msg.content)} hasToolCalls=${Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0} hasToolResults=${toolResultsCount > 0} toolResultsCount=${toolResultsCount}`
|
||||
);
|
||||
if (toolResultsCount > 0) {
|
||||
for (const tr of msg.tool_results) {
|
||||
log(
|
||||
"ENCODE",
|
||||
` toolResult: callId=${tr.tool_call_id} name=${tr.name} rawArgsType=${typeof tr.raw_args} rawArgsLen=${tr.raw_args?.length || 0} resultType=${typeof tr.result} resultLen=${tr.result?.length || 0}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
formattedMessages.push({
|
||||
content: msg.content,
|
||||
@@ -535,8 +787,7 @@ 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 = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value);
|
||||
}
|
||||
|
||||
// Extract tool name
|
||||
@@ -547,6 +798,8 @@ function extractToolCall(toolCallData) {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user