Spaces:
Runtime error
Runtime error
| 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"; | |
| // 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. | |
| function isRecordLike(v: unknown): v is Record<string, unknown> { | |
| return typeof v === "object" && v !== null; | |
| } | |
| /** | |
| * Translate OpenAI `tool_choice` into an extra directive line — cursor's agent | |
| * endpoint has no native equivalent. `"required"` forces some tool; a specific | |
| * `{type:"function", function:{name}}` forces that tool. `"auto"`/`"none"`/ | |
| * absent add nothing here ("none" is handled by dropping tools entirely). | |
| * Ported from composer-api (directToolChoiceHint / tool_choice === "required"). | |
| */ | |
| function toolChoiceDirectiveLine(toolChoice: unknown): string { | |
| if (toolChoice === "required") { | |
| return "\nYou MUST call at least one of the available tools now; do not answer without calling a tool."; | |
| } | |
| if ( | |
| isRecordLike(toolChoice) && | |
| toolChoice.type === "function" && | |
| isRecordLike(toolChoice.function) && | |
| typeof toolChoice.function.name === "string" && | |
| toolChoice.function.name | |
| ) { | |
| return `\nYou MUST call the \`${toolChoice.function.name}\` tool now and not any other tool.`; | |
| } | |
| return ""; | |
| } | |
| /** | |
| * Build an OUTPUT CONSTRAINTS block from OpenAI request params that cursor's | |
| * agent endpoint silently ignores (response_format / max_tokens / stop), so | |
| * they're surfaced to the model as prompt instructions instead. Ported from | |
| * composer-api (appendChatOptions / appendJsonConstraint / appendStopConstraint). | |
| * Returns "" when no constraints apply. | |
| */ | |
| function buildCursorOutputConstraints(body: { | |
| max_tokens?: unknown; | |
| max_completion_tokens?: unknown; | |
| stop?: unknown; | |
| response_format?: unknown; | |
| }): string { | |
| const constraints: string[] = []; | |
| const rawMax = body.max_completion_tokens ?? body.max_tokens; | |
| const maxTokens = typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.floor(rawMax) : 0; | |
| if (maxTokens > 0) { | |
| constraints.push(`Keep the answer within about ${maxTokens} output tokens.`); | |
| } | |
| const stop = body.stop; | |
| if (typeof stop === "string" && stop) { | |
| constraints.push(`Do not include any text at or after this stop sequence: ${stop}`); | |
| } else if (Array.isArray(stop) && stop.length) { | |
| constraints.push(`Stop before any of these sequences: ${stop.filter(Boolean).join(", ")}`); | |
| } | |
| const fmt = body.response_format; | |
| if (isRecordLike(fmt)) { | |
| if (fmt.type === "json_object") { | |
| constraints.push("Return a single valid JSON object and no surrounding prose or code fences."); | |
| } else if (fmt.type === "json_schema") { | |
| const js = isRecordLike(fmt.json_schema) ? fmt.json_schema.schema : fmt.schema; | |
| constraints.push( | |
| `Return only valid JSON (no prose or code fences) matching this schema: ${JSON.stringify(js ?? fmt)}` | |
| ); | |
| } | |
| } | |
| return constraints.length | |
| ? `\n\nOUTPUT CONSTRAINTS:\n${constraints.map((c) => `- ${c}`).join("\n")}` | |
| : ""; | |
| } | |
| /** | |
| * 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<string, unknown>; | |
| 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; | |
| } | |
| } | |
| // ─── Composer thinking-as-content decoding ───────────────────────────────── | |
| // | |
| // The Cursor `composer-*` family encodes its visible reply inside the | |
| // `thinking` field, marked off from the (private) chain-of-thought by a | |
| // final `</think>` sentinel. Everything AFTER the last `</think>` is the | |
| // user-facing reply; the prefix must stay hidden. | |
| // | |
| // Ported from decolua/9router#1310 by Noé Rivera. Same algorithm, adapted | |
| // to OmniRoute's StreamCtx-based pipeline so streaming + non-streaming | |
| // share the accumulation path. | |
| const COMPOSER_THINK_END = "</think>"; | |
| export function isComposerModel(model: string | undefined | null): boolean { | |
| const id = String(model ?? "") | |
| .split("/") | |
| .pop(); | |
| return /^composer(?:-|$)/i.test(id ?? ""); | |
| } | |
| // Composer's protobuf sometimes wraps the visible suffix in sentinel tags: | |
| // `<|final|>` (full-width pipes) or `<|final|>` (ASCII), optionally closed | |
| // with a matching `<|/final|>` / `<|/final|>`. These are protocol-internal | |
| // and must never leak to OpenAI-compatible clients (decolua/9router#1316). | |
| const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i; | |
| const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i; | |
| const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/; | |
| const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/; | |
| export function visibleComposerContentFromThinking(thinking: string): string { | |
| if (!thinking) return ""; | |
| const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); | |
| if (endIdx < 0) return ""; | |
| let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); | |
| if (COMPOSER_OPEN_MARKER.test(visible)) { | |
| visible = visible.replace(COMPOSER_OPEN_MARKER, ""); | |
| } else if ( | |
| COMPOSER_PARTIAL_OPEN.test(visible) || | |
| COMPOSER_PARTIAL_OPEN_PIPE.test(visible) | |
| ) { | |
| // A streamed chunk delivered only a partial opening marker (e.g. `<` or | |
| // `<|fin`). Hold back everything until more data arrives so the marker | |
| // fragment never leaks as content. | |
| return ""; | |
| } | |
| return visible.replace(COMPOSER_CLOSE_MARKER, "").trim(); | |
| } | |
| export function composerReasoningRemainder(thinking: string): string { | |
| if (!thinking) return ""; | |
| const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); | |
| if (endIdx < 0) return thinking; | |
| return thinking.slice(0, endIdx); | |
| } | |
| // ─── 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<string, { execMsgId: number; execId: string; toolName: string }>; | |
| // Composer thinking-as-content (decolua/9router#1310): tracks how much of | |
| // the visible suffix (after the last `</think>`) 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<string, unknown> = { | |
| 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<string>, | |
| opts: { | |
| h2Req?: import("http2").ClientHttp2Stream; | |
| mcpTools?: McpToolDefinition[]; | |
| blobStore?: Map<string, Buffer>; | |
| } = {} | |
| ): 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 `</think>` 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<EncodedImage[] | undefined> { | |
| 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<string, Buffer> }> { | |
| const { userText, tools } = this.assembleTextAndTools(body); | |
| const images = await this.resolveRequestImages(body); | |
| const blobStore = new Map<string, Buffer>(); | |
| 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<string, Buffer>(); | |
| 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<string, string>, | |
| body: Uint8Array, | |
| signal?: AbortSignal | |
| ): Promise<{ | |
| status: number; | |
| headers: Record<string, string | number>; | |
| client: import("http2").ClientHttp2Session; | |
| req: import("http2").ClientHttp2Stream; | |
| initialBytes: Buffer; | |
| consumeError: () => Promise<Buffer>; | |
| }> { | |
| 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<Buffer>((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<string, string | number>, | |
| 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<string, Buffer> | undefined, | |
| signal?: AbortSignal | |
| ): Promise<void> { | |
| const ackedExecIds = new Set<string>(); | |
| // 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<string, Buffer>; | |
| 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 `</think>`) 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; | |