declare const EdgeRuntime: string | undefined; /** * CursorExecutor — talks to Cursor's agent.v1.AgentService/Run endpoint. * * cursor-agent (CLI) and the cursor IDE both use this RPC for every model id * (auto, composer-*, claude-*, gpt-*, gemini-*). The legacy * aiserver.v1.ChatService/StreamUnifiedChatWithTools rejects "auto" and * "composer-*" with errors, so we migrated this executor over. * * Wire format & schema details live in ../utils/cursorAgentProtobuf.ts. */ import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts"; import { buildAgentRequestBody, decodeAgentServerMessage, decodeExecServerEvent, decodeKvServerEvent, encodeRequestContextResponse, encodeKvGetBlobResult, encodeKvSetBlobResult, encodeExecReadRejected, encodeExecWriteRejected, encodeExecDeleteRejected, encodeExecLsRejected, encodeExecShellRejected, encodeExecBackgroundShellSpawnRejected, encodeExecGrepError, encodeExecFetchError, encodeExecWriteShellStdinError, encodeExecDiagnosticsResult, flattenMessages, openAIToolsToMcpDefs, type ChatMessage, type EncodedImage, type ExecServerEvent, type McpToolDefinition, type OpenAITool, } from "../utils/cursorAgentProtobuf.ts"; import { resolveCursorImages, extractImageUrls, CursorImageError } from "../utils/cursorImages.ts"; import { estimateInputTokens, estimateOutputTokens, addBufferToUsage, } from "../utils/usageTracking.ts"; import { getCursorVersion } from "../utils/cursorVersionDetector.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts"; import { parseComposerToolCalls, createStreamingState, feedStreamingChunk, type StreamingState as ComposerStreamingState, } from "../utils/composerToolCalls.ts"; import { cursorSessionManager, type CursorSession } from "../services/cursorSessionManager.ts"; import crypto from "crypto"; import * as fs from "node:fs"; import * as zlib from "node:zlib"; import { promisify } from "node:util"; import { toolChoiceDirectiveLine, buildCursorOutputConstraints } from "./cursor/prompt.ts"; import { isComposerModel, visibleComposerContentFromThinking, composerReasoningRemainder, } from "./cursor/composer.ts"; // Composer helpers re-exported for external importers (tests). export { isComposerModel, visibleComposerContentFromThinking, composerReasoningRemainder, } from "./cursor/composer.ts"; // Reject reason text aligned with kaitranntt/CLIProxyAPIPlus — proven to // keep cursor's model from retrying the same built-in tool indefinitely. // The model adapts and either answers from context or uses declared MCP tools. const BUILTIN_TOOL_REJECT_REASON = "Tool not available in this environment. Use the MCP tools provided instead."; const gunzipAsync = promisify(zlib.gunzip); // Tool-commit directive — adapted from composer-api's TOOL_SYSTEM_DIRECTIVE. // composer-2.5 otherwise narrates intent ("Checking the weather...") and ends // the turn ~20% of the time instead of actually invoking a declared tool. This // directive, prepended to the user text only when the request declares tools, // tells the model to commit to the tool call rather than describe it as prose. const TOOL_COMMIT_DIRECTIVE = [ "You are serving an OpenAI-compatible API request and the client has provided executable tools.", "When a tool is needed to answer (real-time data, web/search lookups, file or project operations), you MUST issue the actual tool call. Do NOT describe what you are about to do as prose and then stop — call the tool.", "Answer directly only when no tool is needed.", "Do not emit duplicate tool calls: call each operation once, then continue after the tool result is returned.", "Never claim that tools are unavailable.", ].join("\n"); // NOTE: composer-api primes the model into "agent mode" with a fabricated // prior switch_mode exchange (AGENT_MODE_PRIMER). On OmniRoute's native-tool // agent endpoint that primer is counterproductive — it references a // non-existent switch_mode tool and measurably LOWERED the tool-call rate in // live A/B (56% vs 69%), so it is intentionally not ported. /** * Build the ExecClientMessage frame that responds to a built-in tool request. * Returns null for the request_context handshake (caller handles separately * to inject MCP tools in Phase 3) and for exec_mcp (model is invoking a * declared MCP tool — Phase 5 surfaces this as an OpenAI tool_calls delta). */ function buildExecRejection(event: ExecServerEvent): Buffer | null { switch (event.kind) { case "exec_request_context": case "exec_mcp": return null; case "exec_read": return encodeExecReadRejected( event.execMsgId, event.execId, event.path, BUILTIN_TOOL_REJECT_REASON ); case "exec_write": return encodeExecWriteRejected( event.execMsgId, event.execId, event.path, BUILTIN_TOOL_REJECT_REASON ); case "exec_delete": return encodeExecDeleteRejected( event.execMsgId, event.execId, event.path, BUILTIN_TOOL_REJECT_REASON ); case "exec_ls": return encodeExecLsRejected( event.execMsgId, event.execId, event.path, BUILTIN_TOOL_REJECT_REASON ); case "exec_grep": return encodeExecGrepError(event.execMsgId, event.execId, BUILTIN_TOOL_REJECT_REASON); case "exec_diagnostics": // Diagnostics has no rejection variant — return an empty success. return encodeExecDiagnosticsResult(event.execMsgId, event.execId); case "exec_shell": case "exec_shell_stream": return encodeExecShellRejected( event.execMsgId, event.execId, event.command, event.workingDir, BUILTIN_TOOL_REJECT_REASON ); case "exec_bg_shell": return encodeExecBackgroundShellSpawnRejected( event.execMsgId, event.execId, event.command, event.workingDir, BUILTIN_TOOL_REJECT_REASON ); case "exec_fetch": return encodeExecFetchError( event.execMsgId, event.execId, event.url, BUILTIN_TOOL_REJECT_REASON ); case "exec_write_shell_stdin": return encodeExecWriteShellStdinError( event.execMsgId, event.execId, BUILTIN_TOOL_REJECT_REASON ); } } const CURSOR_AGENT_HOST = "agentn.global.api5.cursor.sh"; const CURSOR_AGENT_PATH = "/agent.v1.AgentService/Run"; const CURSOR_AGENT_URL = `https://${CURSOR_AGENT_HOST}${CURSOR_AGENT_PATH}`; // Detect cloud environment (Edge runtime, Cloudflare Workers, etc.) const isCloudEnv = () => { if (typeof caches !== "undefined" && typeof caches === "object") return true; if (typeof EdgeRuntime !== "undefined") return true; return false; }; // Lazy import http2 (only in Node.js environment) let http2: typeof import("http2") | null = null; if (!isCloudEnv()) { try { http2 = await import("http2"); } catch { http2 = null; } } // Phase 10: CURSOR_DEBUG=1 enables verbose streaming debug logs (decoded // frame summaries, exec router dispatches, session lifecycle events). // CURSOR_STREAM_DEBUG is kept as a backward-compatible alias. const CURSOR_DEBUG = process.env.CURSOR_DEBUG === "1" || process.env.CURSOR_STREAM_DEBUG === "1"; const debugLog = (...args: unknown[]) => { if (CURSOR_DEBUG) console.log(...args); }; // Phase 8: max wall-clock time before we give up on the upstream and abort // the stream. Cursor's longest-observed plain chat takes ~90s; tool-using // turns can be longer. Five minutes is generous but bounded. A malformed env // value (NaN / non-positive) falls back to the default rather than breaking // setTimeout. const CURSOR_STREAM_TIMEOUT_MS = (() => { const parsed = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : 300000; })(); // Upper bound on a single Connect-RPC frame. The 4-byte length prefix can // declare up to 4 GiB; a corrupt or hostile upstream could send a huge length // that forces driveH2's rolling buffer to grow unbounded (OOM) while it waits // for bytes that never arrive. Real cursor frames are well under 1 MiB // (largest observed: a ~13 KB KV blob), so 16 MiB is a generous ceiling that // turns the failure into a clean stream error instead of memory exhaustion. const CURSOR_MAX_FRAME_BYTES = 16 * 1024 * 1024; type CursorHttpResponse = { status: number; headers: Record; body: Buffer; }; function tryParseJsonError(payload: Buffer): { message: string; status: number } | null { if (payload.length < 2 || payload[0] !== 0x7b) return null; try { const text = payload.toString("utf8"); if (!text.includes('"error"')) return null; const parsed = JSON.parse(text); const err = parsed?.error || {}; const message = err?.details?.[0]?.debug?.details?.title || err?.details?.[0]?.debug?.details?.detail || err?.message || text; const status = err?.code === "resource_exhausted" ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST; return { message, status }; } catch { return null; } } // ─── Phase 4: streaming dispatch context ─────────────────────────────────── // // One StreamCtx flows through a single execute() call. It owns the live // SSE emission state (responseId, created timestamp, model id, role-chunk // flag) plus aggregate state (totalText, tokenDelta) needed for the final // usage chunk and JSON-mode aggregation. Phases 5 (tool calls) and 8 // (end-signal hardening) extend it. export type StreamCtx = { responseId: string; created: number; model: string; emit: (chunk: string) => void; emittedRoleChunk: boolean; totalText: string; thinkingText: string; tokenDelta: number; // End-signal tracking (Phase 8 hardens this further). receivedText: boolean; kvAfterTextSeen: boolean; endReason: "turn_ended" | "kv_after_text" | "tool_calls" | "server_end" | null; // Mid-stream JSON error (rare; emitted once with the error code). midStreamError: { message: string; status: number } | null; // Phase 5: tool-call indexing for parallel calls. Each McpArgs gets a // monotonically-increasing index in the OpenAI delta. emittedToolCalls // tracks how many were emitted so finalizeSseStream picks the right // finish_reason ("tool_calls" vs "stop"). emittedToolCallIndex: number; // Captured tool calls (for JSON-mode aggregation). Each entry maps to // one OpenAI tool_calls[] item. toolCalls: Array<{ id: string; name: string; argumentsJson: string; }>; // Phase 6: maps OpenAI tool_call_id → cursor exec info, so a follow-up // role:"tool" message can be answered on the open h2 stream via // encodeExecMcpResult. pendingToolCalls: Map; // Composer thinking-as-content (decolua/9router#1310): tracks how much of // the visible suffix (after the last ``) has already been streamed // out as `content` deltas, so we only emit the incremental tail per frame. composerVisibleEmittedLength: number; // Composer DeepSeek-format inline tool-call parser state (decolua/9router#1335). // Null for non-Composer models (no overhead). When set, the streaming parser // holds back text inside `<|tool▁calls▁begin|>...<|tool▁calls▁end|>` markers // and emits structured tool_calls SSE chunks once the block closes. composerToolParserState: ComposerStreamingState | null; // True once we've emitted structured tool_calls from the inline Composer parser // (to avoid double-emitting if the block appears in multiple accumulated frames). composerInlineToolCallsEmitted: boolean; }; export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx { return { responseId: `chatcmpl-cursor-${Date.now()}`, created: Math.floor(Date.now() / 1000), model, emit, emittedRoleChunk: false, totalText: "", thinkingText: "", tokenDelta: 0, receivedText: false, kvAfterTextSeen: false, endReason: null, midStreamError: null, emittedToolCallIndex: 0, toolCalls: [], pendingToolCalls: new Map(), composerVisibleEmittedLength: 0, composerToolParserState: isComposerModel(model) ? createStreamingState() : null, composerInlineToolCallsEmitted: false, }; } function emitChunk(ctx: StreamCtx, delta: object, finishReason: string | null = null) { const payload = { id: ctx.responseId, object: "chat.completion.chunk", created: ctx.created, model: ctx.model, choices: [{ index: 0, delta, finish_reason: finishReason }], }; ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); } export function buildCursorUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { const promptTokens = estimateInputTokens(body); const completionTokens = ctx.tokenDelta > 0 ? ctx.tokenDelta : estimateOutputTokens(ctx.totalText.length + ctx.thinkingText.length); const usage: Record = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens, estimated: true, }; if (ctx.thinkingText.length > 0) { usage.completion_tokens_details = { reasoning_tokens: estimateOutputTokens(ctx.thinkingText.length), }; } return addBufferToUsage(usage); } function emitUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { // Always emit a usage chunk on the success path — the OpenAI streaming // contract is that every completed response carries usage. buildCursorUsage // already degrades cleanly to prompt-only counts when the model produced no // text/thinking (e.g. an empty turn), so there's no need to skip it. The // mid-stream-error path in finalizeSseStream returns before calling this, so // errored responses still don't get a spurious usage chunk. const usage = buildCursorUsage(ctx, body); const payload = { id: ctx.responseId, object: "chat.completion.chunk", created: ctx.created, model: ctx.model, choices: [], usage, }; ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); } function emitDone(ctx: StreamCtx) { ctx.emit("data: [DONE]\n\n"); } /** * Process one decoded Connect-RPC frame payload: dispatch ExecServerMessage * events (rejection / context ack / mcp_args), decode AgentServerMessage * interaction updates, and emit OpenAI SSE deltas for any text content. * * Returns true if an end-of-response signal was observed. * * The h2 `req` (used to write rejection acks back on the same stream) is * passed via opts so this function works for both the streaming h2 path * and the buffered fetch fallback (where opts.req is undefined). * * Mutates `ackedExecIds` so each exec_id is dispatched exactly once even * when the same payload is seen multiple times during incremental decoding. */ export function processFrame( payload: Buffer, ctx: StreamCtx, ackedExecIds: Set, opts: { h2Req?: import("http2").ClientHttp2Stream; mcpTools?: McpToolDefinition[]; blobStore?: Map; } = {} ): void { // 1. JSON error envelope (Connect-RPC style — usually status > 200). const jsonError = tryParseJsonError(payload); if (jsonError) { if (ctx.totalText.length === 0) { ctx.midStreamError = jsonError; ctx.endReason = "server_end"; } else { // Already streamed content — terminate cleanly. ctx.endReason = "server_end"; } return; } // 2a. KV server message: cursor requesting a blob (system prompt) or // saving an assistant turn. We reply on the same stream so the model // proceeds. The opaque request_metadata is echoed so cursor can match // request to response. const kvEvent = decodeKvServerEvent(payload); if (kvEvent && opts.h2Req) { if (kvEvent.kind === "kv_get_blob") { const hex = kvEvent.blobId.toString("hex"); const blob = opts.blobStore?.get(hex) ?? Buffer.alloc(0); try { opts.h2Req.write(encodeKvGetBlobResult(kvEvent.kvId, blob, kvEvent.requestMetadata)); } catch {} } else if (kvEvent.kind === "kv_set_blob") { if (opts.blobStore) { opts.blobStore.set(kvEvent.blobId.toString("hex"), kvEvent.blobData); } try { opts.h2Req.write(encodeKvSetBlobResult(kvEvent.kvId, kvEvent.requestMetadata)); } catch {} } } // 2b. ExecServerMessage dispatch (request_context, built-in rejection, mcp). // Dedup by kind+execId+execMsgId — request_context and mcp_args both // arrive with empty execId in the current cursor schema, so a single // execId-only set would collapse them. const event = decodeExecServerEvent(payload); const dedupKey = event ? `${event.kind}:${event.execId}:${event.execMsgId}` : ""; if (event && !ackedExecIds.has(dedupKey)) { ackedExecIds.add(dedupKey); if (event.kind === "exec_request_context") { if (opts.h2Req) { try { // Cursor receives tools via AgentRunRequest.mcp_tools (request body) // — sending them again in the request_context ack causes the // server to stall silently. Empty ack only. opts.h2Req.write(encodeRequestContextResponse(event.execMsgId, event.execId)); } catch {} } } else if (event.kind === "exec_mcp") { // Phase 5: surface the model-invoked MCP tool as an OpenAI tool_calls // SSE delta. Two chunks are emitted per call: an init chunk with the // tool's id+name+empty args, then a chunk with the JSON-stringified // args. Parallel tool calls share one finish chunk (Phase 8 closes). if (!ctx.emittedRoleChunk) { emitChunk(ctx, { role: "assistant", content: "" }); ctx.emittedRoleChunk = true; } const idx = ctx.emittedToolCallIndex++; const openAIToolCallId = generateToolCallId(); const argumentsJson = JSON.stringify(event.args ?? {}); emitChunk(ctx, { tool_calls: [ { index: idx, id: openAIToolCallId, type: "function", function: { name: event.toolName, arguments: "" }, }, ], }); emitChunk(ctx, { tool_calls: [ { index: idx, function: { arguments: argumentsJson }, }, ], }); ctx.toolCalls.push({ id: openAIToolCallId, name: event.toolName, argumentsJson, }); // Phase 6: remember the cursor exec ids so a follow-up role:"tool" // message can be replied with encodeExecMcpResult on the open h2 stream. ctx.pendingToolCalls.set(openAIToolCallId, { execMsgId: event.execMsgId, execId: event.execId, toolName: event.toolName, }); // Cursor pauses after mcp_args waiting for the client to either send // a tool result via ExecMcpResult or close the stream. We mark // endReason now so driveH2 returns; the session manager keeps the h2 // alive for the next OpenAI call (which arrives with role:"tool"). ctx.endReason = "tool_calls"; } else { const rejection = buildExecRejection(event); if (rejection && opts.h2Req) { try { opts.h2Req.write(rejection); } catch {} } } } // 3. Interaction update deltas → OpenAI SSE chunks. let deltas; try { deltas = decodeAgentServerMessage(payload); } catch (err) { debugLog("[cursor-agent] decode failed:", (err as Error).message); return; } for (const d of deltas) { if (d.kind === "text" && d.text) { if (!ctx.emittedRoleChunk) { emitChunk(ctx, { role: "assistant", content: "" }); ctx.emittedRoleChunk = true; } ctx.totalText += d.text; ctx.receivedText = true; emitChunk(ctx, { content: d.text }); } else if (d.kind === "thinking" && d.text) { if (!ctx.emittedRoleChunk) { emitChunk(ctx, { role: "assistant", content: "" }); ctx.emittedRoleChunk = true; } ctx.thinkingText += d.text; ctx.receivedText = true; // Composer (decolua/9router#1310) encodes the visible reply inside the // thinking field, after a final `` marker. Emit the post-marker // suffix as plain `content` (so OpenAI-compatible clients see the reply) // and keep the pre-marker chain-of-thought out of `reasoning_content` — // it was never intended for the user. if (isComposerModel(ctx.model)) { const visible = visibleComposerContentFromThinking(ctx.thinkingText); if (visible.length > ctx.composerVisibleEmittedLength) { // Feed the full accumulated visible text into the DeepSeek inline // tool-call streaming parser (decolua/9router#1335). It tracks how // much has already been safely emitted and returns only the new // safe delta — i.e. text that precedes any `<|tool▁calls▁begin|>` // marker (or a partial prefix of one). When the closing marker // arrives, it sets ready=true and provides the parsed tool_calls. if (ctx.composerToolParserState) { const parseOut = feedStreamingChunk(ctx.composerToolParserState, visible); // composerVisibleEmittedLength tracks what the parser has "emitted" // — stays in sync via state.emitted. ctx.composerVisibleEmittedLength = ctx.composerToolParserState.emitted; if (parseOut.safeDelta) { ctx.totalText += parseOut.safeDelta; emitChunk(ctx, { content: parseOut.safeDelta }); } if ( parseOut.ready && parseOut.toolCalls.length > 0 && !ctx.composerInlineToolCallsEmitted ) { ctx.composerInlineToolCallsEmitted = true; for (const tc of parseOut.toolCalls) { const toolCallIndex = ctx.emittedToolCallIndex++; ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments, }); emitChunk(ctx, { tool_calls: [ { index: toolCallIndex, id: tc.id, type: "function", function: { name: tc.function.name, arguments: tc.function.arguments }, }, ], }); } } } else { // Non-composer or state not initialised — fall back to direct emit. const deltaContent = visible.slice(ctx.composerVisibleEmittedLength); ctx.composerVisibleEmittedLength = visible.length; ctx.totalText += deltaContent; emitChunk(ctx, { content: deltaContent }); } } } else { emitChunk(ctx, { reasoning_content: d.text }); } } else if (d.kind === "token_delta") { ctx.tokenDelta += d.tokens; } else if (d.kind === "turn_ended") { ctx.endReason = "turn_ended"; } else if (d.kind === "tool_call_completed" && ctx.toolCalls.length > 0) { // Phase 6: model paused awaiting tool result. driveH2 returns but the // h2 stream stays open — the session manager keeps it alive for the // next OpenAI call (which will arrive with role:"tool" results). ctx.endReason = "tool_calls"; } else if (d.kind === "kv_server_message" && ctx.receivedText) { // Cursor short-circuits turn_ended for plain chats — kv_server_message // after text means the model finished and the server is saving the // turn. Phase 8 keeps both signals as defense-in-depth. // // Safe vs tool calls: when the model invokes a tool, the exec_mcp event // always arrives at or before this kv checkpoint (verified across many // live composer-2.5 trials — a tool call never follows kv_after_text), so // endReason is already "tool_calls" by the time we get here. Ending on // kv_after_text therefore never truncates a pending tool call. ctx.kvAfterTextSeen = true; ctx.endReason = "kv_after_text"; } } } export class CursorExecutor extends BaseExecutor { constructor() { super("cursor", PROVIDERS.cursor); } buildUrl() { return CURSOR_AGENT_URL; } buildHeaders(credentials) { const accessToken = credentials.accessToken; const ghostMode = credentials.providerSpecificData?.ghostMode !== false; const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; const requestId = crypto.randomUUID(); const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`; // Mirrors cursor-agent's actual headers for agent.v1.AgentService/Run. // Notably: no x-cursor-checksum, no machineId, no x-amzn-trace-id. // Only advertise gzip (not brotli) — our Connect-RPC frame decoder // only handles gzip-compressed message bodies. return { authorization: `Bearer ${cleanToken}`, "backend-traceparent": traceParent, "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "content-type": "application/connect+proto", traceparent: traceParent, "user-agent": "connect-es/1.6.1", "x-cursor-client-type": "cli", "x-cursor-client-version": `cli-${getCursorVersion()}`, "x-ghost-mode": ghostMode ? "true" : "false", "x-original-request-id": requestId, "x-request-id": requestId, }; } /** * Build the request body and return it alongside the request-scoped * blobStore. cursor's models (auto, claude-*, gpt-*) don't reliably * follow system-role content delivered via the KV blob channel — even * though the blob is requested and our reply is accepted, the model * proceeds without applying the prompt. * * As a pragmatic workaround we prepend the system content into the * UserMessage text (the pre-Phase-7 behavior). The KV-blob handshake * machinery is still in place for any future schema where cursor honors * root_prompt_messages_json semantically — verified end-to-end with * wire-tap captures. */ /** * Assemble the user text + resolved tools shared by the sync (transformRequest) * and async (buildRequest) request builders. Image resolution is intentionally * NOT done here — it's async and only the cold-path buildRequest needs it. */ private assembleTextAndTools(body: { messages?: ChatMessage[]; tools?: unknown; tool_choice?: unknown; max_tokens?: unknown; max_completion_tokens?: unknown; stop?: unknown; response_format?: unknown; }): { userText: string; tools: OpenAITool[] | undefined } { const messages: ChatMessage[] = body.messages || []; const declaredTools: OpenAITool[] | undefined = Array.isArray(body.tools) ? (body.tools as OpenAITool[]) : undefined; // tool_choice:"none" means "do not call any tool" — honor it by advertising // no tools at all (matches OpenAI semantics; composer-api does the same). const tools = body.tool_choice === "none" ? undefined : declaredTools; // flattenMessages prepends any role:"system" messages into the user // text (proven path that cursor's models honor). Image parts in the content // are ignored here (they carry no text) and resolved separately. let userText = flattenMessages(messages); // When the request declares tools, prepend the tool-commit directive so // composer-2.5 reliably invokes them instead of narrating intent and // stopping. Measured live: tool-call rate ~53% → ~88% with the directive. // tool_choice "required"/specific-function add a forcing line on top. // Default-on; set CURSOR_TOOL_DIRECTIVE=0 to opt out. See TOOL_COMMIT_DIRECTIVE. if (tools && tools.length > 0 && process.env.CURSOR_TOOL_DIRECTIVE !== "0") { userText = `${TOOL_COMMIT_DIRECTIVE}${toolChoiceDirectiveLine(body.tool_choice)}\n\n${userText}`; } // Surface OpenAI output params cursor ignores natively (response_format / // max_tokens / stop) as trailing prompt constraints. userText += buildCursorOutputConstraints(body); return { userText, tools }; } /** * Resolve any OpenAI image_url parts in the request's user messages into * inlined cursor images. Returns undefined when the request carries no * images (keeps the request byte-identical to the text-only path). Throws * CursorImageError on invalid / oversized / SSRF-blocked input. */ private async resolveRequestImages(body: { messages?: ChatMessage[]; }): Promise { const messages: ChatMessage[] = body.messages || []; const imageUrls: string[] = []; for (const m of messages) { // Images only ride on user turns (the openai-to-cursor translator keeps // them only there). System/assistant/tool turns carry no vision input. if (m.role === "user") { for (const u of extractImageUrls(m.content)) imageUrls.push(u); } } if (imageUrls.length === 0) return undefined; return resolveCursorImages(imageUrls); } private async buildRequest( model: string, body: { messages?: ChatMessage[]; tools?: unknown; tool_choice?: unknown; conversation_id?: string; max_tokens?: unknown; max_completion_tokens?: unknown; stop?: unknown; response_format?: unknown; } ): Promise<{ body: Uint8Array; blobStore: Map }> { const { userText, tools } = this.assembleTextAndTools(body); const images = await this.resolveRequestImages(body); const blobStore = new Map(); const requestBody = buildAgentRequestBody({ modelId: model, userText, conversationId: body.conversation_id, tools, blobStore, images, }); return { body: requestBody, blobStore }; } transformRequest(model, body, _stream, _credentials) { // Sync interface method (not used by cursor's own execute() path, which // uses the async buildRequest). Text-only — image resolution is async. const { userText, tools } = this.assembleTextAndTools(body); const blobStore = new Map(); return buildAgentRequestBody({ modelId: model, userText, conversationId: body.conversation_id, tools, blobStore, }); } // ─── h2 lifecycle: open + drive (Phase 4 streaming refactor) ───────────── // // openH2 establishes the bidirectional stream and waits for the response // headers (so we can decide whether to commit to a streaming SSE Response // or return an error). driveH2 then consumes data events incrementally, // dispatching frames through processFrame so SSE chunks land on the // ReadableStream controller as the upstream produces them. // // The fetch fallback (cloud envs without http2) preserves the legacy // buffer-then-decode behavior — Connect-RPC bidirectional ack-on-same-stream // can't run over a one-shot fetch anyway. private async openH2( url: string, headers: Record, body: Uint8Array, signal?: AbortSignal ): Promise<{ status: number; headers: Record; client: import("http2").ClientHttp2Session; req: import("http2").ClientHttp2Stream; initialBytes: Buffer; consumeError: () => Promise; }> { if (!http2) throw new Error("http2 module not available"); return new Promise((resolve, reject) => { const urlObj = new URL(url); const client = http2!.connect(`https://${urlObj.host}`); const earlyChunks: Buffer[] = []; let resolved = false; client.on("error", (err) => { if (!resolved) reject(err); }); const req = client.request({ ":method": "POST", ":path": urlObj.pathname, ":authority": urlObj.host, ":scheme": "https", ...headers, }); const onAbort = () => { try { req.close(); client.close(); } catch {} if (!resolved) { resolved = true; reject(new Error("aborted")); } }; if (signal) signal.addEventListener("abort", onAbort); req.on("response", (h) => { if (resolved) return; resolved = true; const status = Number(h[":status"] ?? HTTP_STATUS.SERVER_ERROR); // For non-200 statuses, drain the remaining body for an error message. // The caller calls consumeError() to await the full body. const consumeError = () => new Promise((res) => { const out = [...earlyChunks]; req.on("data", (c) => out.push(Buffer.from(c))); req.on("end", () => { try { req.close(); client.close(); } catch {} if (signal) signal.removeEventListener("abort", onAbort); res(Buffer.concat(out)); }); req.on("error", () => { try { req.close(); client.close(); } catch {} if (signal) signal.removeEventListener("abort", onAbort); res(Buffer.concat(out)); }); }); resolve({ status, headers: h as Record, client, req, initialBytes: Buffer.concat(earlyChunks), consumeError, }); }); // Buffer any data that arrives before the response event resolves. // (In practice the response event fires first, but this guards against // implementation differences in node:http2.) req.on("data", (chunk) => { if (!resolved) earlyChunks.push(Buffer.from(chunk)); }); req.on("error", (err) => { if (!resolved) { resolved = true; if (signal) signal.removeEventListener("abort", onAbort); reject(err); } }); // Bidirectional streaming: write the init message but DO NOT send // END_STREAM — cursor's server stops responding once we close our side. // Guard the write like every h2Req.write in processFrame: a synchronous // failure here (e.g. stream already torn down) would otherwise leave the // request hung until the safety timeout instead of failing fast. try { req.write(body); } catch (err) { if (!resolved) { resolved = true; if (signal) signal.removeEventListener("abort", onAbort); try { req.close(); client.close(); } catch {} reject(err instanceof Error ? err : new Error(String(err))); } } }); } /** * Drive an open h2 stream to completion. processFrame populates ctx as * each Connect-RPC frame is decoded; the loop closes when ctx.endReason * is set (turn_ended, kv_after_text, server_end) or the stream errors. * * Phase 8 will add a max-stream safety timeout here. */ private driveH2( h2: { req: import("http2").ClientHttp2Stream; client: import("http2").ClientHttp2Session; initialBytes: Buffer; }, ctx: StreamCtx, mcpTools: McpToolDefinition[] | undefined, blobStore: Map | undefined, signal?: AbortSignal ): Promise { const ackedExecIds = new Set(); // Rolling buffer: chunks arrive on `data`, get appended, and consumed // frames are sliced off so we don't re-scan + re-concat on every event // (avoids O(N²) for long-running streams). let buf: Buffer = h2.initialBytes.length > 0 ? h2.initialBytes : Buffer.alloc(0); return new Promise((resolve, reject) => { let scanning = false; let settled = false; // Phase 8: safety timeout. If neither turn_ended, kv_after_text, nor // server-end fires within CURSOR_STREAM_TIMEOUT_MS, abort the stream // so a stuck upstream doesn't keep the response open indefinitely. const safetyTimer = setTimeout(() => { if (ctx.endReason) return; debugLog("[cursor-agent] stream safety timeout fired"); teardown(); reject(new Error("cursor-agent stream timed out")); }, CURSOR_STREAM_TIMEOUT_MS); const onData = (chunk: Buffer) => { if (CURSOR_DEBUG && process.env.CURSOR_DUMP_FILE) { fs.appendFileSync(process.env.CURSOR_DUMP_FILE, chunk); } buf = buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([buf, chunk]); void tryScan(); }; const onEnd = () => { if (settled) return; settled = true; if (!ctx.endReason) ctx.endReason = "server_end"; detachListeners(); resolve(); }; const onErr = (err: Error) => { if (settled) return; settled = true; teardown(); reject(err); }; const onAbort = () => { if (settled) return; settled = true; teardown(); reject(new Error("aborted")); }; // detachListeners removes data/end/error/abort handlers and clears the // safety timer. Called on successful resolve when the caller keeps the // h2 alive (Phase 6 session reuse). const detachListeners = () => { clearTimeout(safetyTimer); h2.req.off("data", onData); h2.req.off("end", onEnd); h2.req.off("error", onErr); if (signal) signal.removeEventListener("abort", onAbort); }; // teardown additionally closes the h2 stream. Used on error / abort / // safety-timeout — the connection isn't worth keeping at that point. const teardown = () => { detachListeners(); try { h2.req.close(); h2.client.close(); } catch {} }; if (signal) signal.addEventListener("abort", onAbort); const hasCompleteFrame = () => buf.length >= 5 && buf.length >= 5 + buf.readUInt32BE(1); const tryScan = async () => { if (scanning || settled) return; scanning = true; try { let pos = 0; while (!settled && pos + 5 <= buf.length) { const length = buf.readUInt32BE(pos + 1); if (length > CURSOR_MAX_FRAME_BYTES) { // Refuse to buffer an implausibly large frame — fail fast instead // of letting the rolling buffer grow toward OOM. settled = true; teardown(); reject(new Error(`cursor-agent frame too large (${length} bytes)`)); return; } if (pos + 5 + length > buf.length) break; // partial frame; wait const flag = buf[pos]; const raw = buf.subarray(pos + 5, pos + 5 + length); // Per-frame error isolation: if gunzip or processFrame throws on // one frame, log and skip past it instead of getting stuck on // the same offset and hanging until the safety timer fires. try { const payload = flag & 0x1 ? await gunzipAsync(raw) : raw; if (settled) return; processFrame(payload, ctx, ackedExecIds, { h2Req: h2.req, mcpTools, blobStore }); } catch (err) { debugLog( "[cursor-agent] frame decode failed at pos", pos, ":", (err as Error).message ); } pos += 5 + length; if (ctx.endReason) { buf = buf.subarray(pos); settled = true; detachListeners(); resolve(); return; } } // Splice off processed bytes so the buffer stays bounded. if (pos > 0) buf = buf.subarray(pos); } finally { scanning = false; } if (!settled && hasCompleteFrame()) { void tryScan(); } }; h2.req.on("data", onData); h2.req.on("end", onEnd); h2.req.on("error", onErr); // Process any bytes already buffered from openH2. void tryScan(); }); } async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { const url = this.buildUrl(); const headers = this.buildHeaders(credentials); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const messages: ChatMessage[] = body.messages || []; const conversationId: string = typeof body.conversation_id === "string" && body.conversation_id ? body.conversation_id : crypto.randomUUID(); const lastMessage = messages[messages.length - 1]; const isToolFollowUp = lastMessage?.role === "tool"; // Tools embedded in the RequestContext ack throughout the turn — // synced with mcp_tools in the encoded request body. const mcpTools: McpToolDefinition[] | undefined = Array.isArray(body.tools) ? openAIToolsToMcpDefs(body.tools as OpenAITool[]) : undefined; // Sanitize error messages: strip stack traces and absolute paths to // prevent information exposure. Shared helper in utils/error.ts. const buildErrorResponse = (status: number, message: string, type = "invalid_request_error") => new Response( JSON.stringify({ error: { message: sanitizeErrorMessage(message), type, code: "" } }), { status, headers: { "Content-Type": "application/json" } } ); // Cursor's agent.v1.AgentService/Run is a bidirectional Connect-RPC: // request_context, KV blob lookups, and exec rejections must be // written back on the same h2 stream while the response is still // being read. One-shot fetch can't do that, so cloud/edge runtimes // without node:http2 cannot drive cursor at all — fail fast with a // clear error rather than silently producing incomplete output. if (!http2) { return { response: buildErrorResponse( 501, "Cursor provider requires Node.js http2, which is unavailable in this runtime (Edge / Cloudflare Workers / similar). Run OmniRoute on a Node.js runtime to use cursor.", "unsupported_runtime" ), url, headers, transformedBody: body, }; } // ── h2 path with inline session manager (Phase 6) ── // // 1. If this is a tool-result follow-up (last message role:"tool") AND // we have an alive session for the conversation, send the tool // result on the existing h2 stream (inline resume). // 2. Otherwise, open a fresh h2 stream, send a new RunRequest, and // register it as a session. // // Cold-resume fallback (acquire returns undefined, or sendToolResult // doesn't match): always lands on path #2, which now flattens the full // history (including role:"tool" messages) into UserText via // flattenMessages. type H2Like = { req: import("http2").ClientHttp2Stream; client: import("http2").ClientHttp2Session; initialBytes: Buffer; }; let session: CursorSession | undefined; let h2: H2Like; let blobStore: Map; if (isToolFollowUp) { session = cursorSessionManager.acquire(conversationId); } if (session) { // Inline resume: send ExecMcpResult only for tool messages whose // tool_call_id is currently pending in this session. Older tool // messages from prior turns are already consumed by cursor and // sit in the request history harmlessly — sending them again // would either be a no-op or wedge the session, so we skip. // We require at least one match so we don't reuse the session // for a request that has no relevant tool results. blobStore = session.blobStore; let matched = 0; let hadFailure = false; for (const msg of messages) { if (msg.role !== "tool") continue; const id = msg.tool_call_id ?? ""; if (!session.pendingToolCalls.has(id)) continue; const content = typeof msg.content === "string" ? msg.content : ""; if (cursorSessionManager.sendToolResult(session, id, content, false)) { matched++; } else { hadFailure = true; break; } } if (matched === 0 || hadFailure) { cursorSessionManager.close(session); session = undefined; } else { h2 = { client: session.h2Client, req: session.h2Req, initialBytes: Buffer.alloc(0), }; } } if (!session) { // Cold path: open fresh h2 stream with the full message history // flattened into UserText (Phase 6 flattenMessages handles role:"tool" // and assistant.tool_calls). buildRequest also resolves any image_url // parts (base64 / remote) into inlined cursor images. let built; try { built = await this.buildRequest(model, body); } catch (err) { // Image resolution failures (invalid / oversized / SSRF-blocked) are // client errors — return a sanitized 400 rather than a 500. if (err instanceof CursorImageError) { return { response: buildErrorResponse(err.status, err.message, "invalid_request_error"), url, headers, transformedBody: body, }; } const message = err instanceof Error ? err.message : String(err); return { response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), url, headers, transformedBody: body, }; } blobStore = built.blobStore; let opened; try { opened = await this.openH2(url, headers, built.body, signal); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), url, headers, transformedBody: body, }; } if (opened.status !== 200) { const errBuf = await opened.consumeError(); const errText = errBuf.toString("utf8") || "Unknown error"; return { response: buildErrorResponse(opened.status, `[${opened.status}]: ${errText}`), url, headers, transformedBody: body, }; } h2 = opened; session = cursorSessionManager.open(conversationId, opened.client, opened.req, blobStore); } // Closure to share the post-drive lifecycle between stream/non-stream paths. const sessionToUse = session; const finishLifecycle = (ctx: StreamCtx, errored: boolean) => { // Persist any new pendingToolCalls from this turn into the session. for (const [id, info] of ctx.pendingToolCalls) { sessionToUse.pendingToolCalls.set(id, info); } if (errored || ctx.endReason !== "tool_calls") { cursorSessionManager.close(sessionToUse); } else { cursorSessionManager.release(sessionToUse, "awaiting_tool_result"); } }; // Stream mode: ReadableStream that emits SSE chunks as they're decoded. if (stream !== false) { const enc = new TextEncoder(); const sseStream = new ReadableStream( { start: async (controller) => { const ctx = newStreamCtx(model, (s) => controller.enqueue(enc.encode(s))); try { await this.driveH2(h2, ctx, mcpTools, blobStore, signal); this.finalizeSseStream(ctx, body); finishLifecycle(ctx, false); controller.close(); } catch (err) { finishLifecycle(ctx, true); controller.error(err); } }, }, { highWaterMark: 16384 } ); return { response: new Response(sseStream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }), url, headers, transformedBody: body, }; } // Non-streaming: drive to completion, return chat.completion JSON. const ctx = newStreamCtx(model, () => {}); try { await this.driveH2(h2, ctx, mcpTools, blobStore, signal); } catch (err) { finishLifecycle(ctx, true); const message = err instanceof Error ? err.message : String(err); return { response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), url, headers, transformedBody: body, }; } finishLifecycle(ctx, false); return { response: this.buildResponseFromCtx(ctx, body), url, headers, transformedBody: body, }; } /** * Emit the trailing SSE chunks (finish + usage + DONE) onto an already-open * stream. Called once driveH2 returns and ctx.endReason is set. The * mid-stream-error path emits an error chunk instead. */ private finalizeSseStream(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { if (ctx.midStreamError && ctx.totalText.length === 0) { const payload = { id: ctx.responseId, object: "chat.completion.chunk", created: ctx.created, model: ctx.model, choices: [], error: { message: ctx.midStreamError.message, type: ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED ? "rate_limit_error" : "api_error", }, }; ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); ctx.emit("data: [DONE]\n\n"); return; } if (!ctx.emittedRoleChunk) { // Edge case: empty response. Emit a role chunk so clients see at least // one delta before finish. emitChunk(ctx, { role: "assistant", content: "" }); } // End-of-stream Composer inline tool-call fallback (decolua/9router#1335): // if the entire response arrived as a single big chunk (or the streaming // parser state never reached "ready"), try a full non-streaming parse on // the accumulated visible content so we still emit structured tool_calls // and don't leak the markers as plain text. if (isComposerModel(ctx.model) && !ctx.composerInlineToolCallsEmitted && ctx.totalText) { const parsed = parseComposerToolCalls(ctx.totalText); if (parsed.toolCalls.length > 0) { ctx.composerInlineToolCallsEmitted = true; // Replace totalText with the residual (markers stripped). ctx.totalText = parsed.content; for (const tc of parsed.toolCalls) { const toolCallIndex = ctx.emittedToolCallIndex++; ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments, }); emitChunk(ctx, { tool_calls: [ { index: toolCallIndex, id: tc.id, type: "function", function: { name: tc.function.name, arguments: tc.function.arguments }, }, ], }); } } } // OpenAI finish_reason: "tool_calls" if the model invoked any declared // tool, else "stop". A turn with mixed text + tool_calls finishes with // "tool_calls" (the tool calls are the actionable signal for the client). const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; emitChunk(ctx, {}, finishReason); emitUsage(ctx, body); emitDone(ctx); } /** * Build a non-streaming chat.completion JSON Response from a fully-driven * StreamCtx. The streaming path emits chunks live via finalizeSseStream * and never calls this method. */ private buildResponseFromCtx(ctx: StreamCtx, body: { messages?: ChatMessage[] }): Response { if (ctx.midStreamError && ctx.totalText.length === 0) { return new Response( JSON.stringify({ error: { message: ctx.midStreamError.message, type: ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED ? "rate_limit_error" : "api_error", }, }), { status: ctx.midStreamError.status, headers: { "Content-Type": "application/json" }, } ); } // Non-streaming: chat.completion shape. Include tool_calls in the // assistant message when the model invoked any (Phase 5). // Composer DeepSeek inline tool-call fallback (decolua/9router#1335): for // non-streaming requests, the streaming parser never runs — parse the // accumulated visible content once here instead. if (isComposerModel(ctx.model) && !ctx.composerInlineToolCallsEmitted && ctx.totalText) { const parsed = parseComposerToolCalls(ctx.totalText); if (parsed.toolCalls.length > 0) { ctx.composerInlineToolCallsEmitted = true; ctx.totalText = parsed.content; for (const tc of parsed.toolCalls) { ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments, }); } } } const usage = buildCursorUsage(ctx, body); const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; const message: { role: "assistant"; content: string | null; reasoning_content?: string; tool_calls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string }; }>; } = { role: "assistant", content: ctx.totalText.length > 0 ? ctx.totalText : null, }; if (ctx.thinkingText.length > 0) { // Composer: strip the visible reply (after ``) from the reasoning // payload so it is not duplicated — it already lives in message.content // via the processFrame thinking handler. const reasoningPayload = isComposerModel(ctx.model) ? composerReasoningRemainder(ctx.thinkingText) : ctx.thinkingText; if (reasoningPayload.length > 0) { message.reasoning_content = reasoningPayload; } } if (ctx.toolCalls.length > 0) { message.tool_calls = ctx.toolCalls.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.argumentsJson }, })); } return new Response( JSON.stringify({ id: ctx.responseId, object: "chat.completion", created: ctx.created, model: ctx.model, choices: [ { index: 0, message, finish_reason: finishReason, }, ], usage, }), { status: 200, headers: { "Content-Type": "application/json" } } ); } async refreshCredentials() { return null; } } export default CursorExecutor;