From 4eaa2f4c65726f1ad10bf4b64c4baccdba58c358 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:41:06 -0300 Subject: [PATCH] refactor(executors): extract pure prompt + composer helpers from cursor (#5960) Extract two pure clusters from the cursor executor into sibling leaves: - cursor/prompt.ts: isRecordLike + toolChoiceDirectiveLine + buildCursorOutputConstraints - cursor/composer.ts: composer thinking-as-content decoding (isComposerModel, visibleComposerContentFromThinking, composerReasoningRemainder + markers) Host imports both back for internal use and re-exports the 3 composer helpers for external importers (tests). Host 1576 -> 1451 LOC. Byte-identical bodies (verbatim multiset prompt 65/65, composer 32/32), leaves have zero imports (no cycle). Adds a split-guard; consumer tests stay green (cursor-composer-thinking, cursor-streaming, cursor-agent-tool-calls, translator-openai-to-cursor, cursor-agent-system-prompt). --- open-sse/executors/cursor.ts | 181 +++++------------------ open-sse/executors/cursor/composer.ts | 53 +++++++ open-sse/executors/cursor/prompt.ts | 75 ++++++++++ tests/unit/cursor-executor-split.test.ts | 49 ++++++ 4 files changed, 212 insertions(+), 146 deletions(-) create mode 100644 open-sse/executors/cursor/composer.ts create mode 100644 open-sse/executors/cursor/prompt.ts create mode 100644 tests/unit/cursor-executor-split.test.ts diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 62cf10f348..ad7a9b68a6 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -38,11 +38,7 @@ import { type McpToolDefinition, type OpenAITool, } from "../utils/cursorAgentProtobuf.ts"; -import { - resolveCursorImages, - extractImageUrls, - CursorImageError, -} from "../utils/cursorImages.ts"; +import { resolveCursorImages, extractImageUrls, CursorImageError } from "../utils/cursorImages.ts"; import { estimateInputTokens, estimateOutputTokens, @@ -62,6 +58,18 @@ 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. @@ -89,78 +97,6 @@ const TOOL_COMMIT_DIRECTIVE = [ // 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 { - 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 @@ -311,61 +247,6 @@ function tryParseJsonError(payload: Buffer): { message: string; status: number } } } -// ─── 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 `` sentinel. Everything AFTER the last `` 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 = ""; - -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 @@ -676,11 +557,19 @@ export function processFrame( ctx.totalText += parseOut.safeDelta; emitChunk(ctx, { content: parseOut.safeDelta }); } - if (parseOut.ready && parseOut.toolCalls.length > 0 && !ctx.composerInlineToolCallsEmitted) { + 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 }); + ctx.toolCalls.push({ + id: tc.id, + name: tc.function.name, + argumentsJson: tc.function.arguments, + }); emitChunk(ctx, { tool_calls: [ { @@ -1435,11 +1324,7 @@ export class CursorExecutor extends BaseExecutor { // 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 - ) { + if (isComposerModel(ctx.model) && !ctx.composerInlineToolCallsEmitted && ctx.totalText) { const parsed = parseComposerToolCalls(ctx.totalText); if (parsed.toolCalls.length > 0) { ctx.composerInlineToolCallsEmitted = true; @@ -1447,7 +1332,11 @@ export class CursorExecutor extends BaseExecutor { 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 }); + ctx.toolCalls.push({ + id: tc.id, + name: tc.function.name, + argumentsJson: tc.function.arguments, + }); emitChunk(ctx, { tool_calls: [ { @@ -1501,17 +1390,17 @@ export class CursorExecutor extends BaseExecutor { // 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 - ) { + 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 }); + ctx.toolCalls.push({ + id: tc.id, + name: tc.function.name, + argumentsJson: tc.function.arguments, + }); } } } diff --git a/open-sse/executors/cursor/composer.ts b/open-sse/executors/cursor/composer.ts new file mode 100644 index 0000000000..73d590992b --- /dev/null +++ b/open-sse/executors/cursor/composer.ts @@ -0,0 +1,53 @@ +// Composer thinking-as-content decoding for the Cursor executor (verbatim, no host imports). + +// ─── 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 `` sentinel. Everything AFTER the last `` 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 = ""; + +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); +} diff --git a/open-sse/executors/cursor/prompt.ts b/open-sse/executors/cursor/prompt.ts new file mode 100644 index 0000000000..ae93452374 --- /dev/null +++ b/open-sse/executors/cursor/prompt.ts @@ -0,0 +1,75 @@ +// Pure prompt/constraint builders for the Cursor executor (verbatim, no host imports). + +export function isRecordLike(v: unknown): v is Record { + 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"). + */ +export 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. + */ +export 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")}` + : ""; +} diff --git a/tests/unit/cursor-executor-split.test.ts b/tests/unit/cursor-executor-split.test.ts new file mode 100644 index 0000000000..bcfdecb791 --- /dev/null +++ b/tests/unit/cursor-executor-split.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the cursor executor pure-helper extraction. +// Two pure leaves: cursor/prompt.ts (isRecordLike + toolChoiceDirectiveLine + +// buildCursorOutputConstraints) and cursor/composer.ts (composer thinking decoding). +// The host re-exports the 3 composer helpers for external importers (tests). +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXE = join(HERE, "../../open-sse/executors"); +const HOST = join(EXE, "cursor.ts"); +const PROMPT = join(EXE, "cursor/prompt.ts"); +const COMPOSER = join(EXE, "cursor/composer.ts"); + +test("leaves are pure and do not import the host", () => { + const prompt = readFileSync(PROMPT, "utf8"); + const composer = readFileSync(COMPOSER, "utf8"); + assert.match(prompt, /export function toolChoiceDirectiveLine\b/); + assert.match(prompt, /export function buildCursorOutputConstraints\b/); + assert.match(composer, /export function isComposerModel\b/); + assert.doesNotMatch(prompt, /from "\.\.\/cursor\.ts"/); + assert.doesNotMatch(composer, /from "\.\.\/cursor\.ts"/); +}); + +test("host re-exports the composer helpers", () => { + const host = readFileSync(HOST, "utf8"); + assert.match(host, /from "\.\/cursor\/composer\.ts"/); + assert.match(host, /from "\.\/cursor\/prompt\.ts"/); +}); + +test("composer thinking decoding behaves via the leaf", async () => { + const { visibleComposerContentFromThinking, composerReasoningRemainder, isComposerModel } = + await import("../../open-sse/executors/cursor/composer.ts"); + assert.equal(isComposerModel("composer-1"), true); + assert.equal(isComposerModel("gpt-4"), false); + assert.equal(visibleComposerContentFromThinking("hiddenvisible"), "visible"); + assert.equal(composerReasoningRemainder("hiddenvisible"), "hidden"); +}); + +test("prompt constraint builder behaves via the leaf", async () => { + const { toolChoiceDirectiveLine, buildCursorOutputConstraints } = + await import("../../open-sse/executors/cursor/prompt.ts"); + assert.match(toolChoiceDirectiveLine("required"), /MUST call at least one/); + assert.equal(toolChoiceDirectiveLine("auto"), ""); + assert.match(buildCursorOutputConstraints({ max_tokens: 100 }), /100 output tokens/); + assert.equal(buildCursorOutputConstraints({}), ""); +});