/** * 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 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 const LIST_VALUES = 1; // ListValue.values = repeated Value // proto3 map 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 { 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 = { "": "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:} 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; // 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; }; 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; 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 = {}; 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, * : 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) 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 { const result: Record = {}; 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 } const obj = value as Record; 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; }