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).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 18:41:06 -03:00
committed by GitHub
parent 717069af8e
commit 4eaa2f4c65
4 changed files with 212 additions and 146 deletions

View File

@@ -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<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
@@ -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 `</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
@@ -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,
});
}
}
}

View File

@@ -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 `</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);
}

View File

@@ -0,0 +1,75 @@
// Pure prompt/constraint builders for the Cursor executor (verbatim, no host imports).
export 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").
*/
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")}`
: "";
}

View File

@@ -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("hidden</think>visible"), "visible");
assert.equal(composerReasoningRemainder("hidden</think>visible"), "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({}), "");
});