mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
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:
committed by
GitHub
parent
717069af8e
commit
4eaa2f4c65
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
53
open-sse/executors/cursor/composer.ts
Normal file
53
open-sse/executors/cursor/composer.ts
Normal 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);
|
||||
}
|
||||
75
open-sse/executors/cursor/prompt.ts
Normal file
75
open-sse/executors/cursor/prompt.ts
Normal 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")}`
|
||||
: "";
|
||||
}
|
||||
49
tests/unit/cursor-executor-split.test.ts
Normal file
49
tests/unit/cursor-executor-split.test.ts
Normal 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({}), "");
|
||||
});
|
||||
Reference in New Issue
Block a user