1401 lines
52 KiB
TypeScript
1401 lines
52 KiB
TypeScript
/**
|
|
* Hand-rolled protobuf encoder/decoder for Cursor's `agent.v1.AgentService/Run`
|
|
* RPC, the endpoint cursor-agent uses for everything (chat + composer + auto).
|
|
*
|
|
* Replaces the legacy aiserver.v1.ChatService/StreamUnifiedChatWithTools path,
|
|
* which doesn't accept "auto" or "composer-*" model ids.
|
|
*
|
|
* Schema sourced from:
|
|
* - On-the-wire captures of cursor-agent (decoded against the protobuf
|
|
* descriptor shipped in cursor-agent's bundle)
|
|
* - Cross-checked against router-for-me/CLIProxyAPI's reference Go impl
|
|
* and KooshaPari/cliproxyapi-plusplus's hand-rolled field tables
|
|
*
|
|
* The endpoint is a Connect-RPC client-streaming RPC. We send one frame
|
|
* (AgentClientMessage with a RunRequest) and end the stream; the server
|
|
* streams back an AgentServerMessage per chunk.
|
|
*/
|
|
|
|
import zlib from "node:zlib";
|
|
import crypto from "node:crypto";
|
|
import {
|
|
WT_VARINT,
|
|
WT_LEN,
|
|
encodeVarint,
|
|
encodeTag,
|
|
encodeBytes,
|
|
encodeString,
|
|
encodeMessage,
|
|
encodeUInt32Field,
|
|
encodeBoolField,
|
|
encodeDoubleField,
|
|
decodeVarint,
|
|
checkedLen,
|
|
decodeFields,
|
|
findField,
|
|
decodeStringField,
|
|
decodeVarintField,
|
|
type Field,
|
|
} from "./cursorAgentProtobuf/wire.ts";
|
|
|
|
// ─── Field numbers (from agent.proto descriptor) ───────────────────────────
|
|
|
|
const ACM_RUN_REQUEST = 1; // AgentClientMessage.run_request
|
|
|
|
const ARR_CONVERSATION_STATE = 1; // AgentRunRequest.conversation_state
|
|
const ARR_ACTION = 2; // AgentRunRequest.action
|
|
const ARR_MODEL_DETAILS = 3; // AgentRunRequest.model_details (ModelDetails, msg 88)
|
|
const ARR_CONVERSATION_ID = 5; // AgentRunRequest.conversation_id
|
|
const ARR_MCP_TOOLS = 4; // AgentRunRequest.mcp_tools (empty placeholder required)
|
|
const ARR_REQUESTED_MODEL = 9; // AgentRunRequest.requested_model
|
|
const ARR_UNKNOWN_12 = 12; // observed varint=0 in cursor-agent traffic
|
|
const ARR_REQUEST_ID = 16; // observed UUID, same value as conversation_id
|
|
|
|
const CSS_ROOT_PROMPT = 1; // ConversationStateStructure.root_prompt_messages_json
|
|
const CSS_TURNS = 8; // ConversationStateStructure.turns
|
|
|
|
const CA_USER_MESSAGE_ACTION = 1; // ConversationAction.user_message_action
|
|
|
|
const UMA_USER_MESSAGE = 1; // UserMessageAction.user_message
|
|
|
|
const UM_TEXT = 1; // UserMessage.text
|
|
const UM_MESSAGE_ID = 2; // UserMessage.message_id
|
|
const UM_SELECTED_CONTEXT = 3; // UserMessage.selected_context (empty placeholder required)
|
|
const UM_MODE = 4; // UserMessage.mode (cursor-agent sends 1)
|
|
|
|
// ─── Vision input (image) field numbers ────────────────────────────────────
|
|
// Pinned from cursor-agent's agent.v1 protobuf descriptor (bundle version
|
|
// 2026.06.02-8c11d9f, cross-checked against composer-api's older-endpoint
|
|
// encoder for shape). Images attach to the current UserMessage through its
|
|
// selected_context (field 3): UserMessage.selected_context is a SelectedContext
|
|
// whose `selected_images` (field 1) is a repeated SelectedImage. Each
|
|
// SelectedImage carries the raw bytes inline in its `data_or_blob_id` oneof
|
|
// (the `data` case, field 8) — cursor-agent's CLI instead sends a local file
|
|
// `path`, which a proxy cannot use, so we inline the bytes like composer-api.
|
|
const SC_SELECTED_IMAGES = 1; // SelectedContext.selected_images [repeated SelectedImage]
|
|
|
|
const SI_UUID = 2; // SelectedImage.uuid
|
|
const SI_DIMENSION = 4; // SelectedImage.dimension (SelectedImage.Dimension)
|
|
const SI_MIME_TYPE = 7; // SelectedImage.mime_type
|
|
const SI_DATA = 8; // SelectedImage.data (oneof data_or_blob_id) — inline image bytes
|
|
|
|
const DIM_WIDTH = 1; // SelectedImage.Dimension.width (int32)
|
|
const DIM_HEIGHT = 2; // SelectedImage.Dimension.height (int32)
|
|
|
|
const RM_MODEL_ID = 1; // RequestedModel.model_id
|
|
const RM_PARAMETERS = 3; // RequestedModel.parameters [repeated]
|
|
|
|
// ModelDetails (msg 88) — the model envelope cursor-agent actually uses to resolve
|
|
// pinned model variants. Field numbers pinned from the cursor-agent descriptor (and
|
|
// CLIProxyAPIPlus's cursor proto). #3714: pinned Claude/GPT *thinking* variants returned
|
|
// an empty turn when sent only via RequestedModel (field 9) with a bare model_id; the
|
|
// working reference sends them as ModelDetails with all three string fields set.
|
|
const MD_MODEL_ID = 1; // ModelDetails.model_id
|
|
const MD_DISPLAY_MODEL_ID = 3; // ModelDetails.display_model_id
|
|
const MD_DISPLAY_NAME = 4; // ModelDetails.display_name
|
|
|
|
const RMP_ID = 1; // RequestedModel.ModelParameter.id
|
|
const RMP_VALUE = 2; // RequestedModel.ModelParameter.value
|
|
|
|
const ACM_EXEC_CLIENT_MESSAGE = 2; // AgentClientMessage.exec_client_message
|
|
|
|
const ECM_ID = 1; // ExecClientMessage.id
|
|
const ECM_EXEC_ID = 15; // ExecClientMessage.exec_id
|
|
const ECM_REQUEST_CONTEXT_RESULT = 10; // ExecClientMessage.request_context_result
|
|
|
|
const RCR_SUCCESS = 1; // RequestContextResult.success
|
|
const RCS_REQUEST_CONTEXT = 1; // RequestContextSuccess.request_context
|
|
|
|
const ASM_INTERACTION_UPDATE = 1; // AgentServerMessage.interaction_update
|
|
const ASM_EXEC_SERVER_MESSAGE = 2; // AgentServerMessage.exec_server_message
|
|
const ASM_KV_SERVER_MESSAGE = 4; // AgentServerMessage.kv_server_message
|
|
// Cursor sends kv_server_message frames once the model stops generating
|
|
// (it saves the assistant turn into a blob). For non-tool-calling chats
|
|
// this functions as our end-of-response marker.
|
|
|
|
const ESM_ID = 1; // ExecServerMessage.id
|
|
const ESM_EXEC_ID = 15; // ExecServerMessage.exec_id
|
|
const ESM_REQUEST_CONTEXT_ARGS = 10; // ExecServerMessage.request_context_args
|
|
|
|
const IU_TEXT_DELTA = 1; // InteractionUpdate.text_delta
|
|
const IU_THINKING_DELTA = 4; // InteractionUpdate.thinking_delta
|
|
const IU_THINKING_COMPLETED = 5;
|
|
const IU_TOOL_CALL_STARTED = 2;
|
|
const IU_TOOL_CALL_COMPLETED = 3;
|
|
const IU_TOKEN_DELTA = 8;
|
|
const IU_HEARTBEAT = 13;
|
|
const IU_TURN_ENDED = 14;
|
|
|
|
const TDU_TEXT = 1; // TextDeltaUpdate.text
|
|
|
|
// ─── Phase 1+: tool-use field numbers ──────────────────────────────────────
|
|
// Field numbers in result-message oneof discriminators (RES_*) are best-known
|
|
// values; verified against wire-tap captures during integration testing.
|
|
|
|
const ACM_KV_CLIENT_MESSAGE = 3; // AgentClientMessage.kv_client_message
|
|
|
|
// CSS_ROOT_PROMPT and CSS_TURNS already declared above (lines 34-35)
|
|
// CSS_TURNS_OLD = 2 is deprecated; CSS_TURNS = 8 is current.
|
|
|
|
// ExecClientMessage payload variants (mirror ESM_*)
|
|
const ECM_SHELL_RESULT = 2;
|
|
const ECM_WRITE_RESULT = 3;
|
|
const ECM_DELETE_RESULT = 4;
|
|
const ECM_GREP_RESULT = 5;
|
|
const ECM_READ_RESULT = 7;
|
|
const ECM_LS_RESULT = 8;
|
|
const ECM_DIAGNOSTICS_RESULT = 9;
|
|
const ECM_MCP_RESULT = 11;
|
|
const ECM_BACKGROUND_SHELL_SPAWN_RES = 16;
|
|
const ECM_FETCH_RESULT = 20;
|
|
const ECM_WRITE_SHELL_STDIN_RESULT = 23;
|
|
|
|
// ExecServerMessage variant tags (used by exec router in Phase 2)
|
|
const ESM_SHELL_ARGS = 2;
|
|
const ESM_WRITE_ARGS = 3;
|
|
const ESM_DELETE_ARGS = 4;
|
|
const ESM_GREP_ARGS = 5;
|
|
const ESM_READ_ARGS = 7;
|
|
const ESM_LS_ARGS = 8;
|
|
const ESM_DIAGNOSTICS_ARGS = 9;
|
|
const ESM_MCP_ARGS = 11;
|
|
const ESM_SHELL_STREAM_ARGS = 14;
|
|
const ESM_BACKGROUND_SHELL_SPAWN = 16;
|
|
const ESM_FETCH_ARGS = 20;
|
|
const ESM_WRITE_SHELL_STDIN_ARGS = 23;
|
|
|
|
// Args sub-message field numbers (path and shell variants)
|
|
const ARG_PATH = 1; // ReadArgs.path / WriteArgs.path / DeleteArgs.path / LsArgs.path
|
|
const ARG_SHELL_COMMAND = 1; // ShellArgs.command
|
|
const ARG_SHELL_WORKING_DIR = 2; // ShellArgs.working_directory
|
|
const ARG_FETCH_URL = 1; // FetchArgs.url
|
|
|
|
// KvServerMessage / KvClientMessage
|
|
const KSM_ID = 1;
|
|
const KSM_GET_BLOB_ARGS = 2;
|
|
const KSM_SET_BLOB_ARGS = 3;
|
|
// Field 4 of KvServerMessage is an opaque request-correlation/metadata
|
|
// envelope — observed in real wire captures. The exact schema isn't
|
|
// public; we capture its raw bytes and echo them back in our reply
|
|
// so cursor can match request to response.
|
|
const KSM_REQUEST_METADATA = 4;
|
|
const KCM_ID = 1;
|
|
const KCM_GET_BLOB_RESULT = 2;
|
|
const KCM_SET_BLOB_RESULT = 3;
|
|
const KCM_REQUEST_METADATA = 4;
|
|
|
|
// GetBlobArgs / GetBlobResult / SetBlobArgs
|
|
const GBA_BLOB_ID = 1; // GetBlobArgs.blob_id (bytes)
|
|
const SBA_BLOB_ID = 1; // SetBlobArgs.blob_id (bytes)
|
|
const SBA_BLOB_DATA = 2; // SetBlobArgs.blob_data (bytes)
|
|
const GBR_BLOB_DATA = 1; // GetBlobResult.blob_data (bytes) — verified by wire test (cursor parses field 1 as JSON)
|
|
|
|
// Rejection sub-messages (path-based: read/write/delete/ls)
|
|
const REJ_PATH = 1;
|
|
const REJ_REASON = 2;
|
|
|
|
// ShellRejected (command + working_dir + reason)
|
|
const SREJ_COMMAND = 1;
|
|
const SREJ_WORKING_DIR = 2;
|
|
const SREJ_REASON = 3;
|
|
|
|
// Generic error sub-messages
|
|
const ERR_MESSAGE = 1; // GrepError.error / WriteShellStdinError.error
|
|
const FERR_URL = 1; // FetchError.url
|
|
const FERR_ERROR = 2; // FetchError.error
|
|
|
|
// Result-message variant discriminators (oneof). field 1 = success/accepted,
|
|
// field 2 = rejected/error. Matches existing RCR_SUCCESS=1 pattern.
|
|
const RES_REJECTED = 2; // rejected variant for read/write/delete/ls/shell/bg_shell
|
|
|
|
// McpToolDefinition
|
|
const MTD_NAME = 1;
|
|
const MTD_DESCRIPTION = 2;
|
|
const MTD_INPUT_SCHEMA = 3;
|
|
const MTD_PROVIDER_IDENTIFIER = 4;
|
|
const MTD_TOOL_NAME = 5;
|
|
|
|
// McpArgs (used by Phase 5 decoder)
|
|
const MCA_NAME = 1;
|
|
const MCA_ARGS = 2; // map<string, bytes>
|
|
const MCA_TOOL_CALL_ID = 3;
|
|
const MCA_PROVIDER_IDENTIFIER = 4;
|
|
const MCA_TOOL_NAME = 5;
|
|
|
|
// McpResult variants
|
|
const MCR_SUCCESS = 1;
|
|
const MCR_ERROR = 2;
|
|
const MCS_CONTENT = 1; // McpSuccess.content (repeated McpToolResultContentItem)
|
|
const MCS_IS_ERROR = 2;
|
|
const MCC_TEXT = 1; // McpToolResultContentItem.text (oneof) -> McpTextContent
|
|
const MTC_TEXT = 1; // McpTextContent.text
|
|
|
|
// google.protobuf.Value (well-known type)
|
|
const VAL_NULL = 1;
|
|
const VAL_NUMBER = 2;
|
|
const VAL_STRING = 3;
|
|
const VAL_BOOL = 4;
|
|
const VAL_STRUCT = 5;
|
|
const VAL_LIST = 6;
|
|
const STRUCT_FIELDS = 1; // Struct.fields = map<string, Value>
|
|
const LIST_VALUES = 1; // ListValue.values = repeated Value
|
|
|
|
// proto3 map<K,V> serializes as repeated FieldsEntry { key=1, value=2 }
|
|
const MAP_KEY = 1;
|
|
const MAP_VALUE = 2;
|
|
|
|
// ─── Connect-RPC framing ───────────────────────────────────────────────────
|
|
|
|
const FLAG_NONE = 0x00;
|
|
const FLAG_GZIP = 0x01;
|
|
|
|
export function wrapConnectFrame(payload: Buffer, compressed = false): Buffer {
|
|
const data = compressed ? zlib.gzipSync(payload) : payload;
|
|
const header = Buffer.alloc(5);
|
|
header[0] = compressed ? FLAG_GZIP : FLAG_NONE;
|
|
header.writeUInt32BE(data.length, 1);
|
|
return Buffer.concat([header, data]);
|
|
}
|
|
|
|
export type ConnectFrame = {
|
|
flags: number;
|
|
payload: Buffer;
|
|
};
|
|
|
|
export function* iterateConnectFrames(stream: Buffer): Generator<ConnectFrame> {
|
|
let pos = 0;
|
|
while (pos + 5 <= stream.length) {
|
|
const flags = stream[pos];
|
|
const length = stream.readUInt32BE(pos + 1);
|
|
if (pos + 5 + length > stream.length) return;
|
|
const raw = stream.subarray(pos + 5, pos + 5 + length);
|
|
const payload = flags & FLAG_GZIP ? zlib.gunzipSync(raw) : raw;
|
|
yield { flags, payload };
|
|
pos += 5 + length;
|
|
}
|
|
}
|
|
|
|
// ─── Model id translation ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Canonicalize common spelling variants of cursor's composer model ids to the
|
|
* exact ids cursor's server accepts. Without this, an off-by-a-character id
|
|
* (composer-2-5, composer-2.5-sdk, composer-latest, or an empty model) reaches
|
|
* cursor verbatim and is rejected. Only these known-equivalent spellings are
|
|
* remapped (case-insensitively); every other id — including the canonical
|
|
* composer-2.5/composer-2.5-fast and all claude, gpt, and gemini ids — passes
|
|
* through unchanged, so existing behavior is preserved exactly.
|
|
*/
|
|
const CURSOR_MODEL_ALIASES: Record<string, string> = {
|
|
"": "composer-2.5",
|
|
"composer-2-5": "composer-2.5",
|
|
"composer-2.5-sdk": "composer-2.5",
|
|
"composer-latest": "composer-2.5",
|
|
"composer-2-5-fast": "composer-2.5-fast",
|
|
"composer-2.5-sdk-fast": "composer-2.5-fast",
|
|
"composer-latest-fast": "composer-2.5-fast",
|
|
};
|
|
|
|
export function normalizeCursorModelId(modelId: string): string {
|
|
const id = (modelId ?? "").trim();
|
|
const alias = CURSOR_MODEL_ALIASES[id.toLowerCase()];
|
|
return alias ?? id;
|
|
}
|
|
|
|
/**
|
|
* cursor-agent rewrites model ids before putting them on the wire:
|
|
* "auto" → RequestedModel { model_id: "default" }
|
|
* "composer-2-fast" → RequestedModel { model_id: "composer-2",
|
|
* parameters: [{id: "fast", value: "true"}] }
|
|
*
|
|
* Other ids (e.g. "claude-4.6-sonnet-medium") are passed through verbatim
|
|
* after spelling-variant normalization (see normalizeCursorModelId).
|
|
*/
|
|
export function resolveRequestedModel(modelId: string): {
|
|
modelId: string;
|
|
parameters: Array<{ id: string; value: string }>;
|
|
} {
|
|
const normalized = normalizeCursorModelId(modelId);
|
|
if (normalized === "auto") {
|
|
return { modelId: "default", parameters: [] };
|
|
}
|
|
// Strip the "-fast" suffix and surface it as a parameter — only the composer
|
|
// family observably needs this split today, but the protocol field is generic.
|
|
if (normalized.startsWith("composer-") && normalized.endsWith("-fast")) {
|
|
return {
|
|
modelId: normalized.slice(0, -"-fast".length),
|
|
parameters: [{ id: "fast", value: "true" }],
|
|
};
|
|
}
|
|
return { modelId: normalized, parameters: [] };
|
|
}
|
|
|
|
// ─── Request encoder ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* OpenAI tool shape (subset OmniRoute receives from clients). Cursor's
|
|
* AgentRunRequest carries declared tools as McpToolDefinition entries; the
|
|
* model uses these to know what's invocable, then emits ExecServerMessage
|
|
* mcp_args when it wants to call one (Phase 5 surfaces those as OpenAI
|
|
* tool_calls deltas).
|
|
*/
|
|
export type OpenAITool = {
|
|
type?: string;
|
|
function: {
|
|
name: string;
|
|
description?: string;
|
|
parameters?: unknown;
|
|
};
|
|
};
|
|
|
|
export type AgentRunInput = {
|
|
modelId: string;
|
|
userText: string;
|
|
conversationId?: string;
|
|
messageId?: string;
|
|
tools?: OpenAITool[];
|
|
// Phase 7: when systemPrompt is set, the encoder hashes
|
|
// {role:"system", content:<prompt>} into a blob, stores it in blobStore
|
|
// (keyed by hex sha256), and embeds the blob id in the
|
|
// ConversationStateStructure.root_prompt_messages_json field. Cursor's
|
|
// server then sends a KvServerMessage.GetBlobArgs requesting the blob,
|
|
// which the executor's processFrame replies to with the stored bytes.
|
|
systemPrompt?: string;
|
|
blobStore?: Map<string, Buffer>;
|
|
// Vision input: images attached to the current user turn. Encoded inline as
|
|
// SelectedContext.selected_images[] (see encodeSelectedImageBody). Empty /
|
|
// undefined keeps the request byte-identical to the text-only path.
|
|
images?: EncodedImage[];
|
|
};
|
|
|
|
/**
|
|
* A resolved image ready to embed in a cursor request. `data` is the raw
|
|
* decoded image bytes (already SSRF-checked / size-capped by the executor's
|
|
* resolveCursorImages helper). `mimeType` (e.g. "image/png") helps cursor
|
|
* decode the inline bytes; `width`/`height` populate the optional Dimension
|
|
* sub-message when cheaply known; `uuid` is a stable per-image id.
|
|
*/
|
|
export type EncodedImage = {
|
|
data: Buffer;
|
|
mimeType?: string;
|
|
width?: number;
|
|
height?: number;
|
|
uuid: string;
|
|
};
|
|
|
|
/**
|
|
* Encode the body of a SelectedImage message (no outer field tag — the caller
|
|
* wraps it via encodeMessage(SC_SELECTED_IMAGES, [body])). Sets the inline
|
|
* `data` oneof case plus uuid, optional dimension, and mime_type. Fields are
|
|
* written in ascending field-number order (canonical protobuf layout).
|
|
*/
|
|
export function encodeSelectedImageBody(img: EncodedImage): Buffer {
|
|
const parts: Buffer[] = [encodeString(SI_UUID, img.uuid)];
|
|
if (
|
|
typeof img.width === "number" &&
|
|
typeof img.height === "number" &&
|
|
Number.isFinite(img.width) &&
|
|
Number.isFinite(img.height) &&
|
|
img.width > 0 &&
|
|
img.height > 0
|
|
) {
|
|
parts.push(
|
|
encodeMessage(SI_DIMENSION, [
|
|
encodeUInt32Field(DIM_WIDTH, Math.floor(img.width)),
|
|
encodeUInt32Field(DIM_HEIGHT, Math.floor(img.height)),
|
|
])
|
|
);
|
|
}
|
|
if (img.mimeType) {
|
|
parts.push(encodeString(SI_MIME_TYPE, img.mimeType));
|
|
}
|
|
// data_or_blob_id oneof = data (inline bytes) — field 8, written last to
|
|
// keep ascending field order.
|
|
parts.push(encodeBytes(SI_DATA, img.data));
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
/**
|
|
* Convert OpenAI tool definitions to cursor McpToolDefinition bodies. Used
|
|
* both by the AgentRunRequest builder (mcp_tools field) and by the request
|
|
* context ack (request_context.tools field) — the model needs both to see
|
|
* the tools as available.
|
|
*/
|
|
export function openAIToolsToMcpDefs(tools: OpenAITool[]): McpToolDefinition[] {
|
|
return tools.map((t) => {
|
|
const params = t.function?.parameters ?? { type: "object", properties: {} };
|
|
return {
|
|
name: t.function.name,
|
|
description: t.function.description ?? "",
|
|
inputSchemaBytes: jsonSchemaToProtobufValue(params),
|
|
providerIdentifier: "omniroute",
|
|
toolName: t.function.name,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function encodeAgentRunRequest(input: AgentRunInput): Buffer {
|
|
const conversationId = input.conversationId || crypto.randomUUID();
|
|
const messageId = input.messageId || crypto.randomUUID();
|
|
const { modelId, parameters } = resolveRequestedModel(input.modelId);
|
|
|
|
// UserMessage { text, message_id, selected_context, mode=1 }.
|
|
// selected_context is normally an empty placeholder (required by the server
|
|
// even when empty — see below), but when the turn carries vision input we
|
|
// populate its selected_images[] with the inline-encoded images. The
|
|
// empty-images path produces byte-identical output to the text-only request.
|
|
const selectedContextParts: Buffer[] = [];
|
|
if (input.images && input.images.length > 0) {
|
|
for (const img of input.images) {
|
|
selectedContextParts.push(encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)]));
|
|
}
|
|
}
|
|
// The empty selected_context placeholder and mode=1 match cursor-agent's
|
|
// wire format; without them the server accepts the request but never
|
|
// streams a response.
|
|
const userMessage = encodeMessage(UMA_USER_MESSAGE, [
|
|
encodeString(UM_TEXT, input.userText),
|
|
encodeString(UM_MESSAGE_ID, messageId),
|
|
encodeMessage(UM_SELECTED_CONTEXT, selectedContextParts),
|
|
Buffer.concat([encodeTag(UM_MODE, WT_VARINT), encodeVarint(1)]),
|
|
]);
|
|
// UserMessageAction { user_message }
|
|
const userMessageAction = encodeMessage(CA_USER_MESSAGE_ACTION, [userMessage]);
|
|
// ConversationAction { user_message_action }
|
|
const action = encodeMessage(ARR_ACTION, [userMessageAction]);
|
|
|
|
// ConversationStateStructure. When a system prompt is present, hash it to
|
|
// a sha256 blob id and reference the blob from root_prompt_messages_json;
|
|
// the server requests the blob over the KV channel during the turn.
|
|
const cssParts: Buffer[] = [];
|
|
if (input.systemPrompt && input.blobStore) {
|
|
const systemJson = JSON.stringify({ role: "system", content: input.systemPrompt });
|
|
const blobBytes = Buffer.from(systemJson, "utf8");
|
|
const blobId = crypto.createHash("sha256").update(blobBytes).digest();
|
|
input.blobStore.set(blobId.toString("hex"), blobBytes);
|
|
cssParts.push(encodeBytes(CSS_ROOT_PROMPT, blobId));
|
|
}
|
|
const conversationState = encodeMessage(ARR_CONVERSATION_STATE, cssParts);
|
|
|
|
// RequestedModel { model_id, [parameters...] }
|
|
const rmParts: Buffer[] = [encodeString(RM_MODEL_ID, modelId)];
|
|
for (const param of parameters) {
|
|
rmParts.push(
|
|
encodeMessage(RM_PARAMETERS, [
|
|
encodeString(RMP_ID, param.id),
|
|
encodeString(RMP_VALUE, param.value),
|
|
])
|
|
);
|
|
}
|
|
const requestedModel = encodeMessage(ARR_REQUESTED_MODEL, rmParts);
|
|
|
|
// ModelDetails { model_id, display_model_id, display_name } — all set to the resolved
|
|
// model id. #3714: RequestedModel (field 9) alone resolves server-routed ids
|
|
// (auto → default, composer-*) but pinned Claude/GPT *thinking* variants returned an
|
|
// empty turn without this envelope. cursor-agent's working wire format sends both, so
|
|
// we keep RequestedModel (preserves the -fast `parameters` it carries) and add this.
|
|
const modelDetails = encodeMessage(ARR_MODEL_DETAILS, [
|
|
encodeString(MD_MODEL_ID, modelId),
|
|
encodeString(MD_DISPLAY_MODEL_ID, modelId),
|
|
encodeString(MD_DISPLAY_NAME, modelId),
|
|
]);
|
|
|
|
// mcp_tools: McpTools envelope at field 4 of AgentRunRequest. Each tool
|
|
// is packed inside the envelope at field 1 (repeated McpToolDefinition).
|
|
// Empty placeholder for non-tool calls (the field is observably required
|
|
// even when empty — cursor errors if it's omitted entirely).
|
|
const mcpToolDefs = input.tools ? openAIToolsToMcpDefs(input.tools) : [];
|
|
const mcpToolsBlock = encodeMessage(
|
|
ARR_MCP_TOOLS,
|
|
mcpToolDefs.map((def) => encodeMessage(ARR_MCP_TOOLS_INNER, [encodeMcpToolDefinitionBody(def)]))
|
|
);
|
|
|
|
// AgentRunRequest. Field order mirrors cursor-agent's wire format; empty
|
|
// placeholders for mcp_tools and request_id are observably required.
|
|
const agentRunRequest = [
|
|
conversationState,
|
|
action,
|
|
modelDetails,
|
|
mcpToolsBlock,
|
|
encodeString(ARR_CONVERSATION_ID, conversationId),
|
|
requestedModel,
|
|
Buffer.concat([encodeTag(ARR_UNKNOWN_12, WT_VARINT), encodeVarint(0)]),
|
|
encodeString(ARR_REQUEST_ID, conversationId),
|
|
];
|
|
|
|
// AgentClientMessage { run_request }
|
|
const acm = encodeMessage(ACM_RUN_REQUEST, agentRunRequest);
|
|
return acm;
|
|
}
|
|
|
|
// McpTools.tool field number — repeated McpToolDefinition entries go under
|
|
// field 1 of the McpTools wrapper (which itself is field 4 of AgentRunRequest).
|
|
const ARR_MCP_TOOLS_INNER = 1;
|
|
|
|
export function buildAgentRequestBody(input: AgentRunInput): Buffer {
|
|
return wrapConnectFrame(encodeAgentRunRequest(input));
|
|
}
|
|
|
|
// ─── Response decoder ──────────────────────────────────────────────────────
|
|
|
|
export type DecodedDelta =
|
|
| { kind: "text"; text: string }
|
|
| { kind: "thinking"; text: string }
|
|
| { kind: "thinking_complete" }
|
|
| { kind: "token_delta"; tokens: number }
|
|
| { kind: "turn_ended" }
|
|
| { kind: "heartbeat" }
|
|
| { kind: "tool_call_started" }
|
|
| { kind: "tool_call_completed" }
|
|
| { kind: "kv_server_message" }
|
|
| { kind: "unknown"; field: number };
|
|
|
|
export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] {
|
|
const out: DecodedDelta[] = [];
|
|
for (const top of decodeFields(payload)) {
|
|
if (top.fieldNumber === ASM_KV_SERVER_MESSAGE && top.wireType === 2) {
|
|
out.push({ kind: "kv_server_message" });
|
|
continue;
|
|
}
|
|
if (top.fieldNumber !== ASM_INTERACTION_UPDATE || top.wireType !== 2) continue;
|
|
for (const update of decodeFields(top.bytes)) {
|
|
if (update.wireType !== 2 && update.wireType !== 0) continue;
|
|
switch (update.fieldNumber) {
|
|
case IU_TEXT_DELTA:
|
|
if (update.wireType === 2) {
|
|
out.push({ kind: "text", text: decodeStringField(update.bytes, TDU_TEXT) });
|
|
}
|
|
break;
|
|
case IU_THINKING_DELTA:
|
|
if (update.wireType === 2) {
|
|
out.push({ kind: "thinking", text: decodeStringField(update.bytes, TDU_TEXT) });
|
|
}
|
|
break;
|
|
case IU_THINKING_COMPLETED:
|
|
out.push({ kind: "thinking_complete" });
|
|
break;
|
|
case IU_TOOL_CALL_STARTED:
|
|
out.push({ kind: "tool_call_started" });
|
|
break;
|
|
case IU_TOOL_CALL_COMPLETED:
|
|
out.push({ kind: "tool_call_completed" });
|
|
break;
|
|
case IU_TOKEN_DELTA:
|
|
if (update.wireType === 2) {
|
|
out.push({ kind: "token_delta", tokens: decodeVarintField(update.bytes, 1) });
|
|
}
|
|
break;
|
|
case IU_HEARTBEAT:
|
|
out.push({ kind: "heartbeat" });
|
|
break;
|
|
case IU_TURN_ENDED:
|
|
out.push({ kind: "turn_ended" });
|
|
break;
|
|
default:
|
|
out.push({ kind: "unknown", field: update.fieldNumber });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ─── Exec channel handshake ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse an AgentServerMessage looking for an ExecServerMessage requesting
|
|
* context (sent right after the init RunRequest). The server stalls until we
|
|
* respond on the same h2 stream with an ExecClientMessage.RequestContextResult.
|
|
*
|
|
* Kept for backward compat — internally delegates to decodeExecServerEvent.
|
|
*/
|
|
export function decodeExecRequestContext(payload: Buffer): { id: number; execId: string } | null {
|
|
const event = decodeExecServerEvent(payload);
|
|
if (event && event.kind === "exec_request_context") {
|
|
return { id: event.execMsgId, execId: event.execId };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Phase 7: KvServerMessage decoder ──────────────────────────────────────
|
|
//
|
|
// Cursor multiplexes a key-value channel through the same h2 stream. After
|
|
// the init RunRequest with a CSS root_prompt_messages_json blob, the server
|
|
// sends KvServerMessage.GetBlobArgs requesting the blob bytes; we look up
|
|
// the bytes in our request-scoped blobStore and reply on the same stream.
|
|
//
|
|
// SetBlobArgs is sent at end-of-turn (server saving the assistant message);
|
|
// we ack with an empty SetBlobResult.
|
|
|
|
export type KvServerEvent =
|
|
| {
|
|
kind: "kv_get_blob";
|
|
kvId: number;
|
|
blobId: Buffer;
|
|
// Opaque metadata cursor sends with the request; echoed back in the
|
|
// reply so cursor can match request/response correctly. Empty when
|
|
// the request didn't include the metadata field.
|
|
requestMetadata: Buffer | null;
|
|
}
|
|
| {
|
|
kind: "kv_set_blob";
|
|
kvId: number;
|
|
blobId: Buffer;
|
|
blobData: Buffer;
|
|
requestMetadata: Buffer | null;
|
|
};
|
|
|
|
export function decodeKvServerEvent(payload: Buffer): KvServerEvent | null {
|
|
for (const top of decodeFields(payload)) {
|
|
if (top.fieldNumber !== ASM_KV_SERVER_MESSAGE || top.wireType !== 2) continue;
|
|
|
|
let kvId = 0;
|
|
let getBlobArgs: Buffer | null = null;
|
|
let setBlobArgs: Buffer | null = null;
|
|
let requestMetadata: Buffer | null = null;
|
|
|
|
for (const f of decodeFields(top.bytes)) {
|
|
if (f.fieldNumber === KSM_ID && f.wireType === 0) {
|
|
kvId = Number(f.varint);
|
|
} else if (f.fieldNumber === KSM_GET_BLOB_ARGS && f.wireType === 2) {
|
|
getBlobArgs = f.bytes;
|
|
} else if (f.fieldNumber === KSM_SET_BLOB_ARGS && f.wireType === 2) {
|
|
setBlobArgs = f.bytes;
|
|
} else if (f.fieldNumber === KSM_REQUEST_METADATA && f.wireType === 2) {
|
|
requestMetadata = f.bytes;
|
|
}
|
|
}
|
|
|
|
if (getBlobArgs) {
|
|
// GetBlobArgs { blob_id (1): bytes }
|
|
let blobId = Buffer.alloc(0);
|
|
for (const f of decodeFields(getBlobArgs)) {
|
|
if (f.fieldNumber === GBA_BLOB_ID && f.wireType === 2) {
|
|
blobId = f.bytes;
|
|
}
|
|
}
|
|
return { kind: "kv_get_blob", kvId, blobId, requestMetadata };
|
|
}
|
|
if (setBlobArgs) {
|
|
// SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes }
|
|
let blobId = Buffer.alloc(0);
|
|
let blobData = Buffer.alloc(0);
|
|
for (const f of decodeFields(setBlobArgs)) {
|
|
if (f.fieldNumber === SBA_BLOB_ID && f.wireType === 2) {
|
|
blobId = f.bytes;
|
|
} else if (f.fieldNumber === SBA_BLOB_DATA && f.wireType === 2) {
|
|
blobData = f.bytes;
|
|
}
|
|
}
|
|
return { kind: "kv_set_blob", kvId, blobId, blobData, requestMetadata };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Phase 2: full ExecServerMessage variant decoder ───────────────────────
|
|
//
|
|
// Cursor's server multiplexes a tool channel through the h2 stream. After
|
|
// the init RunRequest, the server may emit any of:
|
|
// - request_context_args (always first — context handshake)
|
|
// - read/write/delete/ls/grep/diagnostics/shell/etc args (built-in tools)
|
|
// - mcp_args (MCP tool the model wants to invoke — declared via Phase 3)
|
|
// All variants share the same ExecServerMessage envelope { id, exec_id, ... };
|
|
// only the discriminator field number differs.
|
|
|
|
export type ExecServerEvent =
|
|
| { kind: "exec_request_context"; execMsgId: number; execId: string }
|
|
| { kind: "exec_read"; execMsgId: number; execId: string; path: string }
|
|
| { kind: "exec_write"; execMsgId: number; execId: string; path: string }
|
|
| { kind: "exec_delete"; execMsgId: number; execId: string; path: string }
|
|
| { kind: "exec_ls"; execMsgId: number; execId: string; path: string }
|
|
| { kind: "exec_grep"; execMsgId: number; execId: string }
|
|
| { kind: "exec_diagnostics"; execMsgId: number; execId: string }
|
|
| {
|
|
kind: "exec_shell";
|
|
execMsgId: number;
|
|
execId: string;
|
|
command: string;
|
|
workingDir: string;
|
|
}
|
|
| {
|
|
kind: "exec_shell_stream";
|
|
execMsgId: number;
|
|
execId: string;
|
|
command: string;
|
|
workingDir: string;
|
|
}
|
|
| {
|
|
kind: "exec_bg_shell";
|
|
execMsgId: number;
|
|
execId: string;
|
|
command: string;
|
|
workingDir: string;
|
|
}
|
|
| { kind: "exec_fetch"; execMsgId: number; execId: string; url: string }
|
|
| { kind: "exec_write_shell_stdin"; execMsgId: number; execId: string }
|
|
| {
|
|
kind: "exec_mcp";
|
|
execMsgId: number;
|
|
execId: string;
|
|
toolName: string;
|
|
toolCallId: string;
|
|
// args populated by Phase 5 (decodeMcpArgs); empty {} until then.
|
|
args: Record<string, unknown>;
|
|
};
|
|
|
|
export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null {
|
|
for (const top of decodeFields(payload)) {
|
|
if (top.fieldNumber !== ASM_EXEC_SERVER_MESSAGE || top.wireType !== 2) continue;
|
|
|
|
let execMsgId = 0;
|
|
let execId = "";
|
|
let variantField = 0;
|
|
let variantBytes: Buffer | null = null;
|
|
|
|
for (const f of decodeFields(top.bytes)) {
|
|
if (f.fieldNumber === ESM_ID && f.wireType === 0) {
|
|
execMsgId = Number(f.varint);
|
|
} else if (f.fieldNumber === ESM_EXEC_ID && f.wireType === 2) {
|
|
execId = f.bytes.toString("utf8");
|
|
} else if (f.wireType === 2) {
|
|
// Any other LEN field is the variant payload. Take the first one we
|
|
// see — variants don't co-occur in a well-formed message.
|
|
if (variantField === 0) {
|
|
variantField = f.fieldNumber;
|
|
variantBytes = f.bytes;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (variantBytes === null) continue;
|
|
|
|
switch (variantField) {
|
|
case ESM_REQUEST_CONTEXT_ARGS:
|
|
return { kind: "exec_request_context", execMsgId, execId };
|
|
case ESM_READ_ARGS:
|
|
return {
|
|
kind: "exec_read",
|
|
execMsgId,
|
|
execId,
|
|
path: decodeStringField(variantBytes, ARG_PATH),
|
|
};
|
|
case ESM_WRITE_ARGS:
|
|
return {
|
|
kind: "exec_write",
|
|
execMsgId,
|
|
execId,
|
|
path: decodeStringField(variantBytes, ARG_PATH),
|
|
};
|
|
case ESM_DELETE_ARGS:
|
|
return {
|
|
kind: "exec_delete",
|
|
execMsgId,
|
|
execId,
|
|
path: decodeStringField(variantBytes, ARG_PATH),
|
|
};
|
|
case ESM_LS_ARGS:
|
|
return {
|
|
kind: "exec_ls",
|
|
execMsgId,
|
|
execId,
|
|
path: decodeStringField(variantBytes, ARG_PATH),
|
|
};
|
|
case ESM_GREP_ARGS:
|
|
return { kind: "exec_grep", execMsgId, execId };
|
|
case ESM_DIAGNOSTICS_ARGS:
|
|
return { kind: "exec_diagnostics", execMsgId, execId };
|
|
case ESM_SHELL_ARGS:
|
|
return {
|
|
kind: "exec_shell",
|
|
execMsgId,
|
|
execId,
|
|
command: decodeStringField(variantBytes, ARG_SHELL_COMMAND),
|
|
workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR),
|
|
};
|
|
case ESM_SHELL_STREAM_ARGS:
|
|
return {
|
|
kind: "exec_shell_stream",
|
|
execMsgId,
|
|
execId,
|
|
command: decodeStringField(variantBytes, ARG_SHELL_COMMAND),
|
|
workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR),
|
|
};
|
|
case ESM_BACKGROUND_SHELL_SPAWN:
|
|
return {
|
|
kind: "exec_bg_shell",
|
|
execMsgId,
|
|
execId,
|
|
command: decodeStringField(variantBytes, ARG_SHELL_COMMAND),
|
|
workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR),
|
|
};
|
|
case ESM_FETCH_ARGS:
|
|
return {
|
|
kind: "exec_fetch",
|
|
execMsgId,
|
|
execId,
|
|
url: decodeStringField(variantBytes, ARG_FETCH_URL),
|
|
};
|
|
case ESM_WRITE_SHELL_STDIN_ARGS:
|
|
return { kind: "exec_write_shell_stdin", execMsgId, execId };
|
|
case ESM_MCP_ARGS: {
|
|
// McpArgs.args is map<string, bytes>; each value is a protobuf-
|
|
// encoded google.protobuf.Value. Decode keys and value-bytes here,
|
|
// then convert each Value to its JSON shape.
|
|
let toolName = "";
|
|
let toolCallId = "";
|
|
const args: Record<string, unknown> = {};
|
|
for (const f of decodeFields(variantBytes)) {
|
|
if (f.wireType !== 2) continue;
|
|
if (f.fieldNumber === MCA_TOOL_NAME) {
|
|
toolName = f.bytes.toString("utf8");
|
|
} else if (f.fieldNumber === MCA_NAME && !toolName) {
|
|
// tool_name (5) takes precedence; fall back to name (1)
|
|
toolName = f.bytes.toString("utf8");
|
|
} else if (f.fieldNumber === MCA_TOOL_CALL_ID) {
|
|
toolCallId = f.bytes.toString("utf8");
|
|
} else if (f.fieldNumber === MCA_ARGS) {
|
|
// FieldsEntry { key (1): string, value (2): bytes }
|
|
let key = "";
|
|
let valueBytes: Buffer | null = null;
|
|
for (const entry of decodeFields(f.bytes)) {
|
|
if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) {
|
|
key = entry.bytes.toString("utf8");
|
|
} else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) {
|
|
valueBytes = entry.bytes;
|
|
}
|
|
}
|
|
if (key && valueBytes !== null) {
|
|
args[key] = decodeProtobufValue(valueBytes);
|
|
}
|
|
}
|
|
}
|
|
return { kind: "exec_mcp", execMsgId, execId, toolName, toolCallId, args };
|
|
}
|
|
default:
|
|
// Unknown variant — return null so caller can keep buffering.
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build the ack the server expects after sending RequestContextArgs. We
|
|
* respond with a RequestContext (optionally containing the declared MCP
|
|
* tools so cursor's model knows what's available); cursor's server then
|
|
* proceeds to stream the model's response.
|
|
*
|
|
* The Phase 3 `tools` argument is what unblocks tool-calling — without it
|
|
* cursor's server still streams text but the model never sees the tools as
|
|
* available.
|
|
*/
|
|
export function encodeRequestContextResponse(
|
|
id: number,
|
|
execId: string,
|
|
tools?: McpToolDefinition[]
|
|
): Buffer {
|
|
const rcParts: Buffer[] = [];
|
|
if (tools && tools.length > 0) {
|
|
for (const tool of tools) {
|
|
rcParts.push(encodeMessage(RCS_TOOLS, [encodeMcpToolDefinitionBody(tool)]));
|
|
}
|
|
}
|
|
const requestContext = encodeMessage(RCS_REQUEST_CONTEXT, rcParts);
|
|
const success = encodeMessage(RCR_SUCCESS, [requestContext]);
|
|
const ecm = encodeMessage(ACM_EXEC_CLIENT_MESSAGE, [
|
|
encodeUInt32Field(ECM_ID, id),
|
|
encodeString(ECM_EXEC_ID, execId),
|
|
encodeMessage(ECM_REQUEST_CONTEXT_RESULT, [success]),
|
|
]);
|
|
return wrapConnectFrame(ecm);
|
|
}
|
|
|
|
// RequestContext.tools field number — multiple tool defs are repeated within
|
|
// the inner RequestContext message.
|
|
const RCS_TOOLS = 2;
|
|
|
|
// ─── ExecClientMessage wrapper ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build an ExecClientMessage frame:
|
|
* AgentClientMessage {
|
|
* exec_client_message (2): ExecClientMessage {
|
|
* id (1): execMsgId,
|
|
* exec_id (15): execId,
|
|
* <resultFieldNumber>: resultPayload,
|
|
* }
|
|
* }
|
|
* Connect-RPC framed, ready to write to the h2 stream.
|
|
*
|
|
* `exec_id` is force-set even when empty (matches kaitranntt's behavior).
|
|
*/
|
|
function wrapExecClientMessage(
|
|
execMsgId: number,
|
|
execId: string,
|
|
resultFieldNumber: number,
|
|
resultPayload: Buffer
|
|
): Buffer {
|
|
const ecm = encodeMessage(ACM_EXEC_CLIENT_MESSAGE, [
|
|
encodeUInt32Field(ECM_ID, execMsgId),
|
|
encodeString(ECM_EXEC_ID, execId),
|
|
encodeMessage(resultFieldNumber, [resultPayload]),
|
|
]);
|
|
return wrapConnectFrame(ecm);
|
|
}
|
|
|
|
// ─── Phase 1: built-in tool rejection encoders ─────────────────────────────
|
|
// Cursor's model invokes built-in tools (read/write/shell/grep/etc.) which we
|
|
// can't safely run inside the proxy. We respond with a typed rejection so the
|
|
// model continues without that tool — matches kaitranntt's stance and avoids
|
|
// stalling the h2 stream.
|
|
|
|
function encodePathRejection(path: string, reason: string): Buffer {
|
|
return Buffer.concat([encodeString(REJ_PATH, path), encodeString(REJ_REASON, reason)]);
|
|
}
|
|
|
|
function encodeShellRejection(command: string, workingDir: string, reason: string): Buffer {
|
|
return Buffer.concat([
|
|
encodeString(SREJ_COMMAND, command),
|
|
encodeString(SREJ_WORKING_DIR, workingDir),
|
|
encodeString(SREJ_REASON, reason),
|
|
]);
|
|
}
|
|
|
|
export function encodeExecReadRejected(
|
|
execMsgId: number,
|
|
execId: string,
|
|
path: string,
|
|
reason: string
|
|
): Buffer {
|
|
const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_READ_RESULT, rejected);
|
|
}
|
|
|
|
export function encodeExecWriteRejected(
|
|
execMsgId: number,
|
|
execId: string,
|
|
path: string,
|
|
reason: string
|
|
): Buffer {
|
|
const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_WRITE_RESULT, rejected);
|
|
}
|
|
|
|
export function encodeExecDeleteRejected(
|
|
execMsgId: number,
|
|
execId: string,
|
|
path: string,
|
|
reason: string
|
|
): Buffer {
|
|
const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_DELETE_RESULT, rejected);
|
|
}
|
|
|
|
export function encodeExecLsRejected(
|
|
execMsgId: number,
|
|
execId: string,
|
|
path: string,
|
|
reason: string
|
|
): Buffer {
|
|
const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_LS_RESULT, rejected);
|
|
}
|
|
|
|
export function encodeExecShellRejected(
|
|
execMsgId: number,
|
|
execId: string,
|
|
command: string,
|
|
workingDir: string,
|
|
reason: string
|
|
): Buffer {
|
|
const rejected = encodeMessage(RES_REJECTED, [encodeShellRejection(command, workingDir, reason)]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_SHELL_RESULT, rejected);
|
|
}
|
|
|
|
export function encodeExecBackgroundShellSpawnRejected(
|
|
execMsgId: number,
|
|
execId: string,
|
|
command: string,
|
|
workingDir: string,
|
|
reason: string
|
|
): Buffer {
|
|
const rejected = encodeMessage(RES_REJECTED, [encodeShellRejection(command, workingDir, reason)]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_BACKGROUND_SHELL_SPAWN_RES, rejected);
|
|
}
|
|
|
|
export function encodeExecGrepError(execMsgId: number, execId: string, errMsg: string): Buffer {
|
|
const grepError = encodeString(ERR_MESSAGE, errMsg);
|
|
const errorVariant = encodeMessage(RES_REJECTED, [grepError]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_GREP_RESULT, errorVariant);
|
|
}
|
|
|
|
export function encodeExecFetchError(
|
|
execMsgId: number,
|
|
execId: string,
|
|
url: string,
|
|
errMsg: string
|
|
): Buffer {
|
|
const fetchError = Buffer.concat([encodeString(FERR_URL, url), encodeString(FERR_ERROR, errMsg)]);
|
|
const errorVariant = encodeMessage(RES_REJECTED, [fetchError]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_FETCH_RESULT, errorVariant);
|
|
}
|
|
|
|
export function encodeExecWriteShellStdinError(
|
|
execMsgId: number,
|
|
execId: string,
|
|
errMsg: string
|
|
): Buffer {
|
|
const stdinError = encodeString(ERR_MESSAGE, errMsg);
|
|
const errorVariant = encodeMessage(RES_REJECTED, [stdinError]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_WRITE_SHELL_STDIN_RESULT, errorVariant);
|
|
}
|
|
|
|
export function encodeExecDiagnosticsResult(execMsgId: number, execId: string): Buffer {
|
|
// DiagnosticsResult is empty — there's no rejection variant.
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_DIAGNOSTICS_RESULT, Buffer.alloc(0));
|
|
}
|
|
|
|
// ─── Phase 1: MCP result encoders (used when WE invoke a tool on behalf
|
|
// of the model — Phase 5 wires this to OpenAI tool_calls). ─────────────────
|
|
|
|
export function encodeExecMcpResult(
|
|
execMsgId: number,
|
|
execId: string,
|
|
content: string,
|
|
isError: boolean
|
|
): Buffer {
|
|
// McpTextContent { text } → McpToolResultContentItem.text
|
|
const textContent = encodeMessage(MCC_TEXT, [encodeString(MTC_TEXT, content)]);
|
|
const successFields: Buffer[] = [encodeMessage(MCS_CONTENT, [textContent])];
|
|
if (isError) successFields.push(encodeBoolField(MCS_IS_ERROR, true));
|
|
const success = encodeMessage(MCR_SUCCESS, successFields);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_MCP_RESULT, success);
|
|
}
|
|
|
|
export function encodeExecMcpError(execMsgId: number, execId: string, errMsg: string): Buffer {
|
|
const mcpError = encodeString(ERR_MESSAGE, errMsg);
|
|
const errorVariant = encodeMessage(MCR_ERROR, [mcpError]);
|
|
return wrapExecClientMessage(execMsgId, execId, ECM_MCP_RESULT, errorVariant);
|
|
}
|
|
|
|
// ─── Phase 1: KV blob handshake encoders ───────────────────────────────────
|
|
|
|
/**
|
|
* Reply to KvServerMessage.GetBlobArgs. Server sends `{ id, blob_id, ... }`;
|
|
* we look up the blob in our request-scoped store and reply with the bytes.
|
|
* Echoes the opaque request_metadata cursor sent so the server can match
|
|
* request to response.
|
|
*/
|
|
export function encodeKvGetBlobResult(
|
|
kvId: number,
|
|
blobData: Buffer,
|
|
requestMetadata: Buffer | null = null
|
|
): Buffer {
|
|
const getBlobResult = encodeBytes(GBR_BLOB_DATA, blobData);
|
|
const parts: Buffer[] = [];
|
|
if (kvId !== 0) parts.push(encodeUInt32Field(KCM_ID, kvId));
|
|
parts.push(encodeMessage(KCM_GET_BLOB_RESULT, [getBlobResult]));
|
|
if (requestMetadata && requestMetadata.length > 0) {
|
|
parts.push(encodeBytes(KCM_REQUEST_METADATA, requestMetadata));
|
|
}
|
|
const kcm = encodeMessage(ACM_KV_CLIENT_MESSAGE, parts);
|
|
return wrapConnectFrame(kcm);
|
|
}
|
|
|
|
/**
|
|
* Ack KvServerMessage.SetBlobArgs. Server is saving an assistant turn; we
|
|
* acknowledge with an empty SetBlobResult so the stream proceeds.
|
|
*/
|
|
export function encodeKvSetBlobResult(kvId: number, requestMetadata: Buffer | null = null): Buffer {
|
|
const parts: Buffer[] = [];
|
|
if (kvId !== 0) parts.push(encodeUInt32Field(KCM_ID, kvId));
|
|
parts.push(encodeMessage(KCM_SET_BLOB_RESULT, []));
|
|
if (requestMetadata && requestMetadata.length > 0) {
|
|
parts.push(encodeBytes(KCM_REQUEST_METADATA, requestMetadata));
|
|
}
|
|
const kcm = encodeMessage(ACM_KV_CLIENT_MESSAGE, parts);
|
|
return wrapConnectFrame(kcm);
|
|
}
|
|
|
|
// ─── Phase 1: MCP tool definitions ─────────────────────────────────────────
|
|
|
|
export type McpToolDefinition = {
|
|
name: string;
|
|
description: string;
|
|
inputSchemaBytes: Buffer;
|
|
providerIdentifier?: string;
|
|
toolName?: string;
|
|
};
|
|
|
|
/**
|
|
* Encode the body of an McpToolDefinition (without the wrapping field tag).
|
|
* Use this when embedding a tool def inside a parent message — the parent
|
|
* supplies the field number via encodeMessage(parentField, [body]).
|
|
*/
|
|
export function encodeMcpToolDefinitionBody(def: McpToolDefinition): Buffer {
|
|
const parts: Buffer[] = [
|
|
encodeString(MTD_NAME, def.name),
|
|
encodeString(MTD_DESCRIPTION, def.description),
|
|
encodeBytes(MTD_INPUT_SCHEMA, def.inputSchemaBytes),
|
|
];
|
|
if (def.providerIdentifier) {
|
|
parts.push(encodeString(MTD_PROVIDER_IDENTIFIER, def.providerIdentifier));
|
|
}
|
|
if (def.toolName) {
|
|
parts.push(encodeString(MTD_TOOL_NAME, def.toolName));
|
|
}
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
// ─── Phase 1: JSON Schema → google.protobuf.Value ──────────────────────────
|
|
|
|
/**
|
|
* Convert a JSON object (e.g. an OpenAI tool's input_schema) to bytes
|
|
* encoding a google.protobuf.Value. The result is the body of a Value
|
|
* message — one oneof field set, no outer tag.
|
|
*
|
|
* Used to populate McpToolDefinition.input_schema (which is bytes-typed
|
|
* on the wire even though semantically it's a Value).
|
|
*/
|
|
export function jsonSchemaToProtobufValue(json: unknown): Buffer {
|
|
return encodeProtobufValue(json);
|
|
}
|
|
|
|
/**
|
|
* Reverse of jsonSchemaToProtobufValue: decode google.protobuf.Value bytes
|
|
* back into a JSON-shape value. Used by Phase 5 to translate cursor's
|
|
* McpArgs.args (map<string, bytes-encoded Value>) into the JSON object the
|
|
* OpenAI tool_calls.function.arguments field expects.
|
|
*
|
|
* Handles all six Value variants: null, number (double), string, bool,
|
|
* struct (object), list (array). Unknown fields are skipped.
|
|
*/
|
|
export function decodeProtobufValue(buf: Buffer): unknown {
|
|
let pos = 0;
|
|
while (pos < buf.length) {
|
|
const [t, np] = decodeVarint(buf, pos);
|
|
pos = np;
|
|
const fieldNumber = Number(t >> 3n);
|
|
const wireType = Number(t & 0x7n);
|
|
switch (fieldNumber) {
|
|
case VAL_NULL: {
|
|
if (wireType === WT_VARINT) {
|
|
[, pos] = decodeVarint(buf, pos);
|
|
}
|
|
return null;
|
|
}
|
|
case VAL_NUMBER: {
|
|
if (wireType === 1 && pos + 8 <= buf.length) {
|
|
const value = buf.readDoubleLE(pos);
|
|
pos += 8;
|
|
return value;
|
|
}
|
|
return 0;
|
|
}
|
|
case VAL_STRING: {
|
|
if (wireType === WT_LEN) {
|
|
const [len, np2] = decodeVarint(buf, pos);
|
|
pos = np2;
|
|
const lenN = checkedLen(len, pos, buf);
|
|
const value = buf.subarray(pos, pos + lenN).toString("utf8");
|
|
pos += lenN;
|
|
return value;
|
|
}
|
|
return "";
|
|
}
|
|
case VAL_BOOL: {
|
|
if (wireType === WT_VARINT) {
|
|
const [val, np2] = decodeVarint(buf, pos);
|
|
pos = np2;
|
|
return val !== 0n;
|
|
}
|
|
return false;
|
|
}
|
|
case VAL_STRUCT: {
|
|
if (wireType === WT_LEN) {
|
|
const [len, np2] = decodeVarint(buf, pos);
|
|
pos = np2;
|
|
const lenN = checkedLen(len, pos, buf);
|
|
const inner = buf.subarray(pos, pos + lenN);
|
|
pos += lenN;
|
|
return decodeProtobufStruct(inner);
|
|
}
|
|
return {};
|
|
}
|
|
case VAL_LIST: {
|
|
if (wireType === WT_LEN) {
|
|
const [len, np2] = decodeVarint(buf, pos);
|
|
pos = np2;
|
|
const lenN = checkedLen(len, pos, buf);
|
|
const inner = buf.subarray(pos, pos + lenN);
|
|
pos += lenN;
|
|
return decodeProtobufList(inner);
|
|
}
|
|
return [];
|
|
}
|
|
default:
|
|
// Skip unknown field
|
|
if (wireType === WT_VARINT) {
|
|
[, pos] = decodeVarint(buf, pos);
|
|
} else if (wireType === WT_LEN) {
|
|
const [len, np2] = decodeVarint(buf, pos);
|
|
pos = np2;
|
|
pos += Number(len);
|
|
} else if (wireType === 1) {
|
|
pos += 8;
|
|
} else if (wireType === 5) {
|
|
pos += 4;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function decodeProtobufStruct(buf: Buffer): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
for (const f of decodeFields(buf)) {
|
|
if (f.fieldNumber === STRUCT_FIELDS && f.wireType === 2) {
|
|
let key = "";
|
|
let valueBytes: Buffer | null = null;
|
|
for (const entry of decodeFields(f.bytes)) {
|
|
if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) {
|
|
key = entry.bytes.toString("utf8");
|
|
} else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) {
|
|
valueBytes = entry.bytes;
|
|
}
|
|
}
|
|
if (key && valueBytes) {
|
|
result[key] = decodeProtobufValue(valueBytes);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function decodeProtobufList(buf: Buffer): unknown[] {
|
|
const result: unknown[] = [];
|
|
for (const f of decodeFields(buf)) {
|
|
if (f.fieldNumber === LIST_VALUES && f.wireType === 2) {
|
|
result.push(decodeProtobufValue(f.bytes));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function encodeProtobufValue(value: unknown): Buffer {
|
|
if (value === null || value === undefined) {
|
|
// null_value (1) = NULL_VALUE = 0 (enum)
|
|
return Buffer.concat([encodeTag(VAL_NULL, WT_VARINT), encodeVarint(0)]);
|
|
}
|
|
if (typeof value === "number") {
|
|
return encodeDoubleField(VAL_NUMBER, value);
|
|
}
|
|
if (typeof value === "string") {
|
|
return encodeString(VAL_STRING, value);
|
|
}
|
|
if (typeof value === "boolean") {
|
|
return Buffer.concat([encodeTag(VAL_BOOL, WT_VARINT), encodeVarint(value ? 1 : 0)]);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
// ListValue { values: repeated Value }
|
|
const listParts = value.map((v) => encodeMessage(LIST_VALUES, [encodeProtobufValue(v)]));
|
|
return encodeMessage(VAL_LIST, listParts);
|
|
}
|
|
if (typeof value === "object") {
|
|
// Struct { fields: map<string, Value> }
|
|
const obj = value as Record<string, unknown>;
|
|
const structParts: Buffer[] = [];
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
const entry = Buffer.concat([
|
|
encodeString(MAP_KEY, k),
|
|
encodeMessage(MAP_VALUE, [encodeProtobufValue(v)]),
|
|
]);
|
|
structParts.push(encodeMessage(STRUCT_FIELDS, [entry]));
|
|
}
|
|
return encodeMessage(VAL_STRUCT, structParts);
|
|
}
|
|
// Fallback: encode as null
|
|
return Buffer.concat([encodeTag(VAL_NULL, WT_VARINT), encodeVarint(0)]);
|
|
}
|
|
|
|
// ─── User message extractor (for chat-completions input) ───────────────────
|
|
|
|
export type ChatMessage = {
|
|
role: "user" | "assistant" | "system" | "tool";
|
|
content?: string | Array<{ type: string; text?: string }> | null;
|
|
tool_calls?: Array<{
|
|
id: string;
|
|
type?: "function" | string;
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
tool_call_id?: string;
|
|
};
|
|
|
|
/**
|
|
* Flatten an OpenAI-shaped message list down to a single user-text string
|
|
* suitable for cursor's UserMessage. The agent endpoint expects ONE user
|
|
* message per Run; we concatenate prior conversation as context.
|
|
*
|
|
* Phase 6 cold-resume support: handles `role:"tool"` results and
|
|
* `assistant.tool_calls` so that follow-up turns after an OpenAI tool call
|
|
* round-trip coherently. Format follows kaitranntt's reference impl —
|
|
* cursor's model has been observed to handle this layout reliably.
|
|
*/
|
|
export function flattenMessages(messages: ChatMessage[]): string {
|
|
if (!Array.isArray(messages) || messages.length === 0) return "";
|
|
|
|
const partsToText = (content: ChatMessage["content"]): string => {
|
|
if (typeof content === "string") return content;
|
|
if (content == null) return "";
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.map((p) => (typeof p?.text === "string" ? p.text : ""))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
};
|
|
|
|
// System instructions go first as a labeled prefix. (The cursor executor
|
|
// routes system messages through the KV blob channel — see Phase 7 — but
|
|
// this branch is kept for non-cursor callers.)
|
|
const systemTexts = messages
|
|
.filter((m) => m.role === "system")
|
|
.map((m) => partsToText(m.content))
|
|
.filter(Boolean);
|
|
|
|
const turn = messages.filter((m) => m.role !== "system");
|
|
|
|
// Single-user-message fast path (no tool_calls, no labels).
|
|
if (turn.length === 1 && turn[0].role === "user" && !turn[0].tool_calls) {
|
|
const userText = partsToText(turn[0].content);
|
|
return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${userText}` : userText;
|
|
}
|
|
|
|
// Multi-turn / tool-using format. Each message is labeled. Tool calls
|
|
// and tool results get their own labeled lines.
|
|
const lines: string[] = [];
|
|
for (const m of turn) {
|
|
const text = partsToText(m.content);
|
|
if (m.role === "user") {
|
|
if (text) lines.push(`User: ${text}`);
|
|
} else if (m.role === "assistant") {
|
|
if (text) lines.push(`Assistant: ${text}`);
|
|
if (Array.isArray(m.tool_calls)) {
|
|
for (const tc of m.tool_calls) {
|
|
const args = tc.function?.arguments ?? "";
|
|
lines.push(
|
|
`Assistant called tool ${tc.function?.name ?? "(unknown)"} ` +
|
|
`(${tc.id}) with arguments: ${args}`
|
|
);
|
|
}
|
|
}
|
|
} else if (m.role === "tool") {
|
|
const callId = m.tool_call_id ?? "(unknown)";
|
|
lines.push(`Tool result (${callId}): ${text}`);
|
|
} else {
|
|
if (text) lines.push(`${m.role}: ${text}`);
|
|
}
|
|
}
|
|
const labelled = lines.join("\n\n");
|
|
return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${labelled}` : labelled;
|
|
}
|